From 955870a53125ade0bbc9952c05e87833ee5778f1 Mon Sep 17 00:00:00 2001 From: Deek Roumy Date: Tue, 24 Mar 2026 10:33:17 -0700 Subject: [PATCH] feat(digest): implement weekly financial digest (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a weekly financial digest feature that generates summaries highlighting trends and insights for any given week. Backend changes: - New service: packages/backend/app/services/digest.py - _heuristic_digest: rules-based weekly summary (no API key required) - _gemini_digest: AI-enhanced insights using Gemini API - weekly_digest(): public API with graceful fallback - New route: GET /insights/weekly-digest - Optional ?week=YYYY-MM-DD query param (defaults to current week) - X-Gemini-Api-Key / X-Insight-Persona headers supported - Returns: week_start, week_end, total_expenses, total_income, net_flow, wow_change_pct, top_categories, daily_breakdown, insights - OpenAPI: fully documented new endpoint with schema and examples - Tests: packages/backend/tests/test_digest.py (6 test cases) Frontend changes: - New page: app/src/pages/WeeklyDigest.tsx - Summary cards: expenses, income, net flow, previous week - Top spending categories table - Day-by-day breakdown list - Insights panel (AI-powered or heuristic) - Week picker + optional Gemini API key input - New API type: WeeklyDigest + getWeeklyDigest() in api/insights.ts - New route: /weekly-digest (protected) - Navbar: 'Weekly Digest' link added Acceptance criteria: ✅ Production-ready implementation with error handling + fallbacks ✅ Tests included (6 backend test cases) ✅ Documentation updated (OpenAPI spec + inline docstrings) --- app/src/App.tsx | 9 + app/src/api/insights.ts | 27 +++ app/src/components/layout/Navbar.tsx | 1 + app/src/pages/WeeklyDigest.tsx | 258 +++++++++++++++++++++++ packages/backend/app/openapi.yaml | 89 ++++++++ packages/backend/app/routes/insights.py | 35 ++++ packages/backend/app/services/digest.py | 262 ++++++++++++++++++++++++ packages/backend/tests/test_digest.py | 151 ++++++++++++++ 8 files changed, 832 insertions(+) create mode 100644 app/src/pages/WeeklyDigest.tsx create mode 100644 packages/backend/app/services/digest.py create mode 100644 packages/backend/tests/test_digest.py diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..340e93b2 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import { WeeklyDigest } from "./pages/WeeklyDigest"; const queryClient = new QueryClient({ defaultOptions: { @@ -91,6 +92,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/api/insights.ts b/app/src/api/insights.ts index 031d1e53..1791c109 100644 --- a/app/src/api/insights.ts +++ b/app/src/api/insights.ts @@ -32,3 +32,30 @@ export async function getBudgetSuggestion(params?: { if (params?.persona) headers['X-Insight-Persona'] = params.persona; return api(`/insights/budget-suggestion${monthQuery}`, { headers }); } + +export type WeeklyDigest = { + week_start: string; + week_end: string; + total_expenses: number; + total_income: number; + net_flow: number; + week_over_week_change_pct: number; + previous_week_expenses: number; + top_categories: Array<{ category: string; amount: number }>; + daily_breakdown: Array<{ date: string; amount: number }>; + insights: string[]; + method: 'gemini' | 'heuristic' | string; + warnings?: string[]; +}; + +export async function getWeeklyDigest(params?: { + week?: string; + geminiApiKey?: string; + persona?: string; +}): Promise { + const weekQuery = params?.week ? `?week=${encodeURIComponent(params.week)}` : ''; + const headers: Record = {}; + if (params?.geminiApiKey) headers['X-Gemini-Api-Key'] = params.geminiApiKey; + if (params?.persona) headers['X-Insight-Persona'] = params.persona; + return api(`/insights/weekly-digest${weekQuery}`, { headers }); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..b2a227e4 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -13,6 +13,7 @@ const navigation = [ { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Analytics', href: '/analytics' }, + { name: 'Weekly Digest', href: '/weekly-digest' }, ]; export function Navbar() { diff --git a/app/src/pages/WeeklyDigest.tsx b/app/src/pages/WeeklyDigest.tsx new file mode 100644 index 00000000..30823f35 --- /dev/null +++ b/app/src/pages/WeeklyDigest.tsx @@ -0,0 +1,258 @@ +import { useEffect, useState } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + ArrowDownRight, + ArrowUpRight, + TrendingDown, + TrendingUp, + Lightbulb, + BarChart2, + Calendar, +} from 'lucide-react'; +import { getWeeklyDigest, type WeeklyDigest } from '@/api/insights'; +import { formatMoney } from '@/lib/currency'; +import { useToast } from '@/hooks/use-toast'; + +function getMondayOfWeek(d: Date): string { + const day = d.getDay(); + const diff = (day === 0 ? -6 : 1) - day; + const monday = new Date(d); + monday.setDate(d.getDate() + diff); + return monday.toISOString().slice(0, 10); +} + +export function WeeklyDigest() { + const { toast } = useToast(); + const [week, setWeek] = useState(() => getMondayOfWeek(new Date())); + const [geminiKey, setGeminiKey] = useState(''); + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + async function load() { + setLoading(true); + setError(null); + try { + const payload = await getWeeklyDigest({ + week, + geminiApiKey: geminiKey.trim() || undefined, + }); + setData(payload); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load digest'; + setError(message); + toast({ title: 'Failed to load weekly digest', description: message }); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + const wowPositive = data ? data.week_over_week_change_pct > 0 : false; + + return ( +
+
+

Weekly Digest

+

+ Trends & insights for your financial week at a glance. +

+
+ + {/* Controls */} + + + + + Select Week + + + +
+ + setWeek(e.target.value)} + className="w-44" + /> +
+
+ + setGeminiKey(e.target.value)} + /> +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {data && ( + <> + {/* Summary cards */} +
+ + + Total Expenses + + {formatMoney(data.total_expenses)} + + + + {wowPositive ? ( + + ) : ( + + )} + + {Math.abs(data.week_over_week_change_pct)}% vs last week + + + + + + + Total Income + + {formatMoney(data.total_income)} + + + + + + + Net Flow + = 0 ? 'text-green-500' : 'text-destructive'}`} + > + {formatMoney(data.net_flow)} + + + + {data.net_flow >= 0 ? ( + + ) : ( + + )} + {data.net_flow >= 0 ? 'In the green' : 'Over budget'} + + + + + + Previous Week + + {formatMoney(data.previous_week_expenses)} + + + +
+ + {/* Top categories */} + {data.top_categories.length > 0 && ( + + + + + Top Spending Categories + + + {data.week_start} – {data.week_end} + + + +
    + {data.top_categories.map((cat) => ( +
  • + {cat.category} + {formatMoney(cat.amount)} +
  • + ))} +
+
+
+ )} + + {/* Daily breakdown */} + {data.daily_breakdown.length > 0 && ( + + + Day-by-Day Spending + + +
+ {data.daily_breakdown.map((day) => ( +
+ + {new Date(day.date + 'T12:00:00').toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + })} + + {formatMoney(day.amount)} +
+ ))} +
+
+
+ )} + + {/* AI / Heuristic insights */} + {data.insights.length > 0 && ( + + + + + Insights + {data.method === 'gemini' && ( + + AI-powered + + )} + + + +
    + {data.insights.map((tip, i) => ( +
  • {tip}
  • + ))} +
+ {data.warnings?.includes('gemini_unavailable') && ( +

+ ⚠ AI insights unavailable — showing rule-based insights. +

+ )} +
+
+ )} + + )} +
+ ); +} diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..7cd71033 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -480,6 +480,95 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } + /insights/weekly-digest: + get: + summary: Get weekly financial digest + description: | + Returns a weekly financial summary including total income, total expenses, + net flow, week-over-week change, top spending categories, a day-by-day + breakdown, and actionable insights (heuristic or AI-powered via Gemini). + tags: [Insights] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: week + required: false + description: Any ISO date (YYYY-MM-DD) within the desired week. Defaults to the current week. + schema: { type: string, format: date } + - in: header + name: X-Gemini-Api-Key + required: false + description: Optional user-supplied Gemini API key for AI-enhanced insights. + schema: { type: string } + - in: header + name: X-Insight-Persona + required: false + description: Custom system persona for the AI model. + schema: { type: string } + responses: + '200': + description: Weekly digest + content: + application/json: + schema: + type: object + properties: + week_start: { type: string, format: date } + week_end: { type: string, format: date } + total_expenses: { type: number } + total_income: { type: number } + net_flow: { type: number } + week_over_week_change_pct: { type: number } + previous_week_expenses: { type: number } + top_categories: + type: array + items: + type: object + properties: + category: { type: string } + amount: { type: number } + daily_breakdown: + type: array + items: + type: object + properties: + date: { type: string, format: date } + amount: { type: number } + insights: + type: array + items: { type: string } + method: { type: string, enum: [heuristic, gemini] } + warnings: + type: array + items: { type: string } + example: + week_start: "2025-08-11" + week_end: "2025-08-17" + total_expenses: 340.50 + total_income: 500.00 + net_flow: 159.50 + week_over_week_change_pct: -12.3 + previous_week_expenses: 388.00 + top_categories: + - { category: "Groceries", amount: 120.00 } + - { category: "Transport", amount: 80.50 } + daily_breakdown: + - { date: "2025-08-11", amount: 45.00 } + - { date: "2025-08-12", amount: 110.50 } + insights: + - "Great job! You spent 12.3% less than last week. Keep it up." + - "Your top spend category this week: Groceries (120.0)." + method: heuristic + '400': + description: Invalid week format + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } components: securitySchemes: diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py index bfc02e43..5e343deb 100644 --- a/packages/backend/app/routes/insights.py +++ b/packages/backend/app/routes/insights.py @@ -23,3 +23,38 @@ def budget_suggestion(): ) logger.info("Budget suggestion served user=%s month=%s", uid, ym) return jsonify(suggestion) + + +from datetime import date +from ..services.digest import weekly_digest as _weekly_digest + + +@bp.get("/weekly-digest") +@jwt_required() +def weekly_digest(): + """Return a weekly financial digest. + + Query params: + week (optional): ISO date (YYYY-MM-DD) of any day in the desired week. + Defaults to the current week. + """ + uid = int(get_jwt_identity()) + week_param = request.args.get("week") + week_start = None + if week_param: + try: + week_start = date.fromisoformat(week_param) + except ValueError: + return jsonify({"error": "Invalid week format. Use YYYY-MM-DD."}), 400 + + user_gemini_key = (request.headers.get("X-Gemini-Api-Key") or "").strip() or None + persona = (request.headers.get("X-Insight-Persona") or "").strip() or None + + result = _weekly_digest( + uid, + week_start=week_start, + gemini_api_key=user_gemini_key, + persona=persona, + ) + logger.info("Weekly digest served user=%s week_start=%s", uid, result.get("week_start")) + return jsonify(result) diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py new file mode 100644 index 00000000..facf77cb --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,262 @@ +"""Weekly financial digest service. + +Generates a weekly summary of income, expenses, top spending categories, +day-over-day trends, and actionable insights. +""" +from __future__ import annotations + +import json +from datetime import date, timedelta +from urllib import request + +from sqlalchemy import func + +from ..config import Settings +from ..extensions import db +from ..models import Category, Expense + +_settings = Settings() + +_DEFAULT_PERSONA = ( + "You are FinMind's pragmatic financial coach. Be concise, non-judgmental, " + "data-driven, and action-oriented." +) + + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def _week_bounds(week_start: date) -> tuple[date, date]: + """Return (start, end) for the ISO week containing *week_start*. + + The caller may pass any date; we normalise to Monday of that week. + """ + monday = week_start - timedelta(days=week_start.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +def _expenses_in_range(uid: int, start: date, end: date) -> list[Expense]: + return ( + db.session.query(Expense) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .all() + ) + + +def _income_in_range(uid: int, start: date, end: date) -> float: + val = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + return float(val) + + +def _category_name(cat_id: int | None) -> str: + if cat_id is None: + return "Uncategorised" + cat = db.session.get(Category, cat_id) + return cat.name if cat else "Unknown" + + +def _daily_breakdown(expenses: list[Expense]) -> list[dict]: + by_day: dict[str, float] = {} + for e in expenses: + key = str(e.spent_at) + by_day[key] = by_day.get(key, 0.0) + float(e.amount) + return [{"date": d, "amount": round(v, 2)} for d, v in sorted(by_day.items())] + + +def _top_categories(expenses: list[Expense], n: int = 5) -> list[dict]: + totals: dict[str | None, float] = {} + for e in expenses: + totals[e.category_id] = totals.get(e.category_id, 0.0) + float(e.amount) + ranked = sorted(totals.items(), key=lambda x: x[1], reverse=True)[:n] + return [ + {"category": _category_name(cid), "amount": round(amt, 2)} + for cid, amt in ranked + ] + + +# --------------------------------------------------------------------------- +# Heuristic digest (no AI required) +# --------------------------------------------------------------------------- + + +def _heuristic_digest( + uid: int, start: date, end: date, prev_start: date, prev_end: date +) -> dict: + expenses = _expenses_in_range(uid, start, end) + prev_expenses = _expenses_in_range(uid, prev_start, prev_end) + total = round(sum(float(e.amount) for e in expenses), 2) + prev_total = round(sum(float(e.amount) for e in prev_expenses), 2) + income = round(_income_in_range(uid, start, end), 2) + + wow_pct = ( + round(((total - prev_total) / prev_total) * 100, 1) + if prev_total > 0 + else 0.0 + ) + + # Simple insight rules + tips: list[str] = [] + if total > prev_total * 1.1: + tips.append( + f"Spending rose {wow_pct}% vs the previous week — look for one-off items to cut." + ) + elif total < prev_total * 0.9: + tips.append( + f"Great job! You spent {abs(wow_pct)}% less than last week. Keep it up." + ) + else: + tips.append("Your spending is stable week-over-week — a great baseline.") + + top = _top_categories(expenses) + if top: + tips.append( + f"Your top spend category this week: {top[0]['category']} " + f"({top[0]['amount']})." + ) + + if income > 0 and total > income: + tips.append( + "Heads up: you spent more than you earned this week. Consider reviewing discretionary items." + ) + + return { + "week_start": str(start), + "week_end": str(end), + "total_expenses": total, + "total_income": income, + "net_flow": round(income - total, 2), + "week_over_week_change_pct": wow_pct, + "previous_week_expenses": prev_total, + "top_categories": top, + "daily_breakdown": _daily_breakdown(expenses), + "insights": tips, + "method": "heuristic", + } + + +# --------------------------------------------------------------------------- +# Gemini-enhanced digest +# --------------------------------------------------------------------------- + + +def _gemini_digest( + uid: int, + start: date, + end: date, + prev_start: date, + prev_end: date, + api_key: str, + model: str, + persona: str, +) -> dict: + base = _heuristic_digest(uid, start, end, prev_start, prev_end) + + prompt = ( + f"{persona}\n" + "Given this weekly financial data, return a JSON object with a single key " + '"insights" whose value is a list of 3-5 concise, actionable strings. ' + "Do not return anything else.\n" + f"week_start={start}\n" + f"total_expenses={base['total_expenses']}\n" + f"total_income={base['total_income']}\n" + f"week_over_week_change_pct={base['week_over_week_change_pct']}\n" + f"top_categories={base['top_categories']}\n" + ) + + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{model}:generateContent?key={api_key}" + ) + body = json.dumps( + { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.3}, + } + ).encode() + req = request.Request(url=url, data=body, headers={"Content-Type": "application/json"}, method="POST") + + with request.urlopen(req, timeout=10) as resp: # nosec B310 + raw = json.loads(resp.read().decode()) + + text = ( + raw.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + .strip() + ) + # Strip markdown fences if present + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].strip() + + start_idx = text.find("{") + end_idx = text.rfind("}") + if start_idx != -1 and end_idx > start_idx: + parsed = json.loads(text[start_idx : end_idx + 1]) + if isinstance(parsed.get("insights"), list): + base["insights"] = parsed["insights"] + + base["method"] = "gemini" + return base + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def weekly_digest( + uid: int, + week_start: date | None = None, + gemini_api_key: str | None = None, + gemini_model: str | None = None, + persona: str | None = None, +) -> dict: + """Return a weekly financial digest for user *uid*. + + Args: + uid: User ID. + week_start: Any date within the desired week (defaults to current week). + gemini_api_key: Optional Gemini API key for AI-enhanced insights. + gemini_model: Override Gemini model name. + persona: System persona for the AI. + + Returns: + Dictionary with weekly summary data. + """ + ref = week_start or date.today() + start, end = _week_bounds(ref) + prev_start, prev_end = _week_bounds(start - timedelta(days=7)) + + key = (gemini_api_key or "").strip() or (_settings.gemini_api_key or "") + model = gemini_model or _settings.gemini_model + persona_text = (persona or _DEFAULT_PERSONA).strip() + + if key: + try: + return _gemini_digest(uid, start, end, prev_start, prev_end, key, model, persona_text) + except Exception: + result = _heuristic_digest(uid, start, end, prev_start, prev_end) + result["warnings"] = ["gemini_unavailable"] + return result + + return _heuristic_digest(uid, start, end, prev_start, prev_end) diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 00000000..cd826cb9 --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,151 @@ +"""Tests for the weekly financial digest endpoint.""" +from datetime import date, timedelta + + +def _monday_of(d: date) -> date: + return d - timedelta(days=d.weekday()) + + +def test_weekly_digest_returns_required_fields(client, auth_header): + """GET /insights/weekly-digest returns all required response keys.""" + today = date.today() + monday = _monday_of(today) + + # Add an expense in the current week + r = client.post( + "/expenses", + json={ + "amount": 120, + "description": "Groceries", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/insights/weekly-digest", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + required_keys = { + "week_start", + "week_end", + "total_expenses", + "total_income", + "net_flow", + "week_over_week_change_pct", + "previous_week_expenses", + "top_categories", + "daily_breakdown", + "insights", + "method", + } + assert required_keys.issubset(payload.keys()), ( + f"Missing keys: {required_keys - payload.keys()}" + ) + assert payload["method"] == "heuristic" + assert payload["total_expenses"] >= 120 + + +def test_weekly_digest_week_param(client, auth_header): + """Passing ?week= selects a specific week.""" + target_monday = _monday_of(date.today()) - timedelta(weeks=2) + + r = client.post( + "/expenses", + json={ + "amount": 50, + "description": "Old week spend", + "date": target_monday.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get( + f"/insights/weekly-digest?week={target_monday.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + payload = r.get_json() + assert payload["week_start"] == str(target_monday) + assert payload["total_expenses"] >= 50 + + +def test_weekly_digest_invalid_week_param(client, auth_header): + """Invalid ?week= returns 400.""" + r = client.get("/insights/weekly-digest?week=not-a-date", headers=auth_header) + assert r.status_code == 400 + + +def test_weekly_digest_net_flow(client, auth_header): + """net_flow is income - expenses.""" + today = date.today() + monday = _monday_of(today) + + client.post( + "/expenses", + json={"amount": 200, "description": "Salary", "date": monday.isoformat(), "expense_type": "INCOME"}, + headers=auth_header, + ) + client.post( + "/expenses", + json={"amount": 80, "description": "Lunch", "date": monday.isoformat(), "expense_type": "EXPENSE"}, + headers=auth_header, + ) + + r = client.get("/insights/weekly-digest", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert abs(payload["net_flow"] - (payload["total_income"] - payload["total_expenses"])) < 0.01 + + +def test_weekly_digest_uses_gemini_key(client, auth_header, monkeypatch): + """When X-Gemini-Api-Key is supplied, _gemini_digest is called.""" + captured = {} + + def _fake(uid, start, end, prev_start, prev_end, api_key, model, persona): + captured["api_key"] = api_key + return { + "week_start": str(start), + "week_end": str(end), + "total_expenses": 0.0, + "total_income": 0.0, + "net_flow": 0.0, + "week_over_week_change_pct": 0.0, + "previous_week_expenses": 0.0, + "top_categories": [], + "daily_breakdown": [], + "insights": ["AI tip"], + "method": "gemini", + } + + monkeypatch.setattr("app.services.digest._gemini_digest", _fake) + + r = client.get( + "/insights/weekly-digest", + headers={**auth_header, "X-Gemini-Api-Key": "test-key"}, + ) + assert r.status_code == 200 + assert r.get_json()["method"] == "gemini" + assert captured["api_key"] == "test-key" + + +def test_weekly_digest_falls_back_on_gemini_error(client, auth_header, monkeypatch): + """When Gemini fails, falls back to heuristic with warning.""" + def _boom(*_args, **_kwargs): + raise RuntimeError("network error") + + monkeypatch.setattr("app.services.digest._gemini_digest", _boom) + + r = client.get( + "/insights/weekly-digest", + headers={**auth_header, "X-Gemini-Api-Key": "bad-key"}, + ) + assert r.status_code == 200 + payload = r.get_json() + assert payload["method"] == "heuristic" + assert "warnings" in payload + assert "gemini_unavailable" in payload["warnings"]