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:
parent
ed67a91b52
commit
8f8e273650
1 changed files with 62 additions and 19 deletions
|
|
@ -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('')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue