Files
krow_backend/go-api/internal/tools/applications.go
2026-08-28 12:21:44 +05:30

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