Compare employee pay against their own baseline, not raw rank; tighten output

Two real problems from live testing:
1. gatherEmployeeAnomalies() ranked by absolute cost, so it always
   surfaced senior/supervisory/longer-shift staff — that's their
   normal rate, not an anomaly. Now compares each employee's £/shift
   this week against their own trailing 4-week average and only
   reports the deviation; the prompt explicitly tells the model not to
   flag high pay in absolute or relative terms, only genuine
   deviations from someone's own baseline. Validated: Jack Evans
   (previously flagged 3 briefings running) is +0.5% vs his own
   baseline — not an anomaly at all — while Joseph Trice-Rolph's +41%
   swing is a genuine standout.
2. Output was hitting the token cap and cutting off mid-sentence
   (confirmed: last generation used exactly 900/900 output tokens).
   Raised cap to 1400, and tightened the system prompt to a hard
   5-bullet-total limit with no per-department/per-employee sections,
   since the model was writing a full structured report instead of a
   short briefing regardless of the token budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-25 10:50:20 +00:00
parent ed67a91b52
commit 8f8e273650

View file

@ -6,7 +6,7 @@ import { forecastDayCost } from '../lib/forecast.js'
export const DEFAULT_MODEL = 'claude-haiku-4-5-20251001'
export const DEFAULT_DAILY_TOKEN_BUDGET = 5000
const MAX_OUTPUT_TOKENS = 900
const MAX_OUTPUT_TOKENS = 1400
export const MANUAL_RATE_LIMIT_MINUTES = 5
function toISODate(d) {
@ -290,17 +290,23 @@ export async function gatherForecastData(monthProgress) {
}
}
// Individual shifts/wages worth a look — top-cost employees over the trailing week.
export async function gatherEmployeeAnomalies(days = 7) {
// Individual shifts/wages worth a look — over the trailing week, compared against each
// employee's OWN trailing 4-week average cost-per-shift (not ranked by raw cost). Ranking by
// absolute cost or cost-per-shift alone always surfaces senior/supervisory/longer-shift staff,
// since they're legitimately paid more — that's not an anomaly, it's their normal rate. What's
// actually worth flagging is a employee costing notably more than THEY usually do.
export async function gatherEmployeeAnomalies(days = 7, baselineDays = 28) {
const costCol = await getCostCol()
const today = new Date()
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
const toStr = toISODate(new Date(today.getTime() - 86_400_000))
const baselineFromStr = toISODate(new Date(today.getTime() - (days + baselineDays) * 86_400_000))
const baselineToStr = toISODate(new Date(today.getTime() - (days + 1) * 86_400_000))
// wage_actuals_detail only stores department_id (a raw Workforce code, e.g. "972312"),
// not a human-readable name — resolve it from wage_actuals, which has both, so the
// prompt (and the model) never has to guess which department a code refers to.
const [res, deptNamesRes] = await Promise.all([
const [res, baselineRes, deptNamesRes] = await Promise.all([
pool.query(
`SELECT employee_id, employee_name, department_id,
SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
@ -311,22 +317,44 @@ export async function gatherEmployeeAnomalies(days = 7) {
LIMIT 15`,
[fromStr, toStr]
),
pool.query(
`SELECT employee_id, SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
FROM wage_actuals_detail
WHERE date >= $1 AND date <= $2
GROUP BY employee_id`,
[baselineFromStr, baselineToStr]
),
pool.query(`SELECT DISTINCT department_id, department_name FROM wage_actuals`),
])
const deptNames = Object.fromEntries(deptNamesRes.rows.map(r => [r.department_id, r.department_name]))
const baselinePerShift = Object.fromEntries(
baselineRes.rows
.filter(r => r.shift_count > 0)
.map(r => [r.employee_id, parseFloat(r.cost) / r.shift_count])
)
return {
fromStr,
toStr,
employees: res.rows.map(r => ({
employee_id: r.employee_id,
employee_name: r.employee_name,
department_id: r.department_id,
department_name: deptNames[r.department_id] || r.department_id,
cost: parseFloat(r.cost),
shift_count: r.shift_count,
})),
baselineFromStr,
baselineToStr,
employees: res.rows.map(r => {
const cost = parseFloat(r.cost)
const costPerShift = r.shift_count > 0 ? cost / r.shift_count : 0
const baseline = baselinePerShift[r.employee_id] ?? null
return {
employee_id: r.employee_id,
employee_name: r.employee_name,
department_id: r.department_id,
department_name: deptNames[r.department_id] || r.department_id,
cost,
shift_count: r.shift_count,
costPerShift,
baselinePerShift: baseline,
deviationPct: baseline ? (costPerShift - baseline) / baseline : null,
}
}),
}
}
@ -351,11 +379,12 @@ export async function gatherRevenueCorrelation(monthProgress) {
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights = [], manualContext = '') {
const systemMsg =
"You are an AI assistant for a wage cost controller in a UK hospitality business. Analyze the data below and " +
"provide a concise daily briefing (3-5 bullet points). Focus on: how this month's wage cost is tracking " +
"against budget, the month-end forecast, notable variance between rota and actual cost by department and " +
"what might be causing it, individual pay anomalies worth a look, and wage cost as a percentage of revenue. " +
"Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. Be specific with department/employee " +
"names and numbers. Keep it actionable — no fluff or generic advice.\n\n" +
"produce a SHORT daily briefing: hard limit of 5 bullet points TOTAL across the entire response, each a single " +
"sentence (max ~30 words). Do not create a separate section or heading per department or per employee — pick " +
"only the 4-5 most important things overall (across budget tracking, forecast, rota variance, pay anomalies, " +
"wage % of revenue) and drop the rest; a department or employee with nothing notable gets no mention at all. " +
"Use UK date format (DD/MM/YYYY) and GBP (£). Be specific with names and numbers in the bullets you do write. " +
"No headings, no numbered action-plan section, no closing summary — just the bullets.\n\n" +
"A 'Manual Context' section may be included below, written by a human who knows things the data can't show " +
"(e.g. an employee's contract type, a known one-off cause, a planned change). Treat it as ground truth and " +
"apply it directly — don't flag something as an anomaly or overspend if this context already explains it.\n\n" +
@ -423,9 +452,23 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast,
lines.push('')
lines.push(`## Notable Individual Shifts/Wages (${fmtDateUK(anomalies.fromStr)} - ${fmtDateUK(anomalies.toStr)})`)
lines.push('Employee | Department | Cost | Shifts')
lines.push(
"'vs own avg' compares this week's £/shift to that employee's own trailing 4-week average " +
"(" + fmtDateUK(anomalies.baselineFromStr) + " - " + fmtDateUK(anomalies.baselineToStr) + "). Do NOT flag pay as " +
"anomalous just because it's high in absolute terms or relative to colleagues — role, seniority, and " +
"shift length legitimately vary pay, and a supervisor on a long shift will always cost more than a " +
"junior on a short one. Only flag a genuine deviation from that employee's OWN baseline (e.g. +20% or " +
"more), a large deviation with no baseline (new/rare worker), or something the Manual Context doesn't " +
"already explain — do not list someone here just because they're at the top of the cost column."
)
lines.push('Employee | Department | Cost | Shifts | £/shift | own avg £/shift | vs own avg')
for (const e of anomalies.employees) {
lines.push(`${e.employee_name} | ${e.department_name} | ${fmtMoney(e.cost)} | ${e.shift_count}`)
const baselineStr = e.baselinePerShift != null ? fmtMoney(e.baselinePerShift) : 'no baseline'
const devStr = e.deviationPct != null ? `${e.deviationPct >= 0 ? '+' : ''}${(e.deviationPct * 100).toFixed(0)}%` : '-'
lines.push(
`${e.employee_name} | ${e.department_name} | ${fmtMoney(e.cost)} | ${e.shift_count} | ` +
`${fmtMoney(e.costPerShift)} | ${baselineStr} | ${devStr}`
)
}
lines.push('')