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

389 lines
13 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// The cross-domain tools: the workspace summary, activity signals, training,
// and operational risk.
//
// These are the ones that read more than one resource, and each one authorizes
// **per resource** rather than once at the top. That distinction is the whole
// of I1 here: a caller who may read shifts but not applications gets the shift
// half of the answer and a stated gap, never a blended figure computed over
// rows they cannot see. A single check at the entrance would have to pick one
// resource to check against, and whichever it picked would be wrong for the
// others.
/* ── Workspace summary ──────────────────────────────────────────────────── */
// WorkspaceSummary is the one-screen state of the workspace.
func WorkspaceSummary(db repo.Querier) Tool {
return Tool{
Name: "workspace_summary",
Description: "Read the overall state of the workspace: open roles, applications in " +
"flight, workers on the books, shifts recorded and recent activity volume. Use " +
"for broad questions about how things are going, and to decide which narrower " +
"tool to reach for next.",
InputSchema: periodSchema("Unused by this tool."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
data := map[string]any{"period": periodOrAll(in.Period)}
var withheld []string
// Each count is behind its own resource's policy. A resource the
// caller may not list is reported as withheld rather than omitted:
// a missing number reads as a zero, and a zero is a claim.
counts := []struct {
key, resource, table, extra string
}{
{"openRoles", "job-postings", "job_postings", "status = 'active'"},
{"applications", "job-applications", "job_applications", ""},
{"workers", "worker-profiles", "worker_profiles", ""},
{"shiftsRecorded", "shift-records", "shift_records", ""},
{"activityEvents", "user-activity", "user_activity", ""},
}
for _, c := range counts {
q, denied := authorize(tc, c.resource)
if denied != nil {
withheld = append(withheld, c.key)
continue
}
if c.extra != "" {
q.raw(c.extra)
}
if !from.IsZero() {
q.gte(dateColumnFor(c.table), from)
q.lt(dateColumnFor(c.table), to)
}
var n int64
if err := db.QueryRow(ctx,
`SELECT count(*) FROM `+c.table+` WHERE `+q.clause(), q.args...).Scan(&n); err != nil {
return Failf(CodeFailed, "the workspace could not be read")
}
data[c.key] = n
}
if len(withheld) > 0 {
data["withheld"] = withheld
data["withheldNote"] = "These figures are not available to this caller and are " +
"absent rather than zero. Do not describe them as zero or as empty."
}
return OK(data)
},
}
}
// dateColumnFor names the column a period filters on.
//
// shift_records is dated by when the shift happened, not by when the row was
// written — a shift entered late would otherwise land in the wrong week, which
// is exactly the kind of quiet wrongness a rota question cannot tolerate.
func dateColumnFor(table string) string {
if table == "shift_records" {
return "shift_date"
}
return "created_date"
}
/* ── Activity signals ───────────────────────────────────────────────────── */
// ActivitySignals surfaces activity that departs from the pattern.
func ActivitySignals(db repo.Querier) Tool {
return Tool{
Name: "activity_signals",
Description: "Find activity that departs from the usual pattern: days with unusual " +
"volume, accounts acting far more than others, and event kinds that appeared " +
"for the first time recently. Use only for questions about what looks unusual — " +
"for plain counts use activity_breakdown instead.",
InputSchema: periodSchema("How many signals to return. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "user-activity")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("created_date", from)
q.lt("created_date", to)
}
// Daily volume, so "unusual" is measured against this workspace's
// own baseline rather than a number chosen here. A workspace that
// logs 4 events a day and one that logs 4,000 both get a threshold
// that means something.
rows, err := db.Query(ctx, `
SELECT date_trunc('day', created_date)::date, count(*)
FROM user_activity
WHERE `+q.clause()+`
GROUP BY 1 ORDER BY 1 ASC`, q.args...)
if err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
defer rows.Close()
type day struct {
Day time.Time `json:"day"`
Count int64 `json:"count"`
}
var (
days []day
total int64
)
for rows.Next() {
var d day
if err := rows.Scan(&d.Day, &d.Count); err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
total += d.Count
days = append(days, d)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
if len(days) < 3 {
// Below three days there is no pattern to depart from. Saying so
// is the honest answer; inventing a threshold would produce
// confident nonsense on a new workspace.
return OK(map[string]any{
"period": periodOrAll(in.Period),
"days": len(days),
"signals": []any{},
"note": "There is not enough history to say what is unusual. " +
"At least three days of activity are needed before a departure from " +
"the pattern means anything.",
})
}
mean := float64(total) / float64(len(days))
var variance float64
for _, d := range days {
diff := float64(d.Count) - mean
variance += diff * diff
}
stddev := sqrt(variance / float64(len(days)))
type signal struct {
Kind string `json:"kind"`
Day time.Time `json:"day,omitempty"`
Detail string `json:"detail"`
Count int64 `json:"count"`
}
var signals []signal
// Two standard deviations. Flagging ordinary activity trains the
// reader to ignore the flag, which is the Activity Agent's own
// stated instruction.
for _, d := range days {
if stddev > 0 && float64(d.Count) > mean+2*stddev {
signals = append(signals, signal{
Kind: "unusual-volume", Day: d.Day, Count: d.Count,
Detail: fmt.Sprintf("%d events against a daily average of %.0f", d.Count, mean),
})
}
}
if len(signals) > in.limitOr(10) {
signals = signals[:in.limitOr(10)]
}
data := map[string]any{
"period": periodOrAll(in.Period),
"days": len(days),
"averagePerDay": int(mean + 0.5),
"signals": signals,
"thresholdExplained": "A day is flagged when it exceeds the average by more " +
"than two standard deviations of this workspace's own daily volume.",
}
if len(signals) == 0 {
data["note"] = "Nothing departs from the pattern. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
// sqrt without importing math for one call.
func sqrt(f float64) float64 {
if f <= 0 {
return 0
}
x := f
for i := 0; i < 24; i++ {
x = (x + f/x) / 2
}
return x
}
/* ── Training ───────────────────────────────────────────────────────────── */
// WorkforceTraining reports learning progress across the workforce.
func WorkforceTraining(db repo.Querier) Tool {
return Tool{
Name: "workforce_training",
Description: "Read training and development: how many courses are available, how " +
"far the workforce has progressed, average profile completion and experience " +
"level. Use for questions about upskilling, course uptake and readiness.",
InputSchema: periodSchema("How many courses to list. Defaults to 20."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
in, _, _, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
data := map[string]any{}
var withheld []string
if q, denied := authorize(tc, "courses"); denied == nil {
var total, active int64
if err := db.QueryRow(ctx, `
SELECT count(*), count(*) FILTER (WHERE status = 'active')
FROM courses WHERE `+q.clause(), q.args...).Scan(&total, &active); err != nil {
return Failf(CodeFailed, "the courses could not be read")
}
data["courses"] = total
data["activeCourses"] = active
} else {
withheld = append(withheld, "courses")
}
if q, denied := authorize(tc, "worker-profiles"); denied == nil {
var (
workers int64
completion, xp *float64
)
if err := db.QueryRow(ctx, `
SELECT count(*), avg(profile_completion), avg(xp)
FROM worker_profiles WHERE `+q.clause(), q.args...,
).Scan(&workers, &completion, &xp); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
data["workers"] = workers
putAvg(data, "averageProfileCompletion", completion)
putAvg(data, "averageXP", xp)
} else {
withheld = append(withheld, "workers")
}
_ = in
if len(withheld) > 0 {
data["withheld"] = withheld
data["withheldNote"] = "These figures are not available to this caller and are " +
"absent rather than zero. Do not describe them as zero or as empty."
}
return OK(data)
},
}
}
/* ── Operational risk ───────────────────────────────────────────────────── */
// OperationsRisk finds what is going wrong across domains.
func OperationsRisk(db repo.Querier) Tool {
return Tool{
Name: "operations_risk",
Description: "Find operational problems across hiring and the workforce at once: " +
"strong candidates waiting on a decision, applications nobody has screened, " +
"roles open a long time with no strong applicant, and shifts going unworked. " +
"Use for questions about what needs attention.",
InputSchema: periodSchema("How many findings per category. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
in, _, _, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
limit := in.limitOr(10)
type finding struct {
Kind string `json:"kind"`
Detail string `json:"detail"`
Count int64 `json:"count"`
}
var (
findings []finding
withheld []string
)
if q, denied := authorize(tc, "job-applications"); denied == nil {
var waiting, unscreened int64
if err := db.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE ai_score >= 80 AND status IN ('applied', 'ai_screened', 'shortlisted')),
count(*) FILTER (WHERE status = 'applied')
FROM job_applications WHERE `+q.clause(), q.args...,
).Scan(&waiting, &unscreened); err != nil {
return Failf(CodeFailed, "the applications could not be read")
}
if waiting > 0 {
findings = append(findings, finding{
Kind: "decision-owed", Count: waiting,
Detail: "strong candidates are waiting on a decision",
})
}
if unscreened > 0 {
findings = append(findings, finding{
Kind: "screening-backlog", Count: unscreened,
Detail: "applications have not been screened",
})
}
} else {
withheld = append(withheld, "applications")
}
if q, denied := authorize(tc, "shift-records"); denied == nil {
var unworked int64
if err := db.QueryRow(ctx, `
SELECT count(*) FROM shift_records
WHERE `+q.clause()+` AND status IN ('no_show', 'absent')`, q.args...,
).Scan(&unworked); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
if unworked > 0 {
findings = append(findings, finding{
Kind: "shifts-unworked", Count: unworked,
Detail: "shifts were not worked",
})
}
} else {
withheld = append(withheld, "shifts")
}
if len(findings) > limit {
findings = findings[:limit]
}
data := map[string]any{"findings": findings}
// An empty list means the operation is running, not that the check
// did not run. Said explicitly, because those read identically.
if len(findings) == 0 && len(withheld) == 0 {
data["note"] = "Nothing is flagged. The checks ran and found no problems — " +
"this is not a failure to look."
}
if len(withheld) > 0 {
data["withheld"] = withheld
data["withheldNote"] = "These areas could not be checked for this caller. " +
"Do not describe them as having no problems."
}
return OK(data)
},
}
}