diff --git a/backend/api/ai_insights.py b/backend/api/ai_insights.py index 9db2bc6..9a532a8 100644 --- a/backend/api/ai_insights.py +++ b/backend/api/ai_insights.py @@ -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"], diff --git a/backend/api/config.py b/backend/api/config.py index 4dccd78..df9bd09 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -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')), ) diff --git a/backend/jobs/ai_insights.py b/backend/jobs/ai_insights.py index 5d1b2b7..6b4b8d4 100644 --- a/backend/jobs/ai_insights.py +++ b/backend/jobs/ai_insights.py @@ -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: `, " + "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"], diff --git a/backend/main.py b/backend/main.py index 0227698..604208b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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)} diff --git a/backend/requirements.txt b/backend/requirements.txt index e6a4a77..da34be5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/components/AIInsightHistory.tsx b/frontend/src/components/AIInsightHistory.tsx new file mode 100644 index 0000000..fd07f41 --- /dev/null +++ b/frontend/src/components/AIInsightHistory.tsx @@ -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(null) + + const { data, isLoading } = useQuery({ + queryKey: ['ai-insights-history'], + queryFn: () => api.get('/ai-insights/history', { params: { limit: 20 } }).then(r => r.data), + }) + + const insights = data?.insights ?? [] + + return ( +
+
+ + + Insight History + +
+
+ {isLoading && ( +
+
+ Loading history… +
+ )} + {!isLoading && insights.length === 0 && ( +
+

No past insights yet.

+
+ )} + {insights.map((item, i) => { + const isOpen = expandedId === item.id + return ( +
+ + {isOpen && ( +
+ {renderContent(item.content)} +
+ {item.input_tokens}↑ / {item.output_tokens}↓ tokens · {item.triggered_by} +
+
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3bcc934..f9e3dfe 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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, '&') - .replace(//g, '>') -} - -function renderContent(text: string) { - return text.split('\n').map((line, i) => { - const safe = escHtml(line) - const processed = safe.replace(/\*\*(.+?)\*\*/g, '$1') - if (line.startsWith('- ') || line.startsWith('* ')) { - return ( -
- - -
- ) - } - if (line.startsWith('## ') || line.startsWith('# ')) { - const txt = line.replace(/^#+\s*/, '') - return

{txt}

- } - if (line.trim() === '') return
- return

- }) -} +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 && ( +

+ {insight.headline} +

+ )}
{renderContent(insight.content)}
{(insight.input_tokens || insight.output_tokens) && (
@@ -136,6 +96,8 @@ export default function Dashboard() { )}
+ +
) } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 06d261f..ffc1cfb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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') diff --git a/frontend/src/utils/aiInsight.tsx b/frontend/src/utils/aiInsight.tsx new file mode 100644 index 0000000..4cc6e3f --- /dev/null +++ b/frontend/src/utils/aiInsight.tsx @@ -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, '&') + .replace(//g, '>') +} + +export function renderContent(text: string) { + return text.split('\n').map((line, i) => { + const safe = escHtml(line) + const processed = safe.replace(/\*\*(.+?)\*\*/g, '$1') + if (line.startsWith('- ') || line.startsWith('* ')) { + return ( +
+ + +
+ ) + } + if (line.startsWith('## ') || line.startsWith('# ')) { + const txt = line.replace(/^#+\s*/, '') + return

{txt}

+ } + if (line.trim() === '') return
+ return

+ }) +}