Expand AI insight: fix broken competitor query, add continuity, 30-day horizon, holidays

- Fix gather_competitor_data() tier filter: it queried tier IN ('primary','secondary'),
  values that never exist (real values are 'own'/'competitor'/'market'), so the
  cheapest-competitor comparison has always silently returned nothing.
- Feed the previous insight back into the prompt so the model can note what's
  changed/resolved instead of repeating itself.
- Extend forecast horizon from 14 to 30 days; add a per-day revenue table
  alongside the existing occupancy table.
- Annotate the occupancy table with UK (England) bank holidays.
- Add a same-channel market-movement section (B.com vs B.com, rack vs rack)
  diffing rates against the last insight's snapshot, threshold £3.
- Add a parsed headline field + insight history list on the Dashboard,
  collapsed to headline/age and expandable to full content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-24 18:03:31 +00:00
parent 2f5349bd1c
commit 2a7ee1d6b8
9 changed files with 381 additions and 83 deletions

View file

@ -28,7 +28,7 @@ async def get_latest_insight(
"""Get the most recent AI insight for the dashboard card."""
result = await db.execute(
text("""
SELECT id, generated_at, insight_type, content, model,
SELECT id, generated_at, insight_type, headline, content, model,
input_tokens, output_tokens, triggered_by
FROM ai_insights
ORDER BY generated_at DESC
@ -43,6 +43,7 @@ async def get_latest_insight(
"id": row.id,
"generated_at": row.generated_at.isoformat(),
"insight_type": row.insight_type,
"headline": row.headline,
"content": row.content,
"model": row.model,
"input_tokens": row.input_tokens,
@ -61,7 +62,7 @@ async def get_insight_history(
"""Get historical AI insights with pagination."""
result = await db.execute(
text("""
SELECT id, generated_at, insight_type, content, model,
SELECT id, generated_at, insight_type, headline, content, model,
input_tokens, output_tokens, triggered_by
FROM ai_insights
ORDER BY generated_at DESC
@ -79,6 +80,7 @@ async def get_insight_history(
"id": row.id,
"generated_at": row.generated_at.isoformat(),
"insight_type": row.insight_type,
"headline": row.headline,
"content": row.content,
"model": row.model,
"input_tokens": row.input_tokens,
@ -166,6 +168,7 @@ async def generate_insight_manual(
return {
"success": True,
"content": result["content"],
"headline": result.get("headline"),
"input_tokens": result["input_tokens"],
"output_tokens": result["output_tokens"],
"model": result["model"],

View file

@ -1223,7 +1223,7 @@ class AIInsightsSettingsResponse(BaseModel):
api_key_set: bool = False
model: str = "claude-haiku-4-5-20251001"
schedule_time: str = "07:15"
daily_token_budget: int = 5000
daily_token_budget: int = 12000
class AIInsightsSettingsUpdate(BaseModel):
@ -1258,7 +1258,7 @@ async def get_ai_insights_settings(
api_key_set=config.get('_api_key_set', False),
model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'),
schedule_time=config.get('ai_insights_schedule_time', '07:15'),
daily_token_budget=int(config.get('ai_insights_daily_token_budget', '5000')),
daily_token_budget=int(config.get('ai_insights_daily_token_budget', '12000')),
)

View file

@ -1,18 +1,18 @@
"""
AI Daily Insights Generation Job
Gathers Pickup-V2 forecast data, booking pace, competitor rates, and rate parity
information, then sends a compact prompt to Anthropic's Haiku model to generate
a daily briefing for hotel revenue staff.
Gathers Pickup-V2 forecast data, booking pace, competitor rates, rate parity,
UK bank holidays, and the previous insight, then sends a compact prompt to
Anthropic's Haiku model to generate a daily briefing for hotel revenue staff.
Schedule: Daily at 7:15 AM (after all forecasts and accuracy calc complete)
Cost: ~$0.05/month at 1 run/day with Haiku
"""
import json
import logging
from datetime import date, timedelta, datetime, timezone
from typing import Dict, List, Any, Optional
import holidays
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@ -21,8 +21,10 @@ from database import AsyncSessionLocal
logger = logging.getLogger(__name__)
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
DEFAULT_DAILY_TOKEN_BUDGET = 5000
MAX_OUTPUT_TOKENS = 400
DEFAULT_DAILY_TOKEN_BUDGET = 12000
MAX_OUTPUT_TOKENS = 550
FORECAST_HORIZON_DAYS = 30
RATE_MOVEMENT_THRESHOLD = 3.0 # GBP — minimum delta to surface as a market move
async def get_config(db: AsyncSession) -> Dict[str, str]:
@ -61,7 +63,28 @@ async def check_daily_budget(db: AsyncSession, budget: int) -> tuple[bool, int]:
return used < budget, used
async def gather_occupancy_data(db: AsyncSession, days: int = 14) -> List[Dict]:
async def get_previous_insight(db: AsyncSession) -> Optional[Dict[str, Any]]:
"""Fetch the most recent insight so the new one can compare against it."""
result = await db.execute(
text("""
SELECT headline, content, generated_at, data_snapshot
FROM ai_insights
ORDER BY generated_at DESC
LIMIT 1
""")
)
row = result.fetchone()
if not row:
return None
return {
"headline": row.headline,
"content": row.content,
"generated_at": row.generated_at,
"data_snapshot": row.data_snapshot or {},
}
async def gather_occupancy_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> List[Dict]:
"""Gather Pickup-V2 occupancy forecast data for the next N days."""
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast
@ -78,7 +101,7 @@ async def gather_occupancy_data(db: AsyncSession, days: int = 14) -> List[Dict]:
return []
async def gather_revenue_data(db: AsyncSession, days: int = 14) -> List[Dict]:
async def gather_revenue_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> List[Dict]:
"""Gather Pickup-V2 revenue forecast data for the next N days."""
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast
@ -95,7 +118,7 @@ async def gather_revenue_data(db: AsyncSession, days: int = 14) -> List[Dict]:
return []
async def gather_budget_data(db: AsyncSession, days: int = 14) -> Dict[str, float]:
async def gather_budget_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> Dict[str, float]:
"""Gather budget values for forecast comparison."""
today = date.today()
end = today + timedelta(days=days - 1)
@ -117,7 +140,7 @@ async def gather_budget_data(db: AsyncSession, days: int = 14) -> Dict[str, floa
return budgets
async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str, Any]:
async def gather_competitor_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> Dict[str, Any]:
"""Gather competitor rate data from Booking.com scraper."""
today = date.today()
end = today + timedelta(days=days - 1)
@ -151,7 +174,7 @@ async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str,
h.name as comp_name
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE h.tier IN ('primary', 'secondary')
WHERE h.tier IN ('competitor', 'market')
AND r.rate_date BETWEEN :start AND :end
AND r.availability_status = 'available'
AND r.rate_gross IS NOT NULL
@ -186,6 +209,21 @@ async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str,
}
def get_uk_bank_holidays(start: date, end: date) -> Dict[str, str]:
"""UK (England) bank holidays within the given range, keyed by YYYY-MM-DD."""
years = list(range(start.year, end.year + 1))
try:
uk_holidays = holidays.UK(subdiv='England', years=years)
except Exception as e:
logger.warning(f"Failed to load UK bank holidays: {e}")
return {}
return {
str(d): name for d, name in uk_holidays.items()
if start <= d <= end
}
def _fmt_date(d: str) -> str:
"""Convert YYYY-MM-DD to DD/MM/YYYY for UK display."""
try:
@ -197,28 +235,116 @@ def _fmt_date(d: str) -> str:
return str(d)
def build_market_movement_section(
current: Dict[str, Any],
previous: Optional[Dict[str, Any]],
threshold: float = RATE_MOVEMENT_THRESHOLD
) -> List[str]:
"""
Diff competitor/own rates against the previous insight's snapshot.
Only same-channel deltas are compared (B.com vs B.com, Rack vs Rack) so
a direct rack rate is never held up against a competitor's B.com rate.
"""
lines = ["## Market Movement Since Last Insight"]
if not previous:
lines.append("No previous insight to compare against (first run).")
lines.append("")
return lines
prev_competitors = previous.get('competitors', {}) or {}
prev_own_booking = previous.get('own_booking', {}) or {}
prev_rack = previous.get('rack', {}) or {}
cur_competitors = current.get('competitors', {}) or {}
cur_own_booking = current.get('own_booking', {}) or {}
cur_rack = current.get('rack', {}) or {}
moves = []
for d, cur in cur_competitors.items():
prev = prev_competitors.get(d)
if not prev or not cur.get('rate') or not prev.get('rate'):
continue
delta = cur['rate'] - prev['rate']
if abs(delta) >= threshold:
moves.append(
f"{_fmt_date(d)} | Cheapest competitor ({cur.get('name', '?')}, B.com): "
f"£{prev['rate']:.0f} -> £{cur['rate']:.0f} ({delta:+.0f})"
)
for d, cur in cur_own_booking.items():
prev = prev_own_booking.get(d)
if not prev or not cur.get('rate') or not prev.get('rate'):
continue
delta = cur['rate'] - prev['rate']
if abs(delta) >= threshold:
moves.append(
f"{_fmt_date(d)} | Own rate (B.com): "
f"£{prev['rate']:.0f} -> £{cur['rate']:.0f} ({delta:+.0f})"
)
for d, cur_rate in cur_rack.items():
prev_rate = prev_rack.get(d)
if not prev_rate or not cur_rate:
continue
delta = cur_rate - prev_rate
if abs(delta) >= threshold:
moves.append(
f"{_fmt_date(d)} | Own rate (Rack/Newbook): "
f"£{prev_rate:.0f} -> £{cur_rate:.0f} ({delta:+.0f})"
)
if moves:
moves.sort()
lines.extend(moves)
else:
lines.append(f"No competitor or own-rate movements of £{threshold:.0f}+ since last insight.")
lines.append("")
return lines
def build_prompt(
occupancy: List[Dict],
revenue: List[Dict],
budgets: Dict[str, float],
competitor: Dict[str, Any]
competitor: Dict[str, Any],
bank_holidays: Dict[str, str],
previous_insight: Optional[Dict[str, Any]] = None,
) -> tuple[str, str]:
"""Build system and user prompts from gathered data. Returns (system_msg, user_msg)."""
system_msg = (
"You are an AI assistant for a hotel revenue manager in the UK. Analyze the data below and provide "
"a concise daily briefing (3-5 bullet points). Focus on: occupancy trends, pace vs prior "
"year, pricing opportunities, and anything unusual requiring attention. "
"Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. "
"Be specific with numbers and dates. Keep it actionable — no fluff or generic advice."
"a daily briefing. Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. "
"Be specific with numbers and dates. Keep it actionable — no fluff or generic advice.\n\n"
"Output format: first line must be `HEADLINE: <one sentence, the single most important takeaway>`, "
"then a blank line, then 4-6 bullet points covering: occupancy/pace, pricing opportunities, "
"notable competitor rate movements (only if the Market Movement section has any), UK bank holidays "
"in the window if they affect pace, and anything unusual requiring attention.\n\n"
"A 'Previous Insight' section may be included below — compare against it explicitly: call out what's "
"changed, what's resolved, and what's still an open issue. Don't just repeat it verbatim.\n\n"
"Never compare a direct/rack rate to a competitor's Booking.com rate as if they were the same channel — "
"only compare rates within the same channel (B.com vs B.com, rack vs rack) when discussing parity or "
"pricing moves."
)
lines = []
if previous_insight:
gen_at = previous_insight.get('generated_at')
gen_at_str = gen_at.strftime('%d/%m/%Y %H:%M') if gen_at else 'unknown time'
lines.append(f"## Previous Insight ({gen_at_str})")
if previous_insight.get('headline'):
lines.append(f"Headline: {previous_insight['headline']}")
lines.append(previous_insight.get('content', ''))
lines.append("")
# Occupancy section
if occupancy:
lines.append("## Occupancy Forecast - Pickup-V2 (next 14 days)")
lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget")
lines.append(f"## Occupancy Forecast - Pickup-V2 (next {FORECAST_HORIZON_DAYS} days)")
lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget | Notes")
for fc in occupancy:
d = fc.get('date', '')
dow = fc.get('day_of_week', '')
@ -234,20 +360,50 @@ def build_prompt(
py_str = f"{py_final:.0f}%" if py_final is not None else "-"
pace_str = f"{pace:+.0f}%" if pace is not None else "-"
bud_str = f"{budget_val:.0f}%" if budget_val is not None else "-"
holiday_note = bank_holidays.get(str(d), "")
lines.append(f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str}")
lines.append(
f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str} | {holiday_note}"
)
lines.append("")
# Revenue summary
# Revenue section — per-day + aggregate
if revenue:
lines.append(f"## Revenue Forecast - Pickup-V2 (next {FORECAST_HORIZON_DAYS} days)")
lines.append("Date | DoW | OTB Rev | Forecast Rev | PY Rev | Pace vs LY | Budget Rev | Opportunity")
for fc in revenue:
d = fc.get('date', '')
dow = fc.get('day_of_week', '')
otb_rev = fc.get('current_otb_rev')
forecast_rev = fc.get('forecast')
py_rev = fc.get('prior_year_final_rev')
pace = fc.get('pace_vs_prior_pct')
budget_val = budgets.get(f"{d}_net_accom")
lost = fc.get('lost_potential') or 0
otb_str = f"£{otb_rev:,.0f}" if otb_rev is not None else "-"
fc_str = f"£{forecast_rev:,.0f}" if forecast_rev is not None else "-"
py_str = f"£{py_rev:,.0f}" if py_rev is not None else "-"
pace_str = f"{pace:+.0f}%" if pace is not None else "-"
bud_str = f"£{budget_val:,.0f}" if budget_val is not None else "-"
opp_str = f"£{lost:,.0f} left on table" if fc.get('has_pricing_opportunity') else "-"
lines.append(
f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str} | {opp_str}"
)
lines.append("")
total_forecast = sum(fc.get('forecast', 0) or 0 for fc in revenue)
total_otb = sum(fc.get('current_otb_rev', 0) or 0 for fc in revenue)
total_py = sum(fc.get('prior_year_final_rev', 0) or 0 for fc in revenue)
opportunity_days = sum(1 for fc in revenue if fc.get('has_pricing_opportunity'))
total_lost = sum(fc.get('lost_potential', 0) or 0 for fc in revenue)
lines.append("## Revenue Signals")
lines.append(f"14-day forecast: £{total_forecast:,.0f} | OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}")
lines.append("## Revenue Signals (aggregate)")
lines.append(
f"{FORECAST_HORIZON_DAYS}-day forecast: £{total_forecast:,.0f} | "
f"OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}"
)
if opportunity_days > 0:
lines.append(f"Pricing opportunity days: {opportunity_days} | Total lost potential: £{total_lost:,.0f}")
lines.append("")
@ -258,8 +414,8 @@ def build_prompt(
rack_rates = competitor.get('rack', {})
if own_booking or comp_rates:
lines.append("## Competitor Rates (next 14 days)")
lines.append("Date | Own Rack | Own B.com | Cheapest Competitor | Competitor Name")
lines.append(f"## Competitor Rates (next {FORECAST_HORIZON_DAYS} days)")
lines.append("Date | Own Rack | Own B.com | Cheapest Competitor (B.com) | Competitor Name")
all_dates = sorted(set(list(own_booking.keys()) + list(comp_rates.keys()) + list(rack_rates.keys())))
for d in all_dates:
@ -279,6 +435,9 @@ def build_prompt(
lines.append(f"{_fmt_date(d)} | {rack_str} | {own_str} | {comp_str} | {comp_name}{note}")
lines.append("")
# Market movement since last insight (same-channel deltas only)
lines.extend(build_market_movement_section(competitor, (previous_insight or {}).get('data_snapshot', {}).get('competitor_rates')))
user_msg = "\n".join(lines)
return system_msg, user_msg
@ -309,9 +468,20 @@ async def call_llm(api_key: str, system_msg: str, user_msg: str, model: str) ->
await client.close()
def parse_headline(raw_content: str) -> tuple[Optional[str], str]:
"""Split a `HEADLINE: ...` first line off the model's response. Returns (headline, remaining_content)."""
lines = raw_content.split("\n")
if lines and lines[0].strip().upper().startswith("HEADLINE:"):
headline = lines[0].split(":", 1)[1].strip()
remaining = "\n".join(lines[1:]).strip()
return headline, remaining
return None, raw_content
async def save_insight(
db: AsyncSession,
content: str,
headline: Optional[str],
model: str,
input_tokens: int,
output_tokens: int,
@ -322,12 +492,13 @@ async def save_insight(
await db.execute(
text("""
INSERT INTO ai_insights
(content, model, input_tokens, output_tokens, data_snapshot, triggered_by)
VALUES (:content, :model, :input_tokens, :output_tokens,
(content, headline, model, input_tokens, output_tokens, data_snapshot, triggered_by)
VALUES (:content, :headline, :model, :input_tokens, :output_tokens,
CAST(:data_snapshot AS jsonb), :triggered_by)
"""),
{
"content": content,
"headline": headline,
"model": model,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
@ -377,22 +548,30 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
# Gather data
logger.info("Gathering data for AI insight...")
previous_insight = await get_previous_insight(db)
occupancy = await gather_occupancy_data(db)
revenue = await gather_revenue_data(db)
budgets = await gather_budget_data(db)
competitor = await gather_competitor_data(db)
today = date.today()
end = today + timedelta(days=FORECAST_HORIZON_DAYS - 1)
bank_holidays = get_uk_bank_holidays(today, end)
if not occupancy and not revenue:
return {"success": False, "error": "No forecast data available"}
# Build prompt
system_msg, user_msg = build_prompt(occupancy, revenue, budgets, competitor)
system_msg, user_msg = build_prompt(
occupancy, revenue, budgets, competitor, bank_holidays, previous_insight
)
# Store data snapshot for debugging
# Store data snapshot — full competitor rates so the NEXT insight can diff against it
data_snapshot = {
"occupancy_days": len(occupancy),
"revenue_days": len(revenue),
"competitor_dates": len(competitor.get('own_booking', {})),
"competitor_rates": competitor,
"prompt_preview": user_msg[:500],
}
@ -404,10 +583,13 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
logger.error(f"LLM call failed: {e}")
return {"success": False, "error": f"LLM call failed: {str(e)}"}
headline, content = parse_headline(result["content"])
# Save
await save_insight(
db,
content=result["content"],
content=content,
headline=headline,
model=result["model"],
input_tokens=result["input_tokens"],
output_tokens=result["output_tokens"],
@ -428,7 +610,8 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
return {
"success": True,
"content": result["content"],
"content": content,
"headline": headline,
"input_tokens": result["input_tokens"],
"output_tokens": result["output_tokens"],
"model": result["model"],

View file

@ -4,6 +4,8 @@ Auth is handled by the central HNF stack cookie (hnf_session).
"""
import logging
import sys
import os
import time
from contextlib import asynccontextmanager
logging.basicConfig(
@ -105,6 +107,9 @@ async def lifespan(app: FastAPI):
CREATE INDEX IF NOT EXISTS idx_ai_insights_generated
ON ai_insights(generated_at DESC)
"""))
db.execute(text("""
ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS headline TEXT
"""))
db.commit()
finally:
db.close()
@ -122,6 +127,7 @@ app = FastAPI(
version="2.0.0",
lifespan=lifespan,
)
STARTED_AT = str(int(time.time() * 1000))
app.add_middleware(
CORSMiddleware,
@ -155,4 +161,4 @@ app.include_router(ai_insights.router, prefix="/ai-insights", tags=[
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "forecasting-api"}
return {"status": "healthy", "service": "forecasting-api", "version": os.environ.get("BUILD_VERSION", STARTED_AT)}

View file

@ -22,3 +22,4 @@ python-dotenv==1.0.0
python-dateutil==2.8.2
playwright>=1.40.0
anthropic>=0.42.0
holidays>=0.47

View file

@ -0,0 +1,93 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { History, ChevronDown, ChevronRight, Clock } from 'lucide-react'
import api from '../api'
import { AIInsight, formatAge, renderContent } from '../utils/aiInsight'
interface HistoryResponse {
insights: AIInsight[]
total: number
}
export default function AIInsightHistory() {
const [expandedId, setExpandedId] = useState<number | null>(null)
const { data, isLoading } = useQuery<HistoryResponse>({
queryKey: ['ai-insights-history'],
queryFn: () => api.get('/ai-insights/history', { params: { limit: 20 } }).then(r => r.data),
})
const insights = data?.insights ?? []
return (
<div className="card" style={{ marginTop: 16 }}>
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<History size={16} strokeWidth={1.75} color="var(--gold)" />
Insight History
</span>
</div>
<div className="card-body" style={{ padding: 0 }}>
{isLoading && (
<div className="loading-state" style={{ padding: 16 }}>
<div className="spinner" />
Loading history
</div>
)}
{!isLoading && insights.length === 0 && (
<div className="empty-state" style={{ padding: 16 }}>
<p>No past insights yet.</p>
</div>
)}
{insights.map((item, i) => {
const isOpen = expandedId === item.id
return (
<div
key={item.id}
style={{
borderTop: i === 0 ? 'none' : '1px solid var(--card-border)',
}}
>
<button
onClick={() => setExpandedId(isOpen ? null : item.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '10px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
}}
>
{isOpen ? (
<ChevronDown size={14} strokeWidth={1.75} color="var(--text-mid)" style={{ flexShrink: 0 }} />
) : (
<ChevronRight size={14} strokeWidth={1.75} color="var(--text-mid)" style={{ flexShrink: 0 }} />
)}
<span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-dark)' }}>
{item.headline || 'Daily briefing'}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-mid)', flexShrink: 0 }}>
<Clock size={11} strokeWidth={1.75} />
{formatAge(item.generated_at)}
</span>
</button>
{isOpen && (
<div style={{ padding: '0 16px 16px 40px', fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-dark)' }}>
{renderContent(item.content)}
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 12 }}>
{item.input_tokens} / {item.output_tokens} tokens · {item.triggered_by}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View file

@ -2,54 +2,8 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { RefreshCw, Bot, Clock } from 'lucide-react'
import api from '../api'
interface AIInsight {
id: number
generated_at: string
content: string
model: string
input_tokens: number
output_tokens: number
triggered_by: string
}
function formatAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime()
const mins = Math.floor(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function renderContent(text: string) {
return text.split('\n').map((line, i) => {
const safe = escHtml(line)
const processed = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
if (line.startsWith('- ') || line.startsWith('* ')) {
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<span style={{ color: 'var(--gold)', flexShrink: 0 }}></span>
<span dangerouslySetInnerHTML={{ __html: processed.slice(2) }} />
</div>
)
}
if (line.startsWith('## ') || line.startsWith('# ')) {
const txt = line.replace(/^#+\s*/, '')
return <p key={i} style={{ fontWeight: 600, marginTop: 12, marginBottom: 6, color: 'var(--text-dark)' }}>{txt}</p>
}
if (line.trim() === '') return <div key={i} style={{ height: 8 }} />
return <p key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: processed }} />
})
}
import { AIInsight, formatAge, renderContent } from '../utils/aiInsight'
import AIInsightHistory from '../components/AIInsightHistory'
export default function Dashboard() {
const qc = useQueryClient()
@ -66,6 +20,7 @@ export default function Dashboard() {
onSuccess: () => {
setGenError(null)
qc.invalidateQueries({ queryKey: ['ai-insights-latest'] })
qc.invalidateQueries({ queryKey: ['ai-insights-history'] })
},
onError: (err: any) => {
setGenError(err.response?.data?.detail || 'Failed to generate insight')
@ -125,6 +80,11 @@ export default function Dashboard() {
)}
{insight && (
<>
{insight.headline && (
<p style={{ fontWeight: 700, fontSize: 15, marginBottom: 12, color: 'var(--text-dark)' }}>
{insight.headline}
</p>
)}
<div style={{ marginBottom: 16 }}>{renderContent(insight.content)}</div>
{(insight.input_tokens || insight.output_tokens) && (
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--card-border)' }}>
@ -136,6 +96,8 @@ export default function Dashboard() {
)}
</div>
</div>
<AIInsightHistory />
</div>
)
}

View file

@ -6156,6 +6156,7 @@ const AIInsightsPage: React.FC = () => {
setGenerateMessage(`Generated! ${data.input_tokens} in / ${data.output_tokens} out tokens`)
queryClient.invalidateQueries({ queryKey: ['ai-insights-usage'] })
queryClient.invalidateQueries({ queryKey: ['ai-insights-latest'] })
queryClient.invalidateQueries({ queryKey: ['ai-insights-history'] })
} else {
setGenerateStatus('error')
setGenerateMessage(data.detail || 'Generation failed')

View file

@ -0,0 +1,49 @@
export interface AIInsight {
id: number
generated_at: string
insight_type: string
headline: string | null
content: string
model: string
input_tokens: number
output_tokens: number
triggered_by: string
}
export function formatAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime()
const mins = Math.floor(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
export function renderContent(text: string) {
return text.split('\n').map((line, i) => {
const safe = escHtml(line)
const processed = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
if (line.startsWith('- ') || line.startsWith('* ')) {
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<span style={{ color: 'var(--gold)', flexShrink: 0 }}></span>
<span dangerouslySetInnerHTML={{ __html: processed.slice(2) }} />
</div>
)
}
if (line.startsWith('## ') || line.startsWith('# ')) {
const txt = line.replace(/^#+\s*/, '')
return <p key={i} style={{ fontWeight: 600, marginTop: 12, marginBottom: 6, color: 'var(--text-dark)' }}>{txt}</p>
}
if (line.trim() === '') return <div key={i} style={{ height: 8 }} />
return <p key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: processed }} />
})
}