MAX_OUTPUT_TOKENS was 600 and briefings were hitting it mid-sentence (confirmed on a live generation — cut off mid-bullet). Raised to 900. Also adds a time-boxed methodology note (expires 29/07/2026) so the model doesn't flag the 25/07 base-cost-only rota fix as an unexplained swing when it sees a prior insight's rota variance figures differ — it was told to compare against recent insights explicitly, and without this note it correctly but unhelpfully treated a deliberate correction as an open question needing clarification. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
560 lines
23 KiB
JavaScript
560 lines
23 KiB
JavaScript
import Anthropic from '@anthropic-ai/sdk'
|
|
import { pool, getConfig } from '../db.js'
|
|
import { getAnthropicApiKey } from '../lib/central-settings.js'
|
|
import { fcFetch } from '../lib/forecasting-client.js'
|
|
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
|
|
export const MANUAL_RATE_LIMIT_MINUTES = 5
|
|
|
|
// One-time context so the model doesn't flag the 25/07 rota-methodology fix (oncost-inclusive
|
|
// -> base-pay-only comparison) as an unexplained swing when it sees a prior insight's figures
|
|
// change. Safe to delete this constant and its usage below once it's a few days stale.
|
|
const METHOD_CHANGE_NOTE_UNTIL = '2026-07-29'
|
|
|
|
function toISODate(d) {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
function fmtDateUK(isoDate) {
|
|
const [y, m, d] = String(isoDate).split('-')
|
|
return `${d}/${m}/${y}`
|
|
}
|
|
|
|
function fmtMoney(n) {
|
|
return n == null ? '-' : `£${Number(n).toLocaleString('en-GB', { maximumFractionDigits: 0 })}`
|
|
}
|
|
|
|
function fmtPct(n) {
|
|
return n == null ? '-' : `${(n * 100).toFixed(0)}%`
|
|
}
|
|
|
|
async function getCostCol() {
|
|
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
|
return showOncosts ? 'total_cost' : 'base_cost'
|
|
}
|
|
|
|
export async function getAiInsightsConfig() {
|
|
const [enabled, model, scheduleTime, dailyTokenBudget] = await Promise.all([
|
|
getConfig('ai_insights_enabled'),
|
|
getConfig('ai_insights_model'),
|
|
getConfig('ai_insights_schedule_time'),
|
|
getConfig('ai_insights_daily_token_budget'),
|
|
])
|
|
return {
|
|
enabled: enabled === 'true',
|
|
model: model || DEFAULT_MODEL,
|
|
scheduleTime: scheduleTime || '07:15',
|
|
dailyTokenBudget: parseInt(dailyTokenBudget, 10) || DEFAULT_DAILY_TOKEN_BUDGET,
|
|
}
|
|
}
|
|
|
|
// Last few generated insights, most recent first — fed back into the prompt so the
|
|
// model can reference what it already said instead of repeating itself verbatim.
|
|
export async function getRecentInsights(limit = 3) {
|
|
const res = await pool.query(
|
|
`SELECT generated_at, content FROM ai_insights ORDER BY generated_at DESC LIMIT $1`,
|
|
[limit]
|
|
)
|
|
return res.rows
|
|
}
|
|
|
|
export async function checkDailyBudget(budgetTokens) {
|
|
const res = await pool.query(
|
|
`SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
|
|
FROM ai_insights WHERE generated_at >= CURRENT_DATE`
|
|
)
|
|
const usedToday = parseInt(res.rows[0].total, 10)
|
|
return { withinBudget: usedToday < budgetTokens, usedToday }
|
|
}
|
|
|
|
// Month-to-date total wages cost (company-wide) vs the total month budget.
|
|
// No per-department budget split — that split is a rough estimate and skews comparisons.
|
|
export async function gatherMonthProgressData() {
|
|
const costCol = await getCostCol()
|
|
const today = new Date()
|
|
const monthStart = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`
|
|
const yesterdayStr = toISODate(new Date(today.getTime() - 86_400_000))
|
|
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
|
|
const daysElapsed = Math.max(0, today.getDate() - 1)
|
|
|
|
const [actualRes, budgetRes] = await Promise.all([
|
|
pool.query(
|
|
`SELECT COALESCE(SUM(${costCol}), 0) AS mtd_cost
|
|
FROM wage_actuals WHERE date >= $1 AND date <= $2`,
|
|
[monthStart, yesterdayStr]
|
|
),
|
|
pool.query(`SELECT budget_amount FROM wage_budgets WHERE month = $1`, [monthStart]),
|
|
])
|
|
|
|
const mtdCost = parseFloat(actualRes.rows[0].mtd_cost)
|
|
const budgetAmount = budgetRes.rows[0] ? parseFloat(budgetRes.rows[0].budget_amount) : null
|
|
|
|
return {
|
|
monthStart,
|
|
monthLabel: monthStart.slice(0, 7),
|
|
yesterdayStr,
|
|
daysElapsed,
|
|
daysInMonth,
|
|
pctMonthElapsed: daysElapsed / daysInMonth,
|
|
mtdCost,
|
|
budgetAmount,
|
|
pctBudgetUsed: budgetAmount ? mtdCost / budgetAmount : null,
|
|
}
|
|
}
|
|
|
|
// Full previous month and full same-month-last-year totals (complete datasets, not
|
|
// sliced to a matching to-date range) — the model draws its own pace comparison.
|
|
export async function gatherPriorPeriodData() {
|
|
const costCol = await getCostCol()
|
|
const today = new Date()
|
|
|
|
const prevMonthStart = toISODate(new Date(today.getFullYear(), today.getMonth() - 1, 1))
|
|
const prevMonthEnd = toISODate(new Date(today.getFullYear(), today.getMonth(), 0))
|
|
const lastYearStart = toISODate(new Date(today.getFullYear() - 1, today.getMonth(), 1))
|
|
const lastYearEnd = toISODate(new Date(today.getFullYear() - 1, today.getMonth() + 1, 0))
|
|
|
|
const shapeDepts = rows => rows.map(r => ({
|
|
department_id: r.department_id,
|
|
department_name: r.department_name,
|
|
cost: parseFloat(r.cost),
|
|
}))
|
|
|
|
const deptQuery = (from, to) => pool.query(
|
|
`SELECT department_id, department_name, SUM(${costCol}) AS cost
|
|
FROM wage_actuals WHERE date >= $1 AND date <= $2
|
|
GROUP BY department_id, department_name ORDER BY SUM(${costCol}) DESC`,
|
|
[from, to]
|
|
)
|
|
|
|
const [prevMonthRes, lastYearRes] = await Promise.all([
|
|
deptQuery(prevMonthStart, prevMonthEnd),
|
|
deptQuery(lastYearStart, lastYearEnd),
|
|
])
|
|
|
|
const prevMonthDepts = shapeDepts(prevMonthRes.rows)
|
|
const lastYearDepts = shapeDepts(lastYearRes.rows)
|
|
|
|
return {
|
|
prevMonthLabel: prevMonthStart.slice(0, 7),
|
|
prevMonthTotal: prevMonthDepts.reduce((s, d) => s + d.cost, 0),
|
|
prevMonthDepts,
|
|
lastYearLabel: lastYearStart.slice(0, 7),
|
|
lastYearTotal: lastYearDepts.reduce((s, d) => s + d.cost, 0),
|
|
lastYearDepts,
|
|
}
|
|
}
|
|
|
|
// Department-level variance between scheduled (rota) and actual cost, over the trailing
|
|
// window. Rota rows for past dates aren't deleted once synced, so this covers real history.
|
|
//
|
|
// Always compares on BASE cost for both sides, regardless of the show_oncosts display
|
|
// setting: Workforce's schedules API never actually supplies employer NI oncosts (confirmed —
|
|
// published_total_cost is identical to published_base_cost on every synced row), so comparing
|
|
// oncost-inclusive actuals against rota would inflate every variance by ~14-20% for reasons
|
|
// that have nothing to do with real overspend. Matches the same caveat already surfaced on the
|
|
// Monthly page's rota-based forecast footnote.
|
|
export async function gatherRotaVsActualData(days = 28) {
|
|
const actualCostCol = 'base_cost'
|
|
const schedCostExpr = '(published_base_cost + unpublished_base_cost)'
|
|
|
|
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 [actualRes, schedRes] = await Promise.all([
|
|
pool.query(
|
|
`SELECT date, department_id, department_name, ${actualCostCol} AS cost
|
|
FROM wage_actuals WHERE date >= $1 AND date <= $2`,
|
|
[fromStr, toStr]
|
|
),
|
|
pool.query(
|
|
`SELECT date, department_id, ${schedCostExpr} AS cost
|
|
FROM wage_scheduled WHERE date >= $1 AND date <= $2`,
|
|
[fromStr, toStr]
|
|
),
|
|
])
|
|
|
|
const totalDaysInWindow = Math.round((new Date(toStr + 'T00:00:00') - new Date(fromStr + 'T00:00:00')) / 86_400_000) + 1
|
|
|
|
const schedMap = {}
|
|
for (const r of schedRes.rows) {
|
|
schedMap[`${r.date.toISOString().slice(0, 10)}:${r.department_id}`] = parseFloat(r.cost)
|
|
}
|
|
|
|
// actualTotal = full-window actual cost (context only). actualOnRotaDays = actual cost
|
|
// restricted to the SAME days rota data exists for — this is what variance is computed
|
|
// from, so a department with sparse rota history doesn't get a wildly inflated "overspend"
|
|
// that's really just missing rota rows, not real cost variance.
|
|
const byDept = {}
|
|
for (const r of actualRes.rows) {
|
|
const dateStr = r.date.toISOString().slice(0, 10)
|
|
const dep = r.department_id
|
|
byDept[dep] ??= { department_id: dep, department_name: r.department_name, actualTotal: 0, actualOnRotaDays: 0, schedTotal: 0, daysWithRota: 0 }
|
|
const actualCost = parseFloat(r.cost)
|
|
const schedCost = schedMap[`${dateStr}:${dep}`]
|
|
byDept[dep].actualTotal += actualCost
|
|
if (schedCost != null) {
|
|
byDept[dep].schedTotal += schedCost
|
|
byDept[dep].actualOnRotaDays += actualCost
|
|
byDept[dep].daysWithRota++
|
|
}
|
|
}
|
|
|
|
const depts = Object.values(byDept)
|
|
.map(d => ({
|
|
...d,
|
|
variance: d.actualOnRotaDays - d.schedTotal,
|
|
variancePct: d.schedTotal > 0 ? (d.actualOnRotaDays - d.schedTotal) / d.schedTotal : null,
|
|
}))
|
|
.sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance))
|
|
|
|
return { fromStr, toStr, totalDaysInWindow, depts }
|
|
}
|
|
|
|
// Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic
|
|
// and the forecast_method setting already used by the Monthly/Weekly pages, so this never
|
|
// disagrees with what those pages show. Not related to forecasting app's revenue model.
|
|
export async function gatherForecastData(monthProgress) {
|
|
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
|
const forecastMethodRaw = await getConfig('forecast_method')
|
|
const forecastMethod = forecastMethodRaw === 'rota' ? 'rota' : 'repeat'
|
|
|
|
const today = new Date()
|
|
const todayStr = toISODate(today)
|
|
const monthEndDate = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
|
const monthEndStr = toISODate(monthEndDate)
|
|
const actualsFrom = toISODate(new Date(today.getTime() - 42 * 86_400_000))
|
|
|
|
const [actualRes, schedRes] = await Promise.all([
|
|
pool.query(
|
|
`SELECT date, department_id, department_name, ${showOncosts ? 'total_cost' : 'base_cost'} AS cost
|
|
FROM wage_actuals WHERE date >= $1 AND date < $2`,
|
|
[actualsFrom, todayStr]
|
|
),
|
|
pool.query(
|
|
`SELECT date, department_id, department_name,
|
|
published_base_cost, published_total_cost, published_shift_count,
|
|
unpublished_base_cost, unpublished_total_cost, unpublished_shift_count
|
|
FROM wage_scheduled WHERE date >= $1 AND date <= $2`,
|
|
[todayStr, monthEndStr]
|
|
),
|
|
])
|
|
|
|
const deptNames = {}
|
|
const actualByDept = {}
|
|
for (const r of actualRes.rows) {
|
|
const dep = r.department_id
|
|
deptNames[dep] = r.department_name
|
|
actualByDept[dep] ??= {}
|
|
actualByDept[dep][r.date.toISOString().slice(0, 10)] = { cost: parseFloat(r.cost) }
|
|
}
|
|
|
|
const schedByDept = {}
|
|
for (const r of schedRes.rows) {
|
|
const dep = r.department_id
|
|
deptNames[dep] = r.department_name
|
|
schedByDept[dep] ??= {}
|
|
schedByDept[dep][r.date.toISOString().slice(0, 10)] = {
|
|
published_cost: parseFloat(showOncosts ? r.published_total_cost : r.published_base_cost),
|
|
unpublished_cost: parseFloat(showOncosts ? r.unpublished_total_cost : r.unpublished_base_cost),
|
|
published_shift_count: r.published_shift_count,
|
|
unpublished_shift_count: r.unpublished_shift_count,
|
|
}
|
|
}
|
|
|
|
const remainingDays = []
|
|
for (let d = new Date(today); d <= monthEndDate; d.setDate(d.getDate() + 1)) {
|
|
remainingDays.push(toISODate(d))
|
|
}
|
|
|
|
let remainingTotal = 0
|
|
const byDept = []
|
|
for (const dep of Object.keys(deptNames)) {
|
|
const schedDep = forecastMethod === 'rota' ? schedByDept[dep] : undefined
|
|
let depRemaining = 0
|
|
for (const dateStr of remainingDays) {
|
|
depRemaining += forecastDayCost(dateStr, actualByDept[dep], schedDep, false).cost
|
|
}
|
|
remainingTotal += depRemaining
|
|
byDept.push({ department_id: dep, department_name: deptNames[dep], remaining: depRemaining })
|
|
}
|
|
|
|
const projectedTotal = (monthProgress?.mtdCost ?? 0) + remainingTotal
|
|
return {
|
|
forecastMethod,
|
|
remainingDaysCount: remainingDays.length,
|
|
remainingTotal,
|
|
projectedTotal,
|
|
byDept: byDept.sort((a, b) => b.remaining - a.remaining),
|
|
}
|
|
}
|
|
|
|
// Individual shifts/wages worth a look — top-cost employees over the trailing week.
|
|
export async function gatherEmployeeAnomalies(days = 7) {
|
|
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))
|
|
|
|
// 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([
|
|
pool.query(
|
|
`SELECT employee_id, employee_name, department_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, employee_name, department_id
|
|
ORDER BY SUM(${costCol}) DESC
|
|
LIMIT 15`,
|
|
[fromStr, toStr]
|
|
),
|
|
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]))
|
|
|
|
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,
|
|
})),
|
|
}
|
|
}
|
|
|
|
// Wage cost as a % of net sales, month-to-date — reuses the same cross-app call net-sales.js
|
|
// already makes to the forecasting app's public revenue API.
|
|
export async function gatherRevenueCorrelation(monthProgress) {
|
|
try {
|
|
const days = Math.max(1, monthProgress.daysElapsed)
|
|
const data = await fcFetch(`/forecast/revenue?start_date=${monthProgress.monthStart}&days=${days}&type=all&dow_align=true`)
|
|
const rows = data?.data ?? []
|
|
const totalRevenue = rows.reduce((s, d) => s + parseFloat(d.total?.forecast ?? d.total?.otb ?? 0), 0)
|
|
return {
|
|
totalRevenue,
|
|
wagePct: totalRevenue > 0 ? (monthProgress.mtdCost / totalRevenue) * 100 : null,
|
|
daysCovered: rows.length,
|
|
}
|
|
} catch (e) {
|
|
return { error: e.message }
|
|
}
|
|
}
|
|
|
|
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights = []) {
|
|
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" +
|
|
"A 'Recent Previous Insights' section may be included below — compare against them explicitly: call out " +
|
|
"what's changed, what's resolved, and what's still an open issue. Don't just repeat the same points verbatim."
|
|
|
|
const lines = []
|
|
|
|
if (recentInsights.length && toISODate(new Date()) <= METHOD_CHANGE_NOTE_UNTIL) {
|
|
lines.push('## Methodology Note (25/07/2026)')
|
|
lines.push(
|
|
"The Rota vs Actual Variance comparison was corrected on 25/07/2026 to use base pay only on both " +
|
|
"sides (previously actual cost included employer NI oncosts while rota did not, since Workforce's " +
|
|
"schedules API never supplies oncosts — this inflated every rota variance figure by roughly 14-20%). " +
|
|
"If a recent previous insight below shows a notably different rota variance figure for the same " +
|
|
"department than today's, that is this correction taking effect, not a real change in performance — " +
|
|
"do not describe it as unexplained or needing clarification."
|
|
)
|
|
lines.push('')
|
|
}
|
|
|
|
if (recentInsights.length) {
|
|
lines.push('## Recent Previous Insights (most recent first — reference these, do not just repeat them)')
|
|
for (const r of recentInsights) {
|
|
const when = new Date(r.generated_at).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
lines.push(`${when}:`)
|
|
lines.push(r.content)
|
|
lines.push('')
|
|
}
|
|
}
|
|
|
|
lines.push(`## Month to Date (${monthProgress.monthLabel})`)
|
|
lines.push(
|
|
`Day ${monthProgress.daysElapsed} of ${monthProgress.daysInMonth} (${fmtPct(monthProgress.pctMonthElapsed)} of month elapsed)`
|
|
)
|
|
lines.push(
|
|
`MTD cost: ${fmtMoney(monthProgress.mtdCost)} | Month budget: ${fmtMoney(monthProgress.budgetAmount)}` +
|
|
(monthProgress.pctBudgetUsed != null ? ` (${fmtPct(monthProgress.pctBudgetUsed)} used)` : '')
|
|
)
|
|
lines.push('')
|
|
|
|
lines.push('## Prior Period Comparison (full-month totals, for context — not a budget split)')
|
|
lines.push(`Previous month (${priorPeriod.prevMonthLabel}) total: ${fmtMoney(priorPeriod.prevMonthTotal)}`)
|
|
for (const d of priorPeriod.prevMonthDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
|
|
lines.push(`Same month last year (${priorPeriod.lastYearLabel}) total: ${fmtMoney(priorPeriod.lastYearTotal)}`)
|
|
for (const d of priorPeriod.lastYearDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
|
|
lines.push('')
|
|
|
|
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days)`)
|
|
lines.push(
|
|
"Figures below are BASE PAY only (excluding employer NI) on both sides — Workforce's rota/schedules " +
|
|
"API doesn't supply oncosts, so this is the only basis that's genuinely comparable; do not describe " +
|
|
"this variance using total-cost figures from other sections. Variance is computed only over days that " +
|
|
"have rota data (\"days w/ rota\" below) — if that's well below the total window, treat the variance " +
|
|
"as partial/uncertain due to missing rota history, not a confirmed overspend, and say so explicitly."
|
|
)
|
|
lines.push('Department | Actual base pay (days w/ rota) | Rota base pay | Variance | Variance % | Days w/ rota | Actual base pay (full window)')
|
|
for (const d of rotaVsActual.depts) {
|
|
lines.push(
|
|
`${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` +
|
|
`${fmtMoney(d.variance)} | ${fmtPct(d.variancePct)} | ${d.daysWithRota}/${rotaVsActual.totalDaysInWindow} | ${fmtMoney(d.actualTotal)}`
|
|
)
|
|
}
|
|
lines.push('')
|
|
|
|
lines.push(`## Forecast (method: ${forecast.forecastMethod}, ${forecast.remainingDaysCount} days remaining)`)
|
|
if (forecast.forecastMethod === 'rota') {
|
|
lines.push(
|
|
"Note: remaining days sourced from rota exclude employer NI oncosts (Workforce's schedules API " +
|
|
"doesn't supply them), so this projection may modestly understate the true month-end total — same " +
|
|
"known limitation shown on the Monthly page."
|
|
)
|
|
}
|
|
lines.push(
|
|
`Projected month-end total: ${fmtMoney(forecast.projectedTotal)} vs budget ${fmtMoney(monthProgress.budgetAmount)}` +
|
|
(monthProgress.budgetAmount != null
|
|
? ` (${forecast.projectedTotal > monthProgress.budgetAmount ? 'over' : 'under'} by ${fmtMoney(Math.abs(forecast.projectedTotal - monthProgress.budgetAmount))})`
|
|
: '')
|
|
)
|
|
for (const d of forecast.byDept) lines.push(` ${d.department_name} remaining: ${fmtMoney(d.remaining)}`)
|
|
lines.push('')
|
|
|
|
lines.push(`## Notable Individual Shifts/Wages (${fmtDateUK(anomalies.fromStr)} - ${fmtDateUK(anomalies.toStr)})`)
|
|
lines.push('Employee | Department | Cost | Shifts')
|
|
for (const e of anomalies.employees) {
|
|
lines.push(`${e.employee_name} | ${e.department_name} | ${fmtMoney(e.cost)} | ${e.shift_count}`)
|
|
}
|
|
lines.push('')
|
|
|
|
lines.push('## Wage Cost vs Revenue (month to date)')
|
|
if (revenueCorrelation.error) {
|
|
lines.push(`Unavailable: ${revenueCorrelation.error}`)
|
|
} else {
|
|
lines.push(
|
|
`Revenue: ${fmtMoney(revenueCorrelation.totalRevenue)} | Wage cost: ${fmtMoney(monthProgress.mtdCost)} | ` +
|
|
`Wage %: ${revenueCorrelation.wagePct != null ? revenueCorrelation.wagePct.toFixed(1) + '%' : '-'}`
|
|
)
|
|
}
|
|
|
|
return { systemMsg, userMsg: lines.join('\n') }
|
|
}
|
|
|
|
export async function callLlm(apiKey, systemMsg, userMsg, model) {
|
|
const client = new Anthropic({ apiKey })
|
|
const response = await client.messages.create({
|
|
model,
|
|
max_tokens: MAX_OUTPUT_TOKENS,
|
|
temperature: 0.2,
|
|
system: systemMsg,
|
|
messages: [{ role: 'user', content: userMsg }],
|
|
})
|
|
const content = response.content?.[0]?.text ?? ''
|
|
return {
|
|
content,
|
|
input_tokens: response.usage.input_tokens,
|
|
output_tokens: response.usage.output_tokens,
|
|
model,
|
|
}
|
|
}
|
|
|
|
export async function saveInsight({ content, model, input_tokens, output_tokens, data_snapshot, triggered_by }) {
|
|
await pool.query(
|
|
`INSERT INTO ai_insights (content, model, input_tokens, output_tokens, data_snapshot, triggered_by)
|
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6)`,
|
|
[content, model, input_tokens, output_tokens, JSON.stringify(data_snapshot), triggered_by]
|
|
)
|
|
}
|
|
|
|
export async function cleanupOldInsights(keepDays = 90) {
|
|
await pool.query(`DELETE FROM ai_insights WHERE generated_at < NOW() - ($1 || ' days')::interval`, [keepDays])
|
|
}
|
|
|
|
export async function generateInsight(triggeredBy = 'scheduler') {
|
|
const config = await getAiInsightsConfig()
|
|
if (!config.enabled) return { success: false, error: 'AI insights disabled' }
|
|
|
|
let apiKey
|
|
try {
|
|
apiKey = await getAnthropicApiKey()
|
|
} catch (e) {
|
|
return { success: false, error: e.message }
|
|
}
|
|
|
|
const { withinBudget, usedToday } = await checkDailyBudget(config.dailyTokenBudget)
|
|
if (!withinBudget) {
|
|
return { success: false, error: `Daily token budget exceeded (${usedToday}/${config.dailyTokenBudget} tokens used today)` }
|
|
}
|
|
|
|
const monthProgress = await gatherMonthProgressData()
|
|
const [priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights] = await Promise.all([
|
|
gatherPriorPeriodData(),
|
|
gatherRotaVsActualData(),
|
|
gatherForecastData(monthProgress),
|
|
gatherEmployeeAnomalies(),
|
|
gatherRevenueCorrelation(monthProgress),
|
|
getRecentInsights(3),
|
|
])
|
|
|
|
const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights)
|
|
|
|
let result
|
|
try {
|
|
result = await callLlm(apiKey, systemMsg, userMsg, config.model)
|
|
} catch (e) {
|
|
return { success: false, error: `LLM call failed: ${e.message}` }
|
|
}
|
|
|
|
await saveInsight({
|
|
content: result.content,
|
|
model: result.model,
|
|
input_tokens: result.input_tokens,
|
|
output_tokens: result.output_tokens,
|
|
data_snapshot: { monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation },
|
|
triggered_by: triggeredBy,
|
|
})
|
|
|
|
try {
|
|
await cleanupOldInsights(90)
|
|
} catch (e) {
|
|
console.warn('[ai-insights] cleanup failed:', e.message)
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
content: result.content,
|
|
input_tokens: result.input_tokens,
|
|
output_tokens: result.output_tokens,
|
|
model: result.model,
|
|
}
|
|
}
|
|
|
|
export async function runAiInsightsGeneration() {
|
|
try {
|
|
const result = await generateInsight('scheduler')
|
|
if (result.success) {
|
|
console.log('[ai-insights] scheduled generation completed')
|
|
} else {
|
|
console.log('[ai-insights] scheduled generation skipped:', result.error)
|
|
}
|
|
} catch (e) {
|
|
console.error('[ai-insights] scheduled generation failed:', e.message)
|
|
}
|
|
}
|