forecasting/backend/api/ai_insights.py
jtricerolph 2a7ee1d6b8 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>
2026-07-24 18:03:31 +00:00

180 lines
5.5 KiB
Python

"""
AI Insights API Endpoints
Serves pre-computed daily insights to the dashboard and allows manual generation.
"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
# Rate limit: minimum minutes between manual generations
MANUAL_RATE_LIMIT_MINUTES = 5
@router.get("/latest")
async def get_latest_insight(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get the most recent AI insight for the dashboard card."""
result = await db.execute(
text("""
SELECT id, generated_at, insight_type, headline, content, model,
input_tokens, output_tokens, triggered_by
FROM ai_insights
ORDER BY generated_at DESC
LIMIT 1
""")
)
row = result.fetchone()
if not row:
return None
return {
"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,
"output_tokens": row.output_tokens,
"triggered_by": row.triggered_by,
}
@router.get("/history")
async def get_insight_history(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get historical AI insights with pagination."""
result = await db.execute(
text("""
SELECT id, generated_at, insight_type, headline, content, model,
input_tokens, output_tokens, triggered_by
FROM ai_insights
ORDER BY generated_at DESC
LIMIT :limit OFFSET :offset
"""),
{"limit": limit, "offset": offset}
)
count_result = await db.execute(text("SELECT COUNT(*) FROM ai_insights"))
total = count_result.scalar()
insights = []
for row in result.fetchall():
insights.append({
"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,
"output_tokens": row.output_tokens,
"triggered_by": row.triggered_by,
})
return {"insights": insights, "total": total}
@router.get("/usage")
async def get_usage_stats(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get token usage statistics for the current month."""
result = await db.execute(
text("""
SELECT
COUNT(*) as generation_count,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens
FROM ai_insights
WHERE generated_at >= DATE_TRUNC('month', CURRENT_DATE)
""")
)
row = result.fetchone()
# Also get today's usage for budget display
today_result = await db.execute(
text("""
SELECT COALESCE(SUM(input_tokens + output_tokens), 0) as today_total
FROM ai_insights
WHERE generated_at >= CURRENT_DATE
""")
)
today_row = today_result.fetchone()
return {
"month": {
"generations": row.generation_count,
"input_tokens": row.total_input_tokens,
"output_tokens": row.total_output_tokens,
"total_tokens": row.total_input_tokens + row.total_output_tokens,
},
"today_tokens": today_row.today_total if today_row else 0,
}
@router.post("/generate")
async def generate_insight_manual(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Manually trigger AI insight generation.
Rate-limited to prevent accidental spam.
"""
# Check rate limit
result = await db.execute(
text("""
SELECT generated_at FROM ai_insights
WHERE triggered_by = 'manual'
ORDER BY generated_at DESC
LIMIT 1
""")
)
last_manual = result.fetchone()
if last_manual:
elapsed = datetime.now(timezone.utc) - last_manual.generated_at.replace(tzinfo=timezone.utc)
if elapsed.total_seconds() < MANUAL_RATE_LIMIT_MINUTES * 60:
remaining = MANUAL_RATE_LIMIT_MINUTES - (elapsed.total_seconds() / 60)
raise HTTPException(
status_code=429,
detail=f"Rate limited. Please wait {remaining:.0f} more minutes."
)
# Run generation
from jobs.ai_insights import generate_insight
result = await generate_insight(db, triggered_by="manual")
if result.get("success"):
return {
"success": True,
"content": result["content"],
"headline": result.get("headline"),
"input_tokens": result["input_tokens"],
"output_tokens": result["output_tokens"],
"model": result["model"],
}
else:
raise HTTPException(
status_code=400,
detail=result.get("error", "Failed to generate insight")
)