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

View file

@ -1223,7 +1223,7 @@ class AIInsightsSettingsResponse(BaseModel):
api_key_set: bool = False api_key_set: bool = False
model: str = "claude-haiku-4-5-20251001" model: str = "claude-haiku-4-5-20251001"
schedule_time: str = "07:15" schedule_time: str = "07:15"
daily_token_budget: int = 5000 daily_token_budget: int = 12000
class AIInsightsSettingsUpdate(BaseModel): class AIInsightsSettingsUpdate(BaseModel):
@ -1258,7 +1258,7 @@ async def get_ai_insights_settings(
api_key_set=config.get('_api_key_set', False), api_key_set=config.get('_api_key_set', False),
model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'), model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'),
schedule_time=config.get('ai_insights_schedule_time', '07:15'), 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 AI Daily Insights Generation Job
Gathers Pickup-V2 forecast data, booking pace, competitor rates, and rate parity Gathers Pickup-V2 forecast data, booking pace, competitor rates, rate parity,
information, then sends a compact prompt to Anthropic's Haiku model to generate UK bank holidays, and the previous insight, then sends a compact prompt to
a daily briefing for hotel revenue staff. 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) 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 json
import logging import logging
from datetime import date, timedelta, datetime, timezone from datetime import date, timedelta, datetime, timezone
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
import holidays
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -21,8 +21,10 @@ from database import AsyncSessionLocal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_MODEL = "claude-haiku-4-5-20251001" DEFAULT_MODEL = "claude-haiku-4-5-20251001"
DEFAULT_DAILY_TOKEN_BUDGET = 5000 DEFAULT_DAILY_TOKEN_BUDGET = 12000
MAX_OUTPUT_TOKENS = 400 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]: 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 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.""" """Gather Pickup-V2 occupancy forecast data for the next N days."""
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast 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 [] 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.""" """Gather Pickup-V2 revenue forecast data for the next N days."""
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast 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 [] 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.""" """Gather budget values for forecast comparison."""
today = date.today() today = date.today()
end = today + timedelta(days=days - 1) 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 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.""" """Gather competitor rate data from Booking.com scraper."""
today = date.today() today = date.today()
end = today + timedelta(days=days - 1) 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 h.name as comp_name
FROM booking_com_rates r FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id 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.rate_date BETWEEN :start AND :end
AND r.availability_status = 'available' AND r.availability_status = 'available'
AND r.rate_gross IS NOT NULL 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: def _fmt_date(d: str) -> str:
"""Convert YYYY-MM-DD to DD/MM/YYYY for UK display.""" """Convert YYYY-MM-DD to DD/MM/YYYY for UK display."""
try: try:
@ -197,28 +235,116 @@ def _fmt_date(d: str) -> str:
return str(d) 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( def build_prompt(
occupancy: List[Dict], occupancy: List[Dict],
revenue: List[Dict], revenue: List[Dict],
budgets: Dict[str, float], 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]: ) -> tuple[str, str]:
"""Build system and user prompts from gathered data. Returns (system_msg, user_msg).""" """Build system and user prompts from gathered data. Returns (system_msg, user_msg)."""
system_msg = ( system_msg = (
"You are an AI assistant for a hotel revenue manager in the UK. Analyze the data below and provide " "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 " "a daily briefing. Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. "
"year, pricing opportunities, and anything unusual requiring attention. " "Be specific with numbers and dates. Keep it actionable — no fluff or generic advice.\n\n"
"Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. " "Output format: first line must be `HEADLINE: <one sentence, the single most important takeaway>`, "
"Be specific with numbers and dates. Keep it actionable — no fluff or generic advice." "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 = [] 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 # Occupancy section
if occupancy: if occupancy:
lines.append("## Occupancy Forecast - Pickup-V2 (next 14 days)") lines.append(f"## Occupancy Forecast - Pickup-V2 (next {FORECAST_HORIZON_DAYS} days)")
lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget") lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget | Notes")
for fc in occupancy: for fc in occupancy:
d = fc.get('date', '') d = fc.get('date', '')
dow = fc.get('day_of_week', '') 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 "-" py_str = f"{py_final:.0f}%" if py_final is not None else "-"
pace_str = f"{pace:+.0f}%" if pace 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 "-" 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("") lines.append("")
# Revenue summary # Revenue section — per-day + aggregate
if revenue: 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_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_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) 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')) 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) total_lost = sum(fc.get('lost_potential', 0) or 0 for fc in revenue)
lines.append("## Revenue Signals") lines.append("## Revenue Signals (aggregate)")
lines.append(f"14-day forecast: £{total_forecast:,.0f} | OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}") lines.append(
f"{FORECAST_HORIZON_DAYS}-day forecast: £{total_forecast:,.0f} | "
f"OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}"
)
if opportunity_days > 0: if opportunity_days > 0:
lines.append(f"Pricing opportunity days: {opportunity_days} | Total lost potential: £{total_lost:,.0f}") lines.append(f"Pricing opportunity days: {opportunity_days} | Total lost potential: £{total_lost:,.0f}")
lines.append("") lines.append("")
@ -258,8 +414,8 @@ def build_prompt(
rack_rates = competitor.get('rack', {}) rack_rates = competitor.get('rack', {})
if own_booking or comp_rates: if own_booking or comp_rates:
lines.append("## Competitor Rates (next 14 days)") lines.append(f"## Competitor Rates (next {FORECAST_HORIZON_DAYS} days)")
lines.append("Date | Own Rack | Own B.com | Cheapest Competitor | Competitor Name") 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()))) all_dates = sorted(set(list(own_booking.keys()) + list(comp_rates.keys()) + list(rack_rates.keys())))
for d in all_dates: 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(f"{_fmt_date(d)} | {rack_str} | {own_str} | {comp_str} | {comp_name}{note}")
lines.append("") 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) user_msg = "\n".join(lines)
return system_msg, user_msg 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() 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( async def save_insight(
db: AsyncSession, db: AsyncSession,
content: str, content: str,
headline: Optional[str],
model: str, model: str,
input_tokens: int, input_tokens: int,
output_tokens: int, output_tokens: int,
@ -322,12 +492,13 @@ async def save_insight(
await db.execute( await db.execute(
text(""" text("""
INSERT INTO ai_insights INSERT INTO ai_insights
(content, model, input_tokens, output_tokens, data_snapshot, triggered_by) (content, headline, model, input_tokens, output_tokens, data_snapshot, triggered_by)
VALUES (:content, :model, :input_tokens, :output_tokens, VALUES (:content, :headline, :model, :input_tokens, :output_tokens,
CAST(:data_snapshot AS jsonb), :triggered_by) CAST(:data_snapshot AS jsonb), :triggered_by)
"""), """),
{ {
"content": content, "content": content,
"headline": headline,
"model": model, "model": model,
"input_tokens": input_tokens, "input_tokens": input_tokens,
"output_tokens": output_tokens, "output_tokens": output_tokens,
@ -377,22 +548,30 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
# Gather data # Gather data
logger.info("Gathering data for AI insight...") logger.info("Gathering data for AI insight...")
previous_insight = await get_previous_insight(db)
occupancy = await gather_occupancy_data(db) occupancy = await gather_occupancy_data(db)
revenue = await gather_revenue_data(db) revenue = await gather_revenue_data(db)
budgets = await gather_budget_data(db) budgets = await gather_budget_data(db)
competitor = await gather_competitor_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: if not occupancy and not revenue:
return {"success": False, "error": "No forecast data available"} return {"success": False, "error": "No forecast data available"}
# Build prompt # 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 = { data_snapshot = {
"occupancy_days": len(occupancy), "occupancy_days": len(occupancy),
"revenue_days": len(revenue), "revenue_days": len(revenue),
"competitor_dates": len(competitor.get('own_booking', {})), "competitor_dates": len(competitor.get('own_booking', {})),
"competitor_rates": competitor,
"prompt_preview": user_msg[:500], "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}") logger.error(f"LLM call failed: {e}")
return {"success": False, "error": f"LLM call failed: {str(e)}"} return {"success": False, "error": f"LLM call failed: {str(e)}"}
headline, content = parse_headline(result["content"])
# Save # Save
await save_insight( await save_insight(
db, db,
content=result["content"], content=content,
headline=headline,
model=result["model"], model=result["model"],
input_tokens=result["input_tokens"], input_tokens=result["input_tokens"],
output_tokens=result["output_tokens"], output_tokens=result["output_tokens"],
@ -428,7 +610,8 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
return { return {
"success": True, "success": True,
"content": result["content"], "content": content,
"headline": headline,
"input_tokens": result["input_tokens"], "input_tokens": result["input_tokens"],
"output_tokens": result["output_tokens"], "output_tokens": result["output_tokens"],
"model": result["model"], "model": result["model"],

View file

@ -4,6 +4,8 @@ Auth is handled by the central HNF stack cookie (hnf_session).
""" """
import logging import logging
import sys import sys
import os
import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
logging.basicConfig( logging.basicConfig(
@ -105,6 +107,9 @@ async def lifespan(app: FastAPI):
CREATE INDEX IF NOT EXISTS idx_ai_insights_generated CREATE INDEX IF NOT EXISTS idx_ai_insights_generated
ON ai_insights(generated_at DESC) ON ai_insights(generated_at DESC)
""")) """))
db.execute(text("""
ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS headline TEXT
"""))
db.commit() db.commit()
finally: finally:
db.close() db.close()
@ -122,6 +127,7 @@ app = FastAPI(
version="2.0.0", version="2.0.0",
lifespan=lifespan, lifespan=lifespan,
) )
STARTED_AT = str(int(time.time() * 1000))
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -155,4 +161,4 @@ app.include_router(ai_insights.router, prefix="/ai-insights", tags=[
@app.get("/health") @app.get("/health")
async def health_check(): 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 python-dateutil==2.8.2
playwright>=1.40.0 playwright>=1.40.0
anthropic>=0.42.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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { RefreshCw, Bot, Clock } from 'lucide-react' import { RefreshCw, Bot, Clock } from 'lucide-react'
import api from '../api' import api from '../api'
import { AIInsight, formatAge, renderContent } from '../utils/aiInsight'
interface AIInsight { import AIInsightHistory from '../components/AIInsightHistory'
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 }} />
})
}
export default function Dashboard() { export default function Dashboard() {
const qc = useQueryClient() const qc = useQueryClient()
@ -66,6 +20,7 @@ export default function Dashboard() {
onSuccess: () => { onSuccess: () => {
setGenError(null) setGenError(null)
qc.invalidateQueries({ queryKey: ['ai-insights-latest'] }) qc.invalidateQueries({ queryKey: ['ai-insights-latest'] })
qc.invalidateQueries({ queryKey: ['ai-insights-history'] })
}, },
onError: (err: any) => { onError: (err: any) => {
setGenError(err.response?.data?.detail || 'Failed to generate insight') setGenError(err.response?.data?.detail || 'Failed to generate insight')
@ -125,6 +80,11 @@ export default function Dashboard() {
)} )}
{insight && ( {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> <div style={{ marginBottom: 16 }}>{renderContent(insight.content)}</div>
{(insight.input_tokens || insight.output_tokens) && ( {(insight.input_tokens || insight.output_tokens) && (
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--card-border)' }}> <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>
</div> </div>
<AIInsightHistory />
</div> </div>
) )
} }

View file

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