agent build

This commit is contained in:
2026-08-28 12:21:44 +05:30
parent b6f8655909
commit f7df96c973
138 changed files with 24164 additions and 207 deletions

View File

@@ -0,0 +1,383 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// The workforce tools: attendance, overtime and shift coverage.
//
// Ports of `workforce.attendance`, `workforce.overtime` and
// `workforce.coverage`, which between them are named by eighteen of the shipped
// skills — the densest cluster in the registry.
//
// All three read shift_records, and all three go through authorize(), so a
// talent caller sees their own shifts and an operator sees the tenant's. The
// aggregate is computed after the predicate, never before: "the workforce
// averaged 4% late" computed over rows a caller may not read is a leak even
// though no row is printed.
//
// shift_status is `present | late | absent | no_show` — there is no
// "completed", "scheduled" or "cancelled" member. A status literal that is not
// in the enum matches no rows and raises nothing, so a wrong guess here reads
// as a quiet zero rather than an error. The values below are the enum's own.
type periodInput struct {
Period string `json:"period"`
Limit int `json:"limit"`
}
func (p periodInput) limitOr(n int) int {
if p.Limit <= 0 {
return n
}
if p.Limit > 100 {
return 100
}
return p.Limit
}
// decodePeriod reads the shared period/limit arguments.
func decodePeriod(inputs json.RawMessage) (periodInput, time.Time, time.Time, *Result) {
var in periodInput
if len(inputs) > 0 {
if err := json.Unmarshal(inputs, &in); err != nil {
r := Failf(CodeInvalidInput, "the arguments were not valid JSON")
return in, time.Time{}, time.Time{}, &r
}
}
from, to, err := windowFor(in.Period, time.Now())
if err != nil {
r := Failf(CodeInvalidInput, "%s", err.Error())
return in, time.Time{}, time.Time{}, &r
}
return in, from, to, nil
}
// periodSchema is the shared argument shape. One definition so three tools
// cannot describe the same argument three slightly different ways — the model
// reads these as documentation, and inconsistent documentation is worse than
// terse documentation.
func periodSchema(limitHelp string) map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"period": map[string]any{
"type": "string",
"enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"},
"description": "The window to read. Omit for all recorded history. " +
"Windows are computed from the current date; do not pass a date.",
},
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": limitHelp,
},
},
"additionalProperties": false,
}
}
/* ── Attendance ─────────────────────────────────────────────────────────── */
// WorkforceAttendance reports shift attendance: completion, lateness, no-shows.
func WorkforceAttendance(db repo.Querier) Tool {
return Tool{
Name: "workforce_attendance",
Description: "Read shift attendance: how many shifts were scheduled, completed, " +
"missed or started late, the average minutes late, and the workers with the " +
"weakest attendance. Use for questions about reliability, no-shows, lateness " +
"and whether shifts are being worked.",
InputSchema: periodSchema("How many workers to list, worst attendance first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "shift-records")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("shift_date", from)
q.lt("shift_date", to)
}
var (
total, completed, missed, noShow, late int64
avgLate *float64
)
err := db.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE status IN ('present', 'late')),
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
count(*) FILTER (WHERE status = 'no_show'),
count(*) FILTER (WHERE minutes_late > 0),
avg(minutes_late) FILTER (WHERE minutes_late > 0)
FROM shift_records
WHERE `+q.clause(), q.args...,
).Scan(&total, &completed, &missed, &noShow, &late, &avgLate)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
workers, err := weakestAttendance(ctx, db, q, in.limitOr(10))
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"shiftsScheduled": total,
"shiftsWorked": completed,
"shiftsMissed": missed,
"noShows": noShow,
"lateStarts": late,
"workers": workers,
}
if avgLate != nil {
data["averageMinutesLateWhenLate"] = int(*avgLate + 0.5)
}
if total > 0 {
data["completionRatePercent"] = int(float64(completed)/float64(total)*100 + 0.5)
} else {
data["note"] = "No shifts match that. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
type workerAttendance struct {
Worker string `json:"worker"`
Shifts int64 `json:"shifts"`
Missed int64 `json:"missed"`
NoShows int64 `json:"noShows"`
Late int64 `json:"lateStarts"`
Reliable int `json:"reliabilityPercent"`
AvgMinute int `json:"averageMinutesLate,omitempty"`
}
func weakestAttendance(ctx context.Context, db repo.Querier, q *query, limit int) ([]workerAttendance, error) {
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx, `
SELECT worker_name,
count(*),
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
count(*) FILTER (WHERE status = 'no_show'),
count(*) FILTER (WHERE minutes_late > 0),
coalesce(avg(minutes_late) FILTER (WHERE minutes_late > 0), 0)
FROM shift_records
WHERE `+q.clause()+`
GROUP BY worker_name
ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC,
count(*) FILTER (WHERE minutes_late > 0) DESC,
worker_name ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []workerAttendance
for rows.Next() {
var w workerAttendance
var avg float64
if err := rows.Scan(&w.Worker, &w.Shifts, &w.Missed, &w.NoShows, &w.Late, &avg); err != nil {
return nil, err
}
w.AvgMinute = int(avg + 0.5)
if w.Shifts > 0 {
w.Reliable = int(float64(w.Shifts-w.Missed)/float64(w.Shifts)*100 + 0.5)
}
out = append(out, w)
}
return out, rows.Err()
}
/* ── Overtime ───────────────────────────────────────────────────────────── */
// WorkforceOvertime reports overtime hours and who is accruing them.
func WorkforceOvertime(db repo.Querier) Tool {
return Tool{
Name: "workforce_overtime",
Description: "Read overtime: total overtime hours, how many shifts ran over, and " +
"the workers accruing the most. Use for questions about overtime cost, who is " +
"working beyond their scheduled hours, and whether overtime is concentrated.",
InputSchema: periodSchema("How many workers to list, most overtime first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "shift-records")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("shift_date", from)
q.lt("shift_date", to)
}
var (
shiftsWithOT int64
totalOT float64
totalActual float64
)
err := db.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE overtime_hours > 0),
coalesce(sum(overtime_hours), 0),
coalesce(sum(actual_hours), 0)
FROM shift_records
WHERE `+q.clause(), q.args...,
).Scan(&shiftsWithOT, &totalOT, &totalActual)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
args := append(append([]any{}, q.args...), in.limitOr(10))
rows, err := db.Query(ctx, `
SELECT worker_name, coalesce(sum(overtime_hours), 0), count(*) FILTER (WHERE overtime_hours > 0)
FROM shift_records
WHERE `+q.clause()+`
GROUP BY worker_name
HAVING sum(overtime_hours) > 0
ORDER BY sum(overtime_hours) DESC, worker_name ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
defer rows.Close()
type worker struct {
Worker string `json:"worker"`
Hours float64 `json:"overtimeHours"`
Shifts int64 `json:"shiftsWithOvertime"`
}
var workers []worker
for rows.Next() {
var w worker
if err := rows.Scan(&w.Worker, &w.Hours, &w.Shifts); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
workers = append(workers, w)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"totalOvertimeHours": round1(totalOT),
"shiftsWithOvertime": shiftsWithOT,
"workers": workers,
}
if totalActual > 0 {
data["overtimeSharePercent"] = int(totalOT/totalActual*100 + 0.5)
}
if totalOT == 0 {
data["note"] = "No overtime was recorded in that window. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Coverage ───────────────────────────────────────────────────────────── */
// WorkforceCoverage reports whether shifts are covered and what is unfilled.
func WorkforceCoverage(db repo.Querier) Tool {
return Tool{
Name: "workforce_coverage",
Description: "Read shift coverage: how many shifts are scheduled, cancelled or " +
"unworked, and which roles have the most uncovered shifts. Use for questions " +
"about gaps in the rota, roles that are hard to staff, and what is at risk of " +
"going unworked.",
InputSchema: periodSchema("How many roles to list, most uncovered first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "shift-records")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("shift_date", from)
q.lt("shift_date", to)
}
args := append(append([]any{}, q.args...), in.limitOr(10))
rows, err := db.Query(ctx, `
SELECT coalesce(nullif(role, ''), 'unspecified'),
count(*),
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
count(*) FILTER (WHERE status = 'late')
FROM shift_records
WHERE `+q.clause()+`
GROUP BY 1
ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC, 1 ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
defer rows.Close()
type roleCoverage struct {
Role string `json:"role"`
Shifts int64 `json:"shifts"`
Uncovered int64 `json:"uncovered"`
Late int64 `json:"lateStarts"`
Covered int `json:"coveragePercent"`
}
var (
roles []roleCoverage
allShifts, allUncovered, sch int64
)
for rows.Next() {
var r roleCoverage
if err := rows.Scan(&r.Role, &r.Shifts, &r.Uncovered, &r.Late); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
if r.Shifts > 0 {
r.Covered = int(float64(r.Shifts-r.Uncovered)/float64(r.Shifts)*100 + 0.5)
}
allShifts += r.Shifts
allUncovered += r.Uncovered
sch += r.Late
roles = append(roles, r)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"shifts": allShifts,
"uncovered": allUncovered,
"lateStarts": sch,
"roles": roles,
}
if allShifts > 0 {
data["coveragePercent"] = int(float64(allShifts-allUncovered)/float64(allShifts)*100 + 0.5)
} else {
data["note"] = "No shifts match that. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
func round1(f float64) float64 {
return float64(int(f*10+0.5)) / 10
}