Add manual context field for AI insights

Free-text setting fed directly into every briefing's prompt as ground
truth (e.g. "Joseph Trice-Rolph is on a flexi rota, has no fixed rota
shifts — account for that in overspend"), so known context the data
itself can't show doesn't get flagged as an anomaly. New
ai_insights_manual_context config key, a textarea in Settings, and a
top-of-prompt section in the job with an explicit instruction to treat
it as ground truth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-25 10:07:37 +00:00
parent c09b31c41e
commit d36655c02c
4 changed files with 38 additions and 6 deletions

View file

@ -111,7 +111,8 @@ export async function initDb() {
('ai_insights_enabled', 'false'), ('ai_insights_enabled', 'false'),
('ai_insights_model', 'claude-haiku-4-5-20251001'), ('ai_insights_model', 'claude-haiku-4-5-20251001'),
('ai_insights_schedule_time', '07:15'), ('ai_insights_schedule_time', '07:15'),
('ai_insights_daily_token_budget', '5000') ('ai_insights_daily_token_budget', '5000'),
('ai_insights_manual_context', '')
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
`) `)
} }

View file

@ -37,17 +37,19 @@ async function getCostCol() {
} }
export async function getAiInsightsConfig() { export async function getAiInsightsConfig() {
const [enabled, model, scheduleTime, dailyTokenBudget] = await Promise.all([ const [enabled, model, scheduleTime, dailyTokenBudget, manualContext] = await Promise.all([
getConfig('ai_insights_enabled'), getConfig('ai_insights_enabled'),
getConfig('ai_insights_model'), getConfig('ai_insights_model'),
getConfig('ai_insights_schedule_time'), getConfig('ai_insights_schedule_time'),
getConfig('ai_insights_daily_token_budget'), getConfig('ai_insights_daily_token_budget'),
getConfig('ai_insights_manual_context'),
]) ])
return { return {
enabled: enabled === 'true', enabled: enabled === 'true',
model: model || DEFAULT_MODEL, model: model || DEFAULT_MODEL,
scheduleTime: scheduleTime || '07:15', scheduleTime: scheduleTime || '07:15',
dailyTokenBudget: parseInt(dailyTokenBudget, 10) || DEFAULT_DAILY_TOKEN_BUDGET, dailyTokenBudget: parseInt(dailyTokenBudget, 10) || DEFAULT_DAILY_TOKEN_BUDGET,
manualContext: manualContext || '',
} }
} }
@ -350,7 +352,7 @@ export async function gatherRevenueCorrelation(monthProgress) {
} }
} }
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights = []) { 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 " + "provide a concise daily briefing (3-5 bullet points). Focus on: how this month's wage cost is tracking " +
@ -358,11 +360,20 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast,
"what might be causing it, individual pay anomalies worth a look, and wage cost as a percentage of revenue. " + "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 " + "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" + "names and numbers. Keep it actionable — no fluff or generic advice.\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" +
"A 'Recent Previous Insights' section may be included below — compare against them explicitly: call out " + "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." "what's changed, what's resolved, and what's still an open issue. Don't just repeat the same points verbatim."
const lines = [] const lines = []
if (manualContext.trim()) {
lines.push('## Manual Context (from the wage cost controller — treat as ground truth)')
lines.push(manualContext.trim())
lines.push('')
}
if (recentInsights.length && toISODate(new Date()) <= METHOD_CHANGE_NOTE_UNTIL) { if (recentInsights.length && toISODate(new Date()) <= METHOD_CHANGE_NOTE_UNTIL) {
lines.push('## Methodology Note (25/07/2026)') lines.push('## Methodology Note (25/07/2026)')
lines.push( lines.push(
@ -513,7 +524,7 @@ export async function generateInsight(triggeredBy = 'scheduler') {
getRecentInsights(3), getRecentInsights(3),
]) ])
const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights) const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights, config.manualContext)
let result let result
try { try {
@ -527,7 +538,7 @@ export async function generateInsight(triggeredBy = 'scheduler') {
model: result.model, model: result.model,
input_tokens: result.input_tokens, input_tokens: result.input_tokens,
output_tokens: result.output_tokens, output_tokens: result.output_tokens,
data_snapshot: { monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation }, data_snapshot: { monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, manualContext: config.manualContext },
triggered_by: triggeredBy, triggered_by: triggeredBy,
}) })

View file

@ -4,6 +4,7 @@ import { pool, getConfig, setConfig } from '../db.js'
const ALLOWED_KEYS = new Set([ const ALLOWED_KEYS = new Set([
'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments', 'dept_budget_pcts', 'forecast_method', 'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments', 'dept_budget_pcts', 'forecast_method',
'ai_insights_enabled', 'ai_insights_model', 'ai_insights_schedule_time', 'ai_insights_daily_token_budget', 'ai_insights_enabled', 'ai_insights_model', 'ai_insights_schedule_time', 'ai_insights_daily_token_budget',
'ai_insights_manual_context',
]) ])
export async function settingsRoutes(fastify) { export async function settingsRoutes(fastify) {
@ -15,7 +16,8 @@ export async function settingsRoutes(fastify) {
WHERE key IN ( WHERE key IN (
'forecasting_url','forecasting_api_key','show_oncosts','departments','forecast_method', 'forecasting_url','forecasting_api_key','show_oncosts','departments','forecast_method',
'sync_last_at','backfill_last_at', 'sync_last_at','backfill_last_at',
'ai_insights_enabled','ai_insights_model','ai_insights_schedule_time','ai_insights_daily_token_budget' 'ai_insights_enabled','ai_insights_model','ai_insights_schedule_time','ai_insights_daily_token_budget',
'ai_insights_manual_context'
) )
ORDER BY key` ORDER BY key`
) )

View file

@ -62,6 +62,7 @@ export default function SettingsPage() {
{ key: 'ai_insights_model', value: settings.ai_insights_model ?? 'claude-haiku-4-5-20251001' }, { key: 'ai_insights_model', value: settings.ai_insights_model ?? 'claude-haiku-4-5-20251001' },
{ key: 'ai_insights_schedule_time', value: settings.ai_insights_schedule_time ?? '07:15' }, { key: 'ai_insights_schedule_time', value: settings.ai_insights_schedule_time ?? '07:15' },
{ key: 'ai_insights_daily_token_budget', value: settings.ai_insights_daily_token_budget ?? '5000' }, { key: 'ai_insights_daily_token_budget', value: settings.ai_insights_daily_token_budget ?? '5000' },
{ key: 'ai_insights_manual_context', value: settings.ai_insights_manual_context ?? '' },
]) ])
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
@ -344,6 +345,23 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
Additional Context
</label>
<textarea
rows={3}
style={{ width: '100%', resize: 'vertical', fontFamily: 'inherit' }}
value={settings.ai_insights_manual_context ?? ''}
onChange={e => handleChange('ai_insights_manual_context', e.target.value)}
placeholder="e.g. Joseph Trice-Rolph is on a flexi rota and has no fixed rota shifts — account for that when flagging overspend."
/>
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--text-muted)' }}>
Free text fed directly into every briefing as known context use it for things the data can't show
(contract quirks, known one-off causes, planned changes) so they don't get flagged as anomalies.
</p>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<div> <div>
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={testStatus === 'testing'}> <button className="btn btn-secondary" onClick={handleTestConnection} disabled={testStatus === 'testing'}>