From e447d48e371605fadd12fa033d272b83bbe6a24d Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 25 Jul 2026 11:09:44 +0000 Subject: [PATCH] Make the AI insight's prompt instructions editable via Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the system prompt into an editable part (persona/focus/tone/ format — what the briefing prioritises and how strict its length is) and a fixed structural appendix (how the Manual Context and Recent Previous Insights sections get interpreted, which map directly to conditional data the code assembles, not just prose — kept safe from being accidentally edited away). New ai_insights_prompt_instructions config key, a GET /api/ai-insights/default-prompt endpoint so the Settings UI can pre-fill/reset to the built-in default, and a textarea in the AI Insights settings card. Co-Authored-By: Claude Sonnet 5 --- backend/src/db.js | 3 ++- backend/src/jobs/ai-insights.js | 43 +++++++++++++++++++------------ backend/src/routes/ai-insights.js | 6 ++++- backend/src/routes/settings.js | 4 +-- frontend/src/api.ts | 4 +++ frontend/src/pages/Settings.tsx | 36 +++++++++++++++++++++++--- 6 files changed, 73 insertions(+), 23 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 3f45688..d574773 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -112,7 +112,8 @@ export async function initDb() { ('ai_insights_model', 'claude-haiku-4-5-20251001'), ('ai_insights_schedule_time', '07:15'), ('ai_insights_daily_token_budget', '5000'), - ('ai_insights_manual_context', '') + ('ai_insights_manual_context', ''), + ('ai_insights_prompt_instructions', '') ON CONFLICT (key) DO NOTHING `) } diff --git a/backend/src/jobs/ai-insights.js b/backend/src/jobs/ai-insights.js index 7dae207..94f50bb 100644 --- a/backend/src/jobs/ai-insights.js +++ b/backend/src/jobs/ai-insights.js @@ -32,12 +32,13 @@ async function getCostCol() { } export async function getAiInsightsConfig() { - const [enabled, model, scheduleTime, dailyTokenBudget, manualContext] = await Promise.all([ + const [enabled, model, scheduleTime, dailyTokenBudget, manualContext, promptInstructions] = await Promise.all([ getConfig('ai_insights_enabled'), getConfig('ai_insights_model'), getConfig('ai_insights_schedule_time'), getConfig('ai_insights_daily_token_budget'), getConfig('ai_insights_manual_context'), + getConfig('ai_insights_prompt_instructions'), ]) return { enabled: enabled === 'true', @@ -45,6 +46,7 @@ export async function getAiInsightsConfig() { scheduleTime: scheduleTime || '07:15', dailyTokenBudget: parseInt(dailyTokenBudget, 10) || DEFAULT_DAILY_TOKEN_BUDGET, manualContext: manualContext || '', + promptInstructions: promptInstructions || '', } } @@ -376,20 +378,29 @@ 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 " + - "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" + - "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." +// Editable via Settings ("Prompt Instructions") — the persona/focus/tone/format part of the +// system prompt. Falls back to this when the setting is blank. Kept separate from +// STRUCTURAL_APPENDIX below so a custom prompt can't accidentally break how the Manual +// Context / Recent Previous Insights sections get interpreted — those map directly to +// conditional data sections in buildPrompt, not just prose. +export const DEFAULT_PROMPT_INSTRUCTIONS = + "You are an AI assistant for a wage cost controller in a UK hospitality business. Analyze the data below and " + + "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." + +const STRUCTURAL_APPENDIX = + "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 " + + "what's changed, what's resolved, and what's still an open issue. Don't just repeat the same points verbatim." + +export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights = [], manualContext = '', promptInstructions = '') { + const systemMsg = (promptInstructions.trim() || DEFAULT_PROMPT_INSTRUCTIONS) + "\n\n" + STRUCTURAL_APPENDIX const lines = [] @@ -541,7 +552,7 @@ export async function generateInsight(triggeredBy = 'scheduler') { getRecentInsights(3), ]) - const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights, config.manualContext) + const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights, config.manualContext, config.promptInstructions) let result try { diff --git a/backend/src/routes/ai-insights.js b/backend/src/routes/ai-insights.js index 3764b68..ac6056a 100644 --- a/backend/src/routes/ai-insights.js +++ b/backend/src/routes/ai-insights.js @@ -1,7 +1,7 @@ import { requireAuth, requireCap } from '../auth.js' import { pool, getConfig } from '../db.js' import { getAnthropicApiKey } from '../lib/central-settings.js' -import { generateInsight, MANUAL_RATE_LIMIT_MINUTES, DEFAULT_MODEL } from '../jobs/ai-insights.js' +import { generateInsight, MANUAL_RATE_LIMIT_MINUTES, DEFAULT_MODEL, DEFAULT_PROMPT_INSTRUCTIONS } from '../jobs/ai-insights.js' import Anthropic from '@anthropic-ai/sdk' export async function aiInsightsRoutes(fastify) { @@ -29,6 +29,10 @@ export async function aiInsightsRoutes(fastify) { return { insights: rowsRes.rows, total: countRes.rows[0].count } }) + fastify.get('/api/ai-insights/default-prompt', { preHandler: requireCap('settings') }, async () => { + return { prompt: DEFAULT_PROMPT_INSTRUCTIONS } + }) + fastify.get('/api/ai-insights/usage', { preHandler: requireCap('view') }, async () => { const res = await pool.query( `SELECT COALESCE(SUM(input_tokens), 0)::int AS input_tokens, diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 551f475..0545567 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -4,7 +4,7 @@ import { pool, getConfig, setConfig } from '../db.js' const ALLOWED_KEYS = new Set([ '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_manual_context', + 'ai_insights_manual_context', 'ai_insights_prompt_instructions', ]) export async function settingsRoutes(fastify) { @@ -17,7 +17,7 @@ export async function settingsRoutes(fastify) { 'forecasting_url','forecasting_api_key','show_oncosts','departments','forecast_method', 'sync_last_at','backfill_last_at', 'ai_insights_enabled','ai_insights_model','ai_insights_schedule_time','ai_insights_daily_token_budget', - 'ai_insights_manual_context' + 'ai_insights_manual_context','ai_insights_prompt_instructions' ) ORDER BY key` ) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 70eb971..07b8cf0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -105,3 +105,7 @@ export function generateInsight(): Promise<{ success: boolean; content: string; export function testAiInsightsConnection(): Promise<{ status: string; message: string }> { return request('/ai-insights/test', { method: 'POST' }) } + +export function getDefaultPrompt(): Promise<{ prompt: string }> { + return request('/ai-insights/default-prompt') +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a26ce98..04b442c 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { RefreshCw, Download, X, CheckSquare, Square, Bot } from 'lucide-react' -import { getSettings, saveSettings, getDepartments, triggerSync, getSyncStatus, cancelBackfill, cancelRotaBackfill, testAiInsightsConnection, generateInsight } from '../api' +import { getSettings, saveSettings, getDepartments, triggerSync, getSyncStatus, cancelBackfill, cancelRotaBackfill, testAiInsightsConnection, generateInsight, getDefaultPrompt } from '../api' import type { AppSetting, Department } from '../types' function fmtDate(iso: string | null): string { @@ -26,14 +26,16 @@ export default function SettingsPage() { const [testMessage, setTestMessage] = useState('') const [genStatus, setGenStatus] = useState<'idle' | 'generating' | 'done' | 'error'>('idle') const [genMessage, setGenMessage] = useState('') + const [defaultPrompt, setDefaultPrompt] = useState('') useEffect(() => { - Promise.all([getSettings(), getSyncStatus()]) - .then(([settRes, statusRes]) => { + Promise.all([getSettings(), getSyncStatus(), getDefaultPrompt()]) + .then(([settRes, statusRes, promptRes]) => { const map: Record = {} for (const s of settRes.settings as AppSetting[]) map[s.key] = s.value setSettings(map) setSyncStatus(statusRes) + setDefaultPrompt(promptRes.prompt) // Parse saved departments if present if (map.departments) { @@ -63,6 +65,7 @@ export default function SettingsPage() { { 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_manual_context', value: settings.ai_insights_manual_context ?? '' }, + { key: 'ai_insights_prompt_instructions', value: settings.ai_insights_prompt_instructions ?? '' }, ]) setSaved(true) setTimeout(() => setSaved(false), 2000) @@ -362,6 +365,33 @@ export default function SettingsPage() {

+
+
+ + +
+