forecasting/backend/api/ai_insights.py
jtricerolph 75d2c1fa9d Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 18:49:34 +00:00

177 lines
5.3 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, 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,
"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, 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,
"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"],
"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")
)