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

596 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// Assignments: the first tools that change something, and the two lookups that
// make changing something possible.
//
// §4 has a line that dictates the shape of this file: "a tool description that
// requires the model to guess an ID it has not been given is a design bug. Add
// a lookup tool instead." Every read tool built so far returns aggregates —
// counts, averages, the weakest five — and none of them return a row id,
// deliberately: an id in an analytics answer is noise. But an assignment names
// a posting and a person, so the write is unusable until the model has a
// legitimate way to learn those two things.
//
// Hence three tools, in the order an agent actually uses them:
//
// open_positions → which roles need people, with their ids
// available_workers → who is free in that window, with their emails
// assign_worker → put one on the other, once a person has said yes
//
// The alternative — a single write that accepts a worker's name and resolves it
// itself — reads as friendlier and is considerably worse. Two people called
// Chen makes it ambiguous, and the disambiguation would happen inside a write,
// after approval, with no one watching.
/* ── Lookup: open positions ─────────────────────────────────────────────── */
type openPositionsInput struct {
Limit int `json:"limit"`
}
// OpenPositions lists roles that still need people, with the ids to fill them.
func OpenPositions(db repo.Querier) Tool {
return Tool{
Name: "open_positions",
Description: "List the roles that are currently open, with how many people each " +
"needs, how many are already assigned, and how many are still to fill. " +
"Returns an id for each role. Use this before assigning anyone: it is the " +
"only way to learn a role's id, and an id must never be guessed.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": "How many roles to list, most urgent first. Defaults to 20.",
},
},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "job-postings", "p")
if denied != nil {
return *denied
}
var in openPositionsInput
if len(inputs) > 0 {
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
}
limit := in.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
// Only roles that can actually be staffed. A draft has not been
// agreed, a paused role has been stopped on purpose, and a closed
// one is history — offering any of them as assignable would invite
// the agent to staff a role nobody is hiring for.
q.raw("p.status = 'active'")
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx, `
SELECT p.id::text, p.title, p.role_category, p.location,
p.headcount, p.priority::text, p.start_date,
(SELECT count(*) FROM assignments a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id
AND a.status = 'active')
FROM job_postings p
WHERE `+q.clause()+`
ORDER BY p.priority DESC, p.created_date ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
defer rows.Close()
positions := []map[string]any{}
for rows.Next() {
var (
id, title, category, location, priority string
headcount int
startDate *time.Time
assigned int
)
if err := rows.Scan(&id, &title, &category, &location,
&headcount, &priority, &startDate, &assigned); err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
stillToFill := headcount - assigned
if stillToFill < 0 {
stillToFill = 0
}
p := map[string]any{
"id": id, "title": title, "priority": priority,
"headcount": headcount, "assigned": assigned, "stillToFill": stillToFill,
}
if category != "" {
p["roleCategory"] = category
}
if location != "" {
p["location"] = location
}
if startDate != nil {
p["startDate"] = startDate.Format("2006-01-02")
}
positions = append(positions, p)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
data := map[string]any{"positions": positions, "count": len(positions)}
if len(positions) == 0 {
data["note"] = "No roles are open. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Lookup: available workers ──────────────────────────────────────────── */
type availableWorkersInput struct {
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
Limit int `json:"limit"`
}
// AvailableWorkers lists workers with no clashing assignment in a window.
//
// "Available" here means one specific, checkable thing: no active assignment
// overlapping the window. It does not mean willing, qualified, or within their
// contracted hours. The description says so, because a model given a tool
// called `available_workers` will otherwise report its output as availability
// in the ordinary sense of the word, and a manager will read it that way.
func AvailableWorkers(db repo.Querier) Tool {
return Tool{
Name: "available_workers",
Description: "List workers who have no clashing assignment in a given window, " +
"best-scoring first, with the email needed to assign them. Availability here " +
"means only that nothing else is booked over that window — it does not mean " +
"the person has agreed, is qualified for the role, or is within their hours. " +
"Say so when reporting it.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"starts_at": map[string]any{
"type": "string",
"description": "When the work starts, as an RFC 3339 timestamp " +
"(for example 2026-09-12T18:00:00Z). Required.",
},
"ends_at": map[string]any{
"type": "string",
"description": "When the work ends, as an RFC 3339 timestamp. " +
"Omit for open-ended work.",
},
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": "How many workers to list. Defaults to 10.",
},
},
"required": []string{"starts_at"},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "worker-profiles", "w")
if denied != nil {
return *denied
}
var in availableWorkersInput
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
if bad != nil {
return *bad
}
limit := in.Limit
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
// The clash test is a NOT EXISTS against the same tenant, so it is
// a pre-filter like every other predicate here rather than a list
// fetched and then thinned in Go.
args := append(append([]any{}, q.args...), starts, ends, limit)
startIdx, endIdx, limIdx := len(args)-2, len(args)-1, len(args)
rows, err := db.Query(ctx, fmt.Sprintf(`
SELECT w.full_name, w.email::text, nullif(w.krow_score, 0),
nullif(w.reliability_score, 0), nullif(w.experience_years, 0),
w.current_position
FROM worker_profiles w
WHERE %s
AND NOT EXISTS (
SELECT 1 FROM assignments a
WHERE a.org_id = w.org_id
AND a.worker_email = w.email
AND a.status = 'active'
AND tstzrange(a.starts_at, coalesce(a.ends_at, 'infinity'::timestamptz))
&& tstzrange($%d, coalesce($%d::timestamptz, 'infinity'::timestamptz)))
ORDER BY w.krow_score DESC NULLS LAST, w.reliability_score DESC NULLS LAST
LIMIT $%d`, q.clause(), startIdx, endIdx, limIdx), args...)
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
defer rows.Close()
workers := []map[string]any{}
for rows.Next() {
// Pointers, so an unrated worker carries no rating rather than a
// 0 the model would read as the worst possible score. This list
// feeds assign_worker, so the distinction picks who gets offered.
var (
name, email, position string
krow, reliability, experience *int
)
if err := rows.Scan(&name, &email, &krow, &reliability, &experience, &position); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
w := map[string]any{"name": name, "email": email}
if krow != nil {
w["krowScore"] = *krow
}
if reliability != nil {
w["reliabilityScore"] = *reliability
}
if experience != nil {
w["experienceYears"] = *experience
}
if position != "" {
w["currentPosition"] = position
}
workers = append(workers, w)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
data := map[string]any{
"window": windowLabel(starts, ends),
"workers": workers,
"count": len(workers),
"meaning": "No clashing assignment in this window. Not a statement that " +
"they have agreed or are qualified.",
}
if len(workers) == 0 {
data["note"] = "Nobody is free in that window. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Write: assign a worker ─────────────────────────────────────────────── */
type assignWorkerInput struct {
JobPostingID string `json:"job_posting_id"`
WorkerEmail string `json:"worker_email"`
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
}
// AssignWorker puts a worker on a role. The first tool in this service that
// changes anything.
//
// The pattern every future write should copy is the split between Confirm and
// Handler, and specifically what is duplicated across them. Both authorize.
// Both resolve the posting and the worker. Both check for a clash. That looks
// like repetition and is not: the renderer runs to describe, and the handler
// runs after a person has read that description and agreed — with an unbounded
// gap in between, during which somebody else may have taken the same shift.
//
// So the renderer's clash check produces a WARNING, and the handler's produces
// a REFUSAL. A description is about the moment it was written; a write is about
// the moment it happens.
func AssignWorker(db repo.Querier) Tool {
return Tool{
Name: "assign_worker",
Description: "Assign a worker to an open role for a given period. This creates a " +
"real assignment: the person is scheduled to work. Requires a role id from " +
"open_positions and a worker email from available_workers — never invent " +
"either. A person must approve before this takes effect.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"job_posting_id": map[string]any{
"type": "string",
"description": "The role's id, exactly as returned by open_positions.",
},
"worker_email": map[string]any{
"type": "string",
"description": "The worker's email, exactly as returned by available_workers.",
},
"starts_at": map[string]any{
"type": "string",
"description": "When the work starts, as an RFC 3339 timestamp. Required.",
},
"ends_at": map[string]any{
"type": "string",
"description": "When the work ends, as an RFC 3339 timestamp. Omit for open-ended.",
},
},
"required": []string{"job_posting_id", "worker_email", "starts_at"},
"additionalProperties": false,
},
Effect: EffectWrite,
MaxResultBytes: DefaultMaxResultBytes,
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
plan, denied := planAssignment(ctx, db, tc, inputs)
if denied != nil {
return nil, denied
}
details := []Detail{
{Label: "Worker", Value: plan.workerName},
{Label: "Role", Value: plan.postingTitle},
{Label: "Period", Value: windowLabel(plan.starts, plan.ends)},
}
if plan.location != "" {
details = append(details, Detail{Label: "Location", Value: plan.location})
}
details = append(details, Detail{
Label: "Role filled",
Value: fmt.Sprintf("%d of %d, this would make %d",
plan.assigned, plan.headcount, plan.assigned+1),
})
var warnings []string
if plan.clashes > 0 {
warnings = append(warnings, fmt.Sprintf(
"%s already has %s over this period. Assigning them will double-book.",
plan.workerName, plural(plan.clashes, "assignment", "assignments")))
}
if plan.assigned >= plan.headcount {
warnings = append(warnings, fmt.Sprintf(
"%s already has all %s it asked for. This would go over headcount.",
plan.postingTitle, plural(plan.headcount, "person", "people")))
}
if plan.starts.Before(time.Now()) {
warnings = append(warnings, "This period starts in the past.")
}
return &Confirmation{
Title: fmt.Sprintf("Assign %s to %s", plan.workerName, plan.postingTitle),
Summary: fmt.Sprintf(
"%s will be scheduled to work %s as %s. They will appear on the roster "+
"for this role and count towards its headcount.",
plan.workerName, windowLabel(plan.starts, plan.ends), plan.postingTitle),
Details: details,
Warnings: warnings,
}, nil
},
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
plan, denied := planAssignment(ctx, db, tc, inputs)
if denied != nil {
return *denied
}
// Re-checked here, not merely described above. The approval was
// given against a picture of the world that is now some minutes
// old, and a double-booking created between the asking and the
// answering is one nobody agreed to.
if plan.clashes > 0 {
return Failf(CodeFailed,
"%s was booked over this period since this was approved; nothing was assigned",
plan.workerName)
}
var id string
err := db.QueryRow(ctx, `
INSERT INTO assignments
(org_id, job_posting_id, worker_profile_id, worker_email,
worker_name, starts_at, ends_at, status, source, match_score)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, 'active', 'agent', $8)
RETURNING id::text`,
tc.OrgID(), plan.postingID, plan.workerProfileID, plan.workerEmail,
plan.workerName, plan.starts, nullableTime(plan.ends), plan.matchScore,
).Scan(&id)
if err != nil {
return Failf(CodeFailed, "the assignment could not be created")
}
return OK(map[string]any{
"assignmentId": id,
"worker": plan.workerName,
"role": plan.postingTitle,
"period": windowLabel(plan.starts, plan.ends),
"status": "active",
"confirmed": true,
})
},
}
}
// assignmentPlan is everything both Confirm and Handler need, resolved once.
type assignmentPlan struct {
postingID string
postingTitle string
location string
headcount int
assigned int
workerProfileID *string
workerEmail string
workerName string
matchScore *int
starts time.Time
ends *time.Time
clashes int
}
// planAssignment authorizes, validates and resolves an assign_worker call.
//
// Shared by the renderer and the handler so that the thing described and the
// thing done are resolved by the same code. Two separate resolutions would
// drift, and the drift would land exactly where nobody looks: between what a
// person approved and what then happened.
//
// Every refusal is the same opaque Denied(). A posting id that belongs to
// another tenant, one that does not exist, and one this caller may not see are
// all indistinguishable — otherwise assign_worker becomes a way to ask whether
// a given uuid is real.
func planAssignment(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*assignmentPlan, *Result) {
// Create, not List. `assignments` lists to everyone and creates for
// operators only, so a talent caller is refused here even though they may
// read their own roster perfectly well.
if _, denied := authorizeOp(tc, "assignments", domain.OpCreate, ""); denied != nil {
return nil, denied
}
var in assignWorkerInput
if err := json.Unmarshal(inputs, &in); err != nil {
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
return nil, &bad
}
if strings.TrimSpace(in.JobPostingID) == "" || strings.TrimSpace(in.WorkerEmail) == "" {
bad := Failf(CodeInvalidInput, "a role id and a worker email are both required")
return nil, &bad
}
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
if bad != nil {
return nil, bad
}
plan := &assignmentPlan{starts: starts, ends: ends}
// The posting, behind the caller's own read predicate. Referencing a row
// the caller could not have read would confirm it exists.
pq, denied := authorizeAs(tc, "job-postings", "p")
if denied != nil {
return nil, denied
}
pq.eq("id::text", strings.TrimSpace(in.JobPostingID))
pq.raw("p.status = 'active'")
err := db.QueryRow(ctx, `
SELECT p.id::text, p.title, p.location, p.headcount,
(SELECT count(*) FROM assignments a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.status = 'active')
FROM job_postings p
WHERE `+pq.clause(), pq.args...,
).Scan(&plan.postingID, &plan.postingTitle, &plan.location, &plan.headcount, &plan.assigned)
if err != nil {
denied := Denied()
return nil, &denied
}
// The worker, likewise.
wq, denied := authorizeAs(tc, "worker-profiles", "w")
if denied != nil {
return nil, denied
}
wq.eq("email", strings.TrimSpace(in.WorkerEmail))
var profileID string
if err := db.QueryRow(ctx, `
SELECT w.id::text, w.full_name, w.email::text, nullif(w.krow_score, 0)
FROM worker_profiles w
WHERE `+wq.clause()+`
LIMIT 1`, wq.args...,
).Scan(&profileID, &plan.workerName, &plan.workerEmail, &plan.matchScore); err != nil {
denied := Denied()
return nil, &denied
}
plan.workerProfileID = &profileID
if strings.TrimSpace(plan.workerName) == "" {
plan.workerName = plan.workerEmail
}
// Clashes: active assignments overlapping the window. Counted in SQL —
// fetching the person's roster and comparing in Go would be the same
// post-filter I2 forbids, and would read rows this call has no reason to.
if err := db.QueryRow(ctx, `
SELECT count(*) FROM assignments
WHERE org_id = $1::uuid AND worker_email = $2 AND status = 'active'
AND tstzrange(starts_at, coalesce(ends_at, 'infinity'::timestamptz))
&& tstzrange($3, coalesce($4::timestamptz, 'infinity'::timestamptz))`,
tc.OrgID(), plan.workerEmail, starts, nullableTime(ends),
).Scan(&plan.clashes); err != nil {
failed := Failf(CodeFailed, "the worker's existing assignments could not be read")
return nil, &failed
}
return plan, nil
}
/* ── Shared helpers ─────────────────────────────────────────────────────── */
// decodeWindow parses a caller-supplied period.
//
// RFC 3339 only, and validated here rather than at the database. A timestamp
// that reaches SQL as an uninterpretable string is a constraint violation
// wearing the costume of a tool failure, and the model cannot correct what it
// cannot read.
func decodeWindow(startsAt, endsAt string) (time.Time, *time.Time, *Result) {
starts, err := time.Parse(time.RFC3339, strings.TrimSpace(startsAt))
if err != nil {
bad := Failf(CodeInvalidInput,
"starts_at must be an RFC 3339 timestamp, for example 2026-09-12T18:00:00Z")
return time.Time{}, nil, &bad
}
if strings.TrimSpace(endsAt) == "" {
return starts, nil, nil
}
ends, err := time.Parse(time.RFC3339, strings.TrimSpace(endsAt))
if err != nil {
bad := Failf(CodeInvalidInput,
"ends_at must be an RFC 3339 timestamp, for example 2026-09-12T23:00:00Z")
return time.Time{}, nil, &bad
}
// The table has a CHECK for this. Caught here so the model gets a sentence
// it can act on rather than a constraint name it cannot.
if !ends.After(starts) {
bad := Failf(CodeInvalidInput, "ends_at must be after starts_at")
return time.Time{}, nil, &bad
}
return starts, &ends, nil
}
// windowLabel renders a period the way a person reads one.
func windowLabel(starts time.Time, ends *time.Time) string {
if ends == nil {
return starts.Format("Mon 2 Jan 2006, 15:04") + " onwards"
}
if ends.YearDay() == starts.YearDay() && ends.Year() == starts.Year() {
return fmt.Sprintf("%s–%s",
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("15:04"))
}
return fmt.Sprintf("%s – %s",
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("Mon 2 Jan 2006, 15:04"))
}
func nullableTime(t *time.Time) any {
if t == nil {
return nil
}
return *t
}
func plural(n int, one, many string) string {
if n == 1 {
return fmt.Sprintf("1 %s", one)
}
return fmt.Sprintf("%d %s", n, many)
}