505 lines
18 KiB
Go
505 lines
18 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
"github.com/krow/krow-backend/go-api/internal/repo"
|
|
)
|
|
|
|
// Moving a candidate through the funnel: the platform's second write.
|
|
//
|
|
// It exists to replace a capability rather than to add one. The browser panel
|
|
// could already mark an interview ready, through a matcher that recognised the
|
|
// phrasing and called the app's own mutation — instant, free, and reachable
|
|
// only by the handful of sentences somebody wrote a pattern for. Routing past
|
|
// that machinery is only honest if nothing is lost, and this is the thing that
|
|
// would otherwise have been lost.
|
|
//
|
|
// The second write is also the first test of whether the confirmation
|
|
// mechanism GENERALISES. assign_worker could have been special-cased into the
|
|
// gate a dozen ways without anyone noticing. This tool shares every piece of it
|
|
// — the binding, the renderer contract, the single-use token, the replay — and
|
|
// adds none of its own.
|
|
|
|
/* ── Vocabulary ─────────────────────────────────────────────────────────── */
|
|
|
|
// applicationStages is the funnel, in order.
|
|
//
|
|
// From the `application_status` enum, and in the enum's own order, because
|
|
// "forward" and "backward" are only meaningful against a fixed sequence. The
|
|
// two terminal outcomes sit outside it: hiring and rejecting are decisions, not
|
|
// positions in a queue, and treating them as "further along" would let a
|
|
// request to advance somebody one step quietly hire them.
|
|
var applicationStages = []string{"applied", "ai_screened", "shortlisted", "interview"}
|
|
|
|
// terminalStages are the outcomes a candidate can be moved to from anywhere.
|
|
var terminalStages = []string{"hired", "rejected"}
|
|
|
|
// settledStages are the outcomes that take somebody out of the running.
|
|
//
|
|
// `assigned` appears here and nowhere else in this file's vocabulary: it is not
|
|
// a stage this tool may *set* (see knownStage), but a candidate already placed
|
|
// on a shift is not waiting on a decision either. Leaving it out of this list is
|
|
// how a settled candidate gets chased twice.
|
|
var settledStages = []string{"hired", "rejected", "assigned"}
|
|
|
|
// quotedList renders a package-level vocabulary as a SQL literal list. Never
|
|
// reachable from caller input — every caller passes one of the vars above.
|
|
func quotedList(vs []string) string {
|
|
out := make([]string, len(vs))
|
|
for i, v := range vs {
|
|
out[i] = "'" + v + "'"
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// listableStage reports whether a stage can be asked for by name. Wider than
|
|
// knownStage: every status in the enum can be read, but `assigned` cannot be set.
|
|
func listableStage(s string) bool {
|
|
return knownStage(s) || s == "assigned"
|
|
}
|
|
|
|
// stageLabels are how a person reads a stage. The enum values are for the
|
|
// database; a confirmation dialog saying `ai_screened` is a dialog written for
|
|
// the schema rather than for the person approving it.
|
|
var stageLabels = map[string]string{
|
|
"applied": "Applied",
|
|
"ai_screened": "Screened",
|
|
"shortlisted": "Shortlisted",
|
|
"interview": "Interview",
|
|
"hired": "Hired",
|
|
"rejected": "Rejected",
|
|
"assigned": "Assigned",
|
|
}
|
|
|
|
func stageLabel(s string) string {
|
|
if l, ok := stageLabels[s]; ok {
|
|
return l
|
|
}
|
|
return s
|
|
}
|
|
|
|
// knownStage reports whether a stage is one this tool may set.
|
|
//
|
|
// `assigned` is deliberately absent: an application becomes assigned because
|
|
// somebody was put on a shift, which is assign_worker's business. Letting this
|
|
// tool set it would create a second path to the same state that writes no
|
|
// assignment row — a candidate marked assigned to nothing.
|
|
func knownStage(s string) bool {
|
|
for _, v := range applicationStages {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
for _, v := range terminalStages {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/* ── The tool ───────────────────────────────────────────────────────────── */
|
|
|
|
type moveApplicationInput struct {
|
|
ApplicationID string `json:"application_id"`
|
|
Stage string `json:"stage"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// MoveApplication advances or rejects a candidate.
|
|
func MoveApplication(db repo.Querier) Tool {
|
|
return Tool{
|
|
Name: "move_application",
|
|
Description: "Move a candidate to a different stage of the hiring funnel — screened, " +
|
|
"shortlisted, interview, hired or rejected. This changes a real record and the " +
|
|
"candidate's status in the product. Requires an application id from " +
|
|
"candidates_quality or hires_recent; never invent one. A person must approve " +
|
|
"before this takes effect.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"application_id": map[string]any{
|
|
"type": "string",
|
|
"description": "The application's id, exactly as a lookup returned it.",
|
|
},
|
|
"stage": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"ai_screened", "shortlisted", "interview", "hired", "rejected"},
|
|
"description": "Where to move them. Use 'interview' to mark someone ready to " +
|
|
"interview. 'hired' and 'rejected' are final outcomes.",
|
|
},
|
|
"note": map[string]any{
|
|
"type": "string",
|
|
"description": "Optional one-line reason, shown to the person approving. " +
|
|
"Say why this candidate and not the alternatives.",
|
|
},
|
|
},
|
|
"required": []string{"application_id", "stage"},
|
|
"additionalProperties": false,
|
|
},
|
|
Effect: EffectWrite,
|
|
MaxResultBytes: DefaultMaxResultBytes,
|
|
|
|
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
|
|
plan, denied := planMove(ctx, db, tc, inputs)
|
|
if denied != nil {
|
|
return nil, denied
|
|
}
|
|
|
|
details := []Detail{
|
|
{Label: "Candidate", Value: plan.name},
|
|
{Label: "Role", Value: plan.roleTitle},
|
|
{Label: "Moving", Value: fmt.Sprintf("%s → %s",
|
|
stageLabel(plan.currentStage), stageLabel(plan.stage))},
|
|
}
|
|
if plan.score > 0 {
|
|
details = append(details, Detail{
|
|
Label: "Match score", Value: fmt.Sprintf("%d", plan.score)})
|
|
}
|
|
if plan.note != "" {
|
|
details = append(details, Detail{Label: "Reason", Value: plan.note})
|
|
}
|
|
|
|
var warnings []string
|
|
// A terminal stage is the one a person most needs to be stopped on:
|
|
// it is the hardest to walk back, and the model reaching it early is
|
|
// the most expensive mistake available here.
|
|
switch plan.stage {
|
|
case "hired":
|
|
warnings = append(warnings, fmt.Sprintf(
|
|
"Hiring is a final outcome. %s will count as hired for this role.", plan.name))
|
|
case "rejected":
|
|
warnings = append(warnings, fmt.Sprintf(
|
|
"Rejecting is a final outcome. %s will be out of the running for this role.", plan.name))
|
|
}
|
|
// Skipping stages is legitimate — a strong candidate can go straight
|
|
// to interview — but it is worth pointing out, because a model
|
|
// misreading which stage somebody is at produces exactly this shape.
|
|
if plan.skipped > 1 {
|
|
warnings = append(warnings, fmt.Sprintf(
|
|
"This skips %d stage(s): %s is currently at %s.",
|
|
plan.skipped-1, plan.name, stageLabel(plan.currentStage)))
|
|
}
|
|
if plan.currentStage == plan.stage {
|
|
warnings = append(warnings, fmt.Sprintf(
|
|
"%s is already at %s. Approving this changes nothing.",
|
|
plan.name, stageLabel(plan.stage)))
|
|
}
|
|
|
|
summary := fmt.Sprintf("%s moves from %s to %s for %s.",
|
|
plan.name, stageLabel(plan.currentStage), stageLabel(plan.stage), plan.roleTitle)
|
|
if plan.stage == "interview" {
|
|
summary = fmt.Sprintf(
|
|
"%s will be marked ready to interview for %s, and will appear in the "+
|
|
"interview queue.", plan.name, plan.roleTitle)
|
|
}
|
|
|
|
return &Confirmation{
|
|
Title: fmt.Sprintf("Move %s to %s", plan.name, stageLabel(plan.stage)),
|
|
Summary: summary,
|
|
Details: details,
|
|
Warnings: warnings,
|
|
}, nil
|
|
},
|
|
|
|
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
|
plan, denied := planMove(ctx, db, tc, inputs)
|
|
if denied != nil {
|
|
return *denied
|
|
}
|
|
|
|
// Re-read and re-checked, like assign_worker: the approval was given
|
|
// against a picture some minutes old, and somebody else may have
|
|
// moved this candidate in between. Moving them again from a stage
|
|
// the approver never saw is not what they agreed to.
|
|
if plan.currentStage == plan.stage {
|
|
return OK(map[string]any{
|
|
"applicationId": plan.id,
|
|
"candidate": plan.name,
|
|
"stage": plan.stage,
|
|
"changed": false,
|
|
"note": fmt.Sprintf("%s was already at %s; nothing was changed.",
|
|
plan.name, stageLabel(plan.stage)),
|
|
})
|
|
}
|
|
|
|
// screened_at is deliberately not written. Migration 000003 dropped
|
|
// job_applications_screened_consistent because nothing in the product
|
|
// ever sets that column; writing it here would make this tool its only
|
|
// writer, so the column would come to mean "an agent touched this row"
|
|
// rather than what its name says. status is the screening record.
|
|
var updated string
|
|
err := db.QueryRow(ctx, `
|
|
UPDATE job_applications
|
|
SET status = $3::application_status,
|
|
updated_date = now()
|
|
WHERE id = $1::uuid AND org_id = $2::uuid
|
|
RETURNING status::text`,
|
|
plan.id, tc.OrgID(), plan.stage,
|
|
).Scan(&updated)
|
|
if err != nil {
|
|
return Failf(CodeFailed, "the candidate could not be moved")
|
|
}
|
|
|
|
return OK(map[string]any{
|
|
"applicationId": plan.id,
|
|
"candidate": plan.name,
|
|
"role": plan.roleTitle,
|
|
"from": plan.currentStage,
|
|
"stage": updated,
|
|
"changed": true,
|
|
"confirmed": true,
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
/* ── Resolution ─────────────────────────────────────────────────────────── */
|
|
|
|
type movePlan struct {
|
|
id string
|
|
name string
|
|
email string
|
|
roleTitle string
|
|
currentStage string
|
|
stage string
|
|
note string
|
|
score int
|
|
|
|
// skipped is how many stages forward this moves. 1 is the next one along;
|
|
// more than that jumps the queue, which is allowed and worth saying.
|
|
skipped int
|
|
}
|
|
|
|
// planMove authorizes, validates and resolves a move_application call.
|
|
//
|
|
// Shared by the renderer and the handler so the thing described and the thing
|
|
// done are resolved by identical code — the same reason assign_worker has
|
|
// planAssignment. Two resolutions would drift, and the drift lands exactly
|
|
// between what a person approved and what happened.
|
|
func planMove(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*movePlan, *Result) {
|
|
// Update, not List. `job-applications` lists to everyone — a talent caller
|
|
// may read their own — and updates for operators only. Asking the read
|
|
// question here would let a candidate advance themselves.
|
|
if _, denied := authorizeOp(tc, "job-applications", domain.OpUpdate, ""); denied != nil {
|
|
return nil, denied
|
|
}
|
|
|
|
var in moveApplicationInput
|
|
if err := json.Unmarshal(inputs, &in); err != nil {
|
|
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
|
return nil, &bad
|
|
}
|
|
id := strings.TrimSpace(in.ApplicationID)
|
|
stage := strings.TrimSpace(strings.ToLower(in.Stage))
|
|
if id == "" {
|
|
bad := Failf(CodeInvalidInput, "an application id is required")
|
|
return nil, &bad
|
|
}
|
|
if !knownStage(stage) {
|
|
bad := Failf(CodeInvalidInput,
|
|
"%q is not a stage; use ai_screened, shortlisted, interview, hired or rejected", in.Stage)
|
|
return nil, &bad
|
|
}
|
|
if stage == "applied" {
|
|
// Moving somebody back to the start is not a funnel action, it is an
|
|
// undo — and an undo that erases the record of having been screened.
|
|
bad := Failf(CodeInvalidInput, "a candidate cannot be moved back to applied")
|
|
return nil, &bad
|
|
}
|
|
|
|
plan := &movePlan{id: id, stage: stage, note: strings.TrimSpace(in.Note)}
|
|
|
|
// Behind the caller's own read predicate. Referencing an application this
|
|
// caller could not have read would confirm it exists.
|
|
q, denied := authorizeAs(tc, "job-applications", "a")
|
|
if denied != nil {
|
|
return nil, denied
|
|
}
|
|
q.eq("id::text", id)
|
|
|
|
if err := db.QueryRow(ctx, `
|
|
SELECT a.id::text, a.applicant_name, a.email::text, a.status::text, a.ai_score,
|
|
coalesce(nullif(p.title, ''), 'an unnamed role')
|
|
FROM job_applications a
|
|
JOIN job_postings p ON p.id = a.job_posting_id
|
|
WHERE `+q.clause(), q.args...,
|
|
).Scan(&plan.id, &plan.name, &plan.email, &plan.currentStage, &plan.score, &plan.roleTitle); err != nil {
|
|
denied := Denied()
|
|
return nil, &denied
|
|
}
|
|
if strings.TrimSpace(plan.name) == "" {
|
|
plan.name = plan.email
|
|
}
|
|
|
|
plan.skipped = stagesBetween(plan.currentStage, plan.stage)
|
|
return plan, nil
|
|
}
|
|
|
|
// stagesBetween counts how far forward a move goes.
|
|
//
|
|
// Zero for a terminal outcome or a move that is not forward along the funnel —
|
|
// there is no meaningful "distance" to rejecting somebody, and reporting one
|
|
// would produce a warning about skipping stages on a decision that skips
|
|
// nothing.
|
|
func stagesBetween(from, to string) int {
|
|
index := func(s string) int {
|
|
for i, v := range applicationStages {
|
|
if v == s {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
f, t := index(from), index(to)
|
|
if f < 0 || t < 0 || t <= f {
|
|
return 0
|
|
}
|
|
return t - f
|
|
}
|
|
|
|
/* ── Lookup ─────────────────────────────────────────────────────────────── */
|
|
|
|
type candidatesInput struct {
|
|
Stage string `json:"stage"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
// CandidatesAwaiting lists candidates at a stage, with the ids to move them.
|
|
//
|
|
// §4: a tool whose schema demands an id the model was never given is a design
|
|
// bug, and the fix is a lookup rather than a friendlier error message. Every
|
|
// analytics tool in this package returns aggregates on purpose — an id in a
|
|
// count is noise — so move_application would be unusable without this.
|
|
//
|
|
// Ordered by match score. A person asking "who is waiting" almost always means
|
|
// "who should I look at first", and returning them in insertion order makes the
|
|
// model do ranking it has no basis for.
|
|
func CandidatesAwaiting(db repo.Querier) Tool {
|
|
return Tool{
|
|
Name: "candidates_awaiting",
|
|
Description: "List candidates currently at a given stage of the funnel — strongest " +
|
|
"match first — with the application id needed to move them. Use this before " +
|
|
"moving anyone: it is the only way to learn an application's id, and an id must " +
|
|
"never be guessed.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"stage": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"applied", "ai_screened", "shortlisted", "interview",
|
|
"hired", "rejected", "assigned"},
|
|
"description": "Which stage to list. Omit for everyone still in the running " +
|
|
"(that is, not hired, rejected or already assigned to a shift).",
|
|
},
|
|
"limit": map[string]any{
|
|
"type": "integer", "minimum": 1, "maximum": 50,
|
|
"description": "How many to list. Defaults to 15.",
|
|
},
|
|
},
|
|
"additionalProperties": false,
|
|
},
|
|
Effect: EffectRead,
|
|
MaxResultBytes: DefaultMaxResultBytes,
|
|
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
|
q, denied := authorizeAs(tc, "job-applications", "a")
|
|
if denied != nil {
|
|
return *denied
|
|
}
|
|
|
|
var in candidatesInput
|
|
if len(inputs) > 0 {
|
|
if err := json.Unmarshal(inputs, &in); err != nil {
|
|
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
|
}
|
|
}
|
|
stage := strings.TrimSpace(strings.ToLower(in.Stage))
|
|
if stage != "" && !listableStage(stage) {
|
|
return Failf(CodeInvalidInput, "%q is not a stage", in.Stage)
|
|
}
|
|
|
|
if stage != "" {
|
|
q.eq("status::text", stage)
|
|
} else {
|
|
// Still in the running. Built from this tool's own vocabulary
|
|
// rather than a hand-written list, so a settled outcome added
|
|
// there cannot be left behind here.
|
|
q.raw("a.status NOT IN (" + quotedList(settledStages) + ")")
|
|
}
|
|
|
|
limit := in.Limit
|
|
if limit <= 0 {
|
|
limit = 15
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
|
|
args := append(append([]any{}, q.args...), limit)
|
|
rows, err := db.Query(ctx, `
|
|
SELECT a.id::text, a.applicant_name, a.email::text, a.status::text, a.ai_score,
|
|
coalesce(nullif(p.title, ''), 'an unnamed role'),
|
|
(a.status <> 'applied')
|
|
FROM job_applications a
|
|
JOIN job_postings p ON p.id = a.job_posting_id
|
|
WHERE `+q.clause()+`
|
|
ORDER BY a.ai_score DESC, a.created_date ASC
|
|
LIMIT $`+fmt.Sprint(len(args)), args...)
|
|
if err != nil {
|
|
return Failf(CodeFailed, "the candidates could not be read")
|
|
}
|
|
defer rows.Close()
|
|
|
|
candidates := []map[string]any{}
|
|
for rows.Next() {
|
|
var (
|
|
id, name, email, status, role string
|
|
score int
|
|
screened bool
|
|
)
|
|
if err := rows.Scan(&id, &name, &email, &status, &score, &role, &screened); err != nil {
|
|
return Failf(CodeFailed, "the candidates could not be read")
|
|
}
|
|
if strings.TrimSpace(name) == "" {
|
|
name = email
|
|
}
|
|
row := map[string]any{
|
|
"applicationId": id,
|
|
"name": name,
|
|
"role": role,
|
|
"stage": status,
|
|
"stageLabel": stageLabel(status),
|
|
"screened": screened,
|
|
}
|
|
// Absent rather than 0: a candidate nobody scored has no score,
|
|
// and reporting 0 invites the model to rank them as the worst.
|
|
if score > 0 {
|
|
row["matchScore"] = score
|
|
}
|
|
candidates = append(candidates, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return Failf(CodeFailed, "the candidates could not be read")
|
|
}
|
|
|
|
data := map[string]any{
|
|
"candidates": candidates,
|
|
"count": len(candidates),
|
|
"stage": stage,
|
|
}
|
|
if stage == "" {
|
|
data["stage"] = "still in the running"
|
|
}
|
|
if len(candidates) == 0 {
|
|
data["note"] = "Nobody is at that stage. This is a real answer, not a failure to look."
|
|
}
|
|
return OK(data)
|
|
},
|
|
}
|
|
}
|