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_MODEL = 'claude-haiku-4-5-20251001'
|
||||||
export const DEFAULT_DAILY_TOKEN_BUDGET = 5000
|
export const DEFAULT_DAILY_TOKEN_BUDGET = 5000
|
||||||
const MAX_OUTPUT_TOKENS = 900
|
const MAX_OUTPUT_TOKENS = 1400
|
||||||
export const MANUAL_RATE_LIMIT_MINUTES = 5
|
export const MANUAL_RATE_LIMIT_MINUTES = 5
|
||||||
|
|
||||||
function toISODate(d) {
|
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.
|
// Individual shifts/wages worth a look — over the trailing week, compared against each
|
||||||
export async function gatherEmployeeAnomalies(days = 7) {
|
// 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 costCol = await getCostCol()
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
|
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
|
||||||
const toStr = toISODate(new Date(today.getTime() - 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"),
|
// 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
|
// 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.
|
// 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(
|
pool.query(
|
||||||
`SELECT employee_id, employee_name, department_id,
|
`SELECT employee_id, employee_name, department_id,
|
||||||
SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
|
SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
|
||||||
|
|
@ -311,22 +317,44 @@ export async function gatherEmployeeAnomalies(days = 7) {
|
||||||
LIMIT 15`,
|
LIMIT 15`,
|
||||||
[fromStr, toStr]
|
[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`),
|
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 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 {
|
return {
|
||||||
fromStr,
|
fromStr,
|
||||||
toStr,
|
toStr,
|
||||||
employees: res.rows.map(r => ({
|
baselineFromStr,
|
||||||
employee_id: r.employee_id,
|
baselineToStr,
|
||||||
employee_name: r.employee_name,
|
employees: res.rows.map(r => {
|
||||||
department_id: r.department_id,
|
const cost = parseFloat(r.cost)
|
||||||
department_name: deptNames[r.department_id] || r.department_id,
|
const costPerShift = r.shift_count > 0 ? cost / r.shift_count : 0
|
||||||
cost: parseFloat(r.cost),
|
const baseline = baselinePerShift[r.employee_id] ?? null
|
||||||
shift_count: r.shift_count,
|
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 = '') {
|
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights = [], manualContext = '') {
|
||||||
const systemMsg =
|
const systemMsg =
|
||||||
"You are an AI assistant for a wage cost controller in a UK hospitality business. Analyze the data below and " +
|
"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 " +
|
"produce a SHORT daily briefing: hard limit of 5 bullet points TOTAL across the entire response, each a single " +
|
||||||
"against budget, the month-end forecast, notable variance between rota and actual cost by department and " +
|
"sentence (max ~30 words). Do not create a separate section or heading per department or per employee — pick " +
|
||||||
"what might be causing it, individual pay anomalies worth a look, and wage cost as a percentage of revenue. " +
|
"only the 4-5 most important things overall (across budget tracking, forecast, rota variance, pay anomalies, " +
|
||||||
"Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. Be specific with department/employee " +
|
"wage % of revenue) and drop the rest; a department or employee with nothing notable gets no mention at all. " +
|
||||||
"names and numbers. Keep it actionable — no fluff or generic advice.\n\n" +
|
"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 " +
|
"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 " +
|
"(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" +
|
"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('')
|
||||||
|
|
||||||
lines.push(`## Notable Individual Shifts/Wages (${fmtDateUK(anomalies.fromStr)} - ${fmtDateUK(anomalies.toStr)})`)
|
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) {
|
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('')
|
lines.push('')
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue