389 lines
13 KiB
Go
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)
|
|
},
|
|
}
|
|
}
|