Make the AI insight's prompt instructions editable via Settings

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-25 11:09:44 +00:00
parent 8f8e273650
commit e447d48e37
6 changed files with 73 additions and 23 deletions

View file

@ -112,7 +112,8 @@ export async function initDb() {
('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', '') ('ai_insights_manual_context', ''),
('ai_insights_prompt_instructions', '')
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
`) `)
} }

View file

@ -32,12 +32,13 @@ async function getCostCol() {
} }
export async function getAiInsightsConfig() { 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_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'), getConfig('ai_insights_manual_context'),
getConfig('ai_insights_prompt_instructions'),
]) ])
return { return {
enabled: enabled === 'true', enabled: enabled === 'true',
@ -45,6 +46,7 @@ export async function getAiInsightsConfig() {
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 || '', manualContext: manualContext || '',
promptInstructions: promptInstructions || '',
} }
} }
@ -376,21 +378,30 @@ export async function gatherRevenueCorrelation(monthProgress) {
} }
} }
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights = [], manualContext = '') { // Editable via Settings ("Prompt Instructions") — the persona/focus/tone/format part of the
const systemMsg = // 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 " + "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 " + "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 " + "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, " + "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. " + "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. " + "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" + "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 " + "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" +
"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."
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 = [] const lines = []
if (manualContext.trim()) { if (manualContext.trim()) {
@ -541,7 +552,7 @@ export async function generateInsight(triggeredBy = 'scheduler') {
getRecentInsights(3), 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 let result
try { try {

View file

@ -1,7 +1,7 @@
import { requireAuth, requireCap } from '../auth.js' import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js' import { pool, getConfig } from '../db.js'
import { getAnthropicApiKey } from '../lib/central-settings.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' import Anthropic from '@anthropic-ai/sdk'
export async function aiInsightsRoutes(fastify) { export async function aiInsightsRoutes(fastify) {
@ -29,6 +29,10 @@ export async function aiInsightsRoutes(fastify) {
return { insights: rowsRes.rows, total: countRes.rows[0].count } 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 () => { fastify.get('/api/ai-insights/usage', { preHandler: requireCap('view') }, async () => {
const res = await pool.query( const res = await pool.query(
`SELECT COALESCE(SUM(input_tokens), 0)::int AS input_tokens, `SELECT COALESCE(SUM(input_tokens), 0)::int AS input_tokens,

View file

@ -4,7 +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', 'ai_insights_manual_context', 'ai_insights_prompt_instructions',
]) ])
export async function settingsRoutes(fastify) { export async function settingsRoutes(fastify) {
@ -17,7 +17,7 @@ export async function settingsRoutes(fastify) {
'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' 'ai_insights_manual_context','ai_insights_prompt_instructions'
) )
ORDER BY key` ORDER BY key`
) )

View file

@ -105,3 +105,7 @@ export function generateInsight(): Promise<{ success: boolean; content: string;
export function testAiInsightsConnection(): Promise<{ status: string; message: string }> { export function testAiInsightsConnection(): Promise<{ status: string; message: string }> {
return request('/ai-insights/test', { method: 'POST' }) return request('/ai-insights/test', { method: 'POST' })
} }
export function getDefaultPrompt(): Promise<{ prompt: string }> {
return request('/ai-insights/default-prompt')
}

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { RefreshCw, Download, X, CheckSquare, Square, Bot } from 'lucide-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' import type { AppSetting, Department } from '../types'
function fmtDate(iso: string | null): string { function fmtDate(iso: string | null): string {
@ -26,14 +26,16 @@ export default function SettingsPage() {
const [testMessage, setTestMessage] = useState('') const [testMessage, setTestMessage] = useState('')
const [genStatus, setGenStatus] = useState<'idle' | 'generating' | 'done' | 'error'>('idle') const [genStatus, setGenStatus] = useState<'idle' | 'generating' | 'done' | 'error'>('idle')
const [genMessage, setGenMessage] = useState('') const [genMessage, setGenMessage] = useState('')
const [defaultPrompt, setDefaultPrompt] = useState('')
useEffect(() => { useEffect(() => {
Promise.all([getSettings(), getSyncStatus()]) Promise.all([getSettings(), getSyncStatus(), getDefaultPrompt()])
.then(([settRes, statusRes]) => { .then(([settRes, statusRes, promptRes]) => {
const map: Record<string, string> = {} const map: Record<string, string> = {}
for (const s of settRes.settings as AppSetting[]) map[s.key] = s.value for (const s of settRes.settings as AppSetting[]) map[s.key] = s.value
setSettings(map) setSettings(map)
setSyncStatus(statusRes) setSyncStatus(statusRes)
setDefaultPrompt(promptRes.prompt)
// Parse saved departments if present // Parse saved departments if present
if (map.departments) { 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_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 ?? '' }, { key: 'ai_insights_manual_context', value: settings.ai_insights_manual_context ?? '' },
{ key: 'ai_insights_prompt_instructions', value: settings.ai_insights_prompt_instructions ?? '' },
]) ])
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
@ -362,6 +365,33 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-muted)' }}>
Prompt Instructions
</label>
<button
className="btn btn-secondary"
style={{ fontSize: 11, padding: '2px 8px' }}
onClick={() => handleChange('ai_insights_prompt_instructions', defaultPrompt)}
>
Reset to default
</button>
</div>
<textarea
rows={8}
style={{ width: '100%', resize: 'vertical', fontFamily: 'inherit', fontSize: 12.5 }}
value={settings.ai_insights_prompt_instructions || defaultPrompt}
onChange={e => handleChange('ai_insights_prompt_instructions', e.target.value)}
/>
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--text-muted)' }}>
The persona/focus/tone/format instructions given to the model edit freely to change what it
prioritises, how strict the length limit is, or its tone. Pre-filled with the built-in default;
how the Manual Context and Recent Previous Insights sections above get interpreted is always
appended automatically and can't be removed here.
</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'}>