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) }, } }