From 8f8e2736500e81e412779cb615f11c81709c74c5 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 25 Jul 2026 10:50:20 +0000 Subject: [PATCH] Compare employee pay against their own baseline, not raw rank; tighten output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/jobs/ai-insights.js | 81 +++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/backend/src/jobs/ai-insights.js b/backend/src/jobs/ai-insights.js index cd4f6e3..7dae207 100644 --- a/backend/src/jobs/ai-insights.js +++ b/backend/src/jobs/ai-insights.js @@ -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('')