Add insight history view; feed recent insights back into the prompt

Dashboard now shows a collapsible history of past AI insights (reusing
the existing /history endpoint), and generation now includes the last
3 insights in the prompt so the model can reference what it already
said instead of repeating the same points every day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-24 22:01:13 +00:00
parent cabaff8d35
commit 6339b33270
4 changed files with 100 additions and 4 deletions

View file

@ -46,6 +46,16 @@ export async function getAiInsightsConfig() {
}
}
// 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
@ -315,17 +325,29 @@ export async function gatherRevenueCorrelation(monthProgress) {
}
}
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation) {
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."
"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) {
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)`
@ -430,15 +452,16 @@ export async function generateInsight(triggeredBy = 'scheduler') {
}
const monthProgress = await gatherMonthProgressData()
const [priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation] = await Promise.all([
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)
const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation, recentInsights)
let result
try {

View file

@ -94,6 +94,10 @@ export function getLatestInsight(): Promise<AIInsight | null> {
return request('/ai-insights/latest')
}
export function getInsightHistory(limit = 20, offset = 0): Promise<{ insights: AIInsight[]; total: number }> {
return request(`/ai-insights/history?limit=${limit}&offset=${offset}`)
}
export function generateInsight(): Promise<{ success: boolean; content: string; input_tokens: number; output_tokens: number; model: string }> {
return request('/ai-insights/generate', { method: 'POST' })
}

View file

@ -0,0 +1,64 @@
import { useState, useEffect } from 'react'
import { History, ChevronDown, ChevronRight, Clock } from 'lucide-react'
import { getInsightHistory } from '../api'
import { formatAge, renderContent } from '../lib/aiInsight'
import type { AIInsight } from '../types'
export default function AIInsightHistory({ refreshKey }: { refreshKey?: number }) {
const [insights, setInsights] = useState<AIInsight[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<number | null>(null)
useEffect(() => {
setLoading(true)
getInsightHistory(20)
.then(res => setInsights(res.insights))
.finally(() => setLoading(false))
}, [refreshKey])
return (
<div className="card" style={{ marginTop: 16 }}>
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<History size={16} strokeWidth={1.75} color="var(--gold)" />
Insight History
</div>
{loading && <div className="state-center">Loading history</div>}
{!loading && insights.length === 0 && <div className="state-center">No past insights yet.</div>}
{insights.map((item, i) => {
const isOpen = expandedId === item.id
return (
<div key={item.id} style={{ borderTop: i === 0 ? 'none' : '1px solid var(--border)' }}>
<button
onClick={() => setExpandedId(isOpen ? null : item.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 0',
background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', font: 'inherit',
}}
>
{isOpen
? <ChevronDown size={14} strokeWidth={1.75} color="var(--text-muted)" style={{ flexShrink: 0 }} />
: <ChevronRight size={14} strokeWidth={1.75} color="var(--text-muted)" style={{ flexShrink: 0 }} />}
<span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{new Date(item.generated_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}>
<Clock size={11} strokeWidth={1.75} />
{formatAge(item.generated_at)}
</span>
</button>
{isOpen && (
<div style={{ padding: '0 0 16px 24px', fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-primary)' }}>
{renderContent(item.content)}
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 12 }}>
{item.input_tokens} / {item.output_tokens} tokens · {item.triggered_by}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Bot, Clock, RefreshCw } from 'lucide-react'
import { getLatestInsight, generateInsight } from '../api'
import { formatAge, renderContent } from '../lib/aiInsight'
import AIInsightHistory from '../components/AIInsightHistory'
import type { AIInsight } from '../types'
const REFRESH_MS = 5 * 60_000
@ -12,6 +13,7 @@ export default function Dashboard() {
const [error, setError] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [genError, setGenError] = useState<string | null>(null)
const [historyKey, setHistoryKey] = useState(0)
const load = useCallback(() => {
getLatestInsight()
@ -32,6 +34,7 @@ export default function Dashboard() {
try {
await generateInsight()
load()
setHistoryKey(k => k + 1)
} catch (e) {
setGenError(e instanceof Error ? e.message : 'Failed to generate insight')
} finally {
@ -88,6 +91,8 @@ export default function Dashboard() {
</div>
)}
</div>
<AIInsightHistory refreshKey={historyKey} />
</div>
)
}