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:
parent
8f8e273650
commit
e447d48e37
6 changed files with 73 additions and 23 deletions
|
|
@ -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
|
||||
`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {}
|
||||
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() {
|
|||
</p>
|
||||
</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>
|
||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={testStatus === 'testing'}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue