agent build
This commit is contained in:
504
go-api/internal/tools/applications.go
Normal file
504
go-api/internal/tools/applications.go
Normal file
@@ -0,0 +1,504 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user