- 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>
180 lines
5.5 KiB
Python
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")
|
|
)
|