From eb8b6fba184f2ecc250ded08f6e6ba77f94aba08 Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 15:15:32 +0100 Subject: [PATCH 1/5] feat: add weekly financial digest with AI-powered summaries Implements smart weekly digest generation (#121): - WeeklyDigest model with unique constraint per user/week - Digest service with heuristic and Gemini AI summary generation - Week-over-week spending comparison and trend detection - REST endpoints: GET /digest/latest, GET /digest/history, POST /digest/generate - Comprehensive test suite (5 unit tests + 6 integration tests) - Database schema migration for weekly_digests table The digest analyzes expenses by category, computes week-over-week changes, and generates actionable tips. Falls back to heuristic when AI is unavailable. Closes #121 --- packages/backend/app/db/schema.sql | 14 ++ packages/backend/app/models.py | 18 ++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/digest.py | 66 +++++++ packages/backend/app/services/digest.py | 248 ++++++++++++++++++++++++ packages/backend/tests/test_digest.py | 169 ++++++++++++++++ 6 files changed, 517 insertions(+) create mode 100644 packages/backend/app/routes/digest.py create mode 100644 packages/backend/app/services/digest.py create mode 100644 packages/backend/tests/test_digest.py diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..67df9765 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -117,6 +117,20 @@ CREATE TABLE IF NOT EXISTS user_subscriptions ( started_at TIMESTAMP NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS weekly_digests ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + week_start DATE NOT NULL, + week_end DATE NOT NULL, + summary TEXT NOT NULL, + tips TEXT NOT NULL DEFAULT '[]', + highlights TEXT NOT NULL DEFAULT '[]', + raw_data TEXT NOT NULL DEFAULT '{}', + method VARCHAR(20) NOT NULL DEFAULT 'heuristic', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (user_id, week_start) +); + CREATE TABLE IF NOT EXISTS audit_logs ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..56b48dd7 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,24 @@ class UserSubscription(db.Model): started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class WeeklyDigest(db.Model): + __tablename__ = "weekly_digests" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + week_start = db.Column(db.Date, nullable=False) + week_end = db.Column(db.Date, nullable=False) + summary = db.Column(db.Text, nullable=False) + tips = db.Column(db.Text, default="[]", nullable=False) + highlights = db.Column(db.Text, default="[]", nullable=False) + raw_data = db.Column(db.Text, default="{}", nullable=False) + method = db.Column(db.String(20), default="heuristic", nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + __table_args__ = ( + db.UniqueConstraint("user_id", "week_start", name="uq_user_week"), + ) + + class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..4eb8ee8e 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .digest import bp as digest_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(digest_bp, url_prefix="/digest") diff --git a/packages/backend/app/routes/digest.py b/packages/backend/app/routes/digest.py new file mode 100644 index 00000000..50ec76e1 --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,66 @@ +"""Weekly digest endpoints.""" + +import json +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..models import WeeklyDigest +from ..services.digest import generate_digest + +bp = Blueprint("digest", __name__, url_prefix="/digest") + + +@bp.get("/latest") +@jwt_required() +def get_latest_digest(): + """Get the most recent weekly digest for the current user.""" + user_id = get_jwt_identity() + digest = ( + WeeklyDigest.query.filter_by(user_id=user_id) + .order_by(WeeklyDigest.week_start.desc()) + .first() + ) + if not digest: + return jsonify(error="No digest available yet"), 404 + return jsonify(_serialize(digest)), 200 + + +@bp.get("/history") +@jwt_required() +def get_digest_history(): + """Get digest history for the current user.""" + user_id = get_jwt_identity() + limit = request.args.get("limit", 10, type=int) + limit = min(limit, 52) # cap at 1 year of weekly digests + + digests = ( + WeeklyDigest.query.filter_by(user_id=user_id) + .order_by(WeeklyDigest.week_start.desc()) + .limit(limit) + .all() + ) + return jsonify([_serialize(d) for d in digests]), 200 + + +@bp.post("/generate") +@jwt_required() +def trigger_digest(): + """Manually trigger digest generation for the current user.""" + user_id = get_jwt_identity() + digest = generate_digest(user_id) + if not digest: + return jsonify(error="No transactions found for last week"), 404 + return jsonify(_serialize(digest)), 201 + + +def _serialize(digest): + return { + "id": digest.id, + "week_start": digest.week_start.isoformat(), + "week_end": digest.week_end.isoformat(), + "summary": digest.summary, + "tips": json.loads(digest.tips), + "highlights": json.loads(digest.highlights), + "method": digest.method, + "created_at": digest.created_at.isoformat(), + } diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py new file mode 100644 index 00000000..91076308 --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,248 @@ +"""Weekly financial digest generation service.""" + +import json +import logging +from datetime import date, timedelta +from urllib import request as url_request + +from sqlalchemy import func + +from ..config import Settings +from ..extensions import db +from ..models import Expense, Category, User, WeeklyDigest + +logger = logging.getLogger("finmind.digest") +_settings = Settings() + +DEFAULT_PERSONA = ( + "You are FinMind's weekly financial digest writer. Be concise, insightful, " + "and action-oriented. Highlight trends, flag concerns, celebrate wins." +) + + +def _week_boundaries(reference_date=None): + """Return (start, end) of the previous completed week (Mon-Sun).""" + today = reference_date or date.today() + end = today - timedelta(days=today.weekday() + 1) # last Sunday + start = end - timedelta(days=6) # Monday before + return start, end + + +def _weekly_expenses(user_id, start, end): + """Fetch expenses grouped by category for a given week.""" + rows = ( + db.session.query( + Category.name, + func.coalesce(func.sum(Expense.amount), 0), + func.count(Expense.id), + ) + .outerjoin(Category, Expense.category_id == Category.id) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .group_by(Category.name) + .all() + ) + return [ + {"category": name or "Uncategorized", "total": float(total), "count": int(count)} + for name, total, count in rows + ] + + +def _weekly_income(user_id, start, end): + """Fetch total income for a given week.""" + result = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + return float(result or 0) + + +def _previous_week_total(user_id, start): + """Get total expenses from the week before.""" + prev_end = start - timedelta(days=1) + prev_start = prev_end - timedelta(days=6) + result = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= prev_start, + Expense.spent_at <= prev_end, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return float(result or 0) + + +def _build_digest_data(user_id, start, end): + """Compile raw data for digest generation.""" + by_category = _weekly_expenses(user_id, start, end) + total_spent = sum(c["total"] for c in by_category) + total_income = _weekly_income(user_id, start, end) + prev_total = _previous_week_total(user_id, start) + + wow_change = 0.0 + if prev_total > 0: + wow_change = round(((total_spent - prev_total) / prev_total) * 100, 2) + + top_categories = sorted(by_category, key=lambda x: x["total"], reverse=True)[:5] + + return { + "week_start": start.isoformat(), + "week_end": end.isoformat(), + "total_spent": round(total_spent, 2), + "total_income": round(total_income, 2), + "net_flow": round(total_income - total_spent, 2), + "week_over_week_change_pct": wow_change, + "previous_week_spent": round(prev_total, 2), + "top_categories": top_categories, + "transaction_count": sum(c["count"] for c in by_category), + } + + +def _heuristic_summary(data): + """Generate a plain summary without AI.""" + lines = [f"Week of {data['week_start']} to {data['week_end']}:"] + lines.append(f"Total spent: {data['total_spent']:.2f}") + lines.append(f"Total income: {data['total_income']:.2f}") + lines.append(f"Net flow: {data['net_flow']:.2f}") + + if data["week_over_week_change_pct"] > 0: + lines.append( + f"Spending increased {data['week_over_week_change_pct']}% vs last week." + ) + elif data["week_over_week_change_pct"] < 0: + lines.append( + f"Spending decreased {abs(data['week_over_week_change_pct'])}% vs last week." + ) + + if data["top_categories"]: + lines.append("Top categories:") + for cat in data["top_categories"][:3]: + lines.append(f" - {cat['category']}: {cat['total']:.2f} ({cat['count']} txns)") + + tips = [] + if data["net_flow"] < 0: + tips.append("Your expenses exceeded income this week. Review discretionary spending.") + if data["week_over_week_change_pct"] > 20: + tips.append("Spending jumped significantly. Check if any large one-time purchases drove this.") + if not tips: + tips.append("Good week! Keep maintaining your spending habits.") + + return {"summary": "\n".join(lines), "tips": tips, "method": "heuristic"} + + +def _ai_summary(data, api_key, model): + """Generate an AI-powered summary using Gemini.""" + prompt = ( + f"{DEFAULT_PERSONA}\n\n" + "Generate a brief weekly financial digest from this data. " + "Return JSON with keys: summary (string, 3-5 sentences), " + "tips (list of 2-3 actionable tips), highlights (list of 1-2 positive notes).\n\n" + f"Data: {json.dumps(data)}" + ) + + 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("utf-8") + + req = url_request.Request( + url=url, data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with url_request.urlopen(req, timeout=15) as resp: + payload = json.loads(resp.read().decode("utf-8")) + + text = ( + payload.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + + # Try to parse JSON from response + try: + from .ai import _extract_json_object + parsed = _extract_json_object(text) + parsed["method"] = "gemini" + return parsed + except (ValueError, json.JSONDecodeError): + return {"summary": text.strip(), "tips": [], "method": "gemini_raw"} + + +def generate_digest(user_id, reference_date=None): + """Generate a weekly digest for a user and store it.""" + start, end = _week_boundaries(reference_date) + + # Check if digest already exists for this week + existing = WeeklyDigest.query.filter_by( + user_id=user_id, week_start=start + ).first() + if existing: + return existing + + data = _build_digest_data(user_id, start, end) + + # Skip if no transactions + if data["transaction_count"] == 0: + return None + + # Try AI, fall back to heuristic + api_key = (_settings.gemini_api_key or "").strip() + model = _settings.gemini_model + if api_key: + try: + result = _ai_summary(data, api_key, model) + except Exception: + logger.warning("Gemini digest failed, falling back to heuristic", exc_info=True) + result = _heuristic_summary(data) + else: + result = _heuristic_summary(data) + + digest = WeeklyDigest( + user_id=user_id, + week_start=start, + week_end=end, + summary=result.get("summary", ""), + tips=json.dumps(result.get("tips", [])), + highlights=json.dumps(result.get("highlights", [])), + raw_data=json.dumps(data), + method=result.get("method", "unknown"), + ) + db.session.add(digest) + db.session.commit() + + return digest + + +def generate_all_digests(reference_date=None): + """Generate digests for all active users. Called by scheduler.""" + users = User.query.all() + generated = 0 + for user in users: + try: + result = generate_digest(user.id, reference_date) + if result: + generated += 1 + except Exception: + logger.exception("Failed to generate digest for user %s", user.id) + logger.info("Generated %d digests for %d users", generated, len(users)) + return generated diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 00000000..2828de0a --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,169 @@ +"""Tests for weekly digest feature.""" + +import json +from datetime import date, timedelta + +import pytest + +from app.extensions import db +from app.models import Expense, Category, WeeklyDigest +from app.services.digest import ( + generate_digest, + _week_boundaries, + _build_digest_data, + _heuristic_summary, +) + + +class TestWeekBoundaries: + def test_returns_monday_to_sunday(self): + # Wednesday 2026-03-18 + start, end = _week_boundaries(date(2026, 3, 18)) + assert start.weekday() == 0 # Monday + assert end.weekday() == 6 # Sunday + assert start == date(2026, 3, 9) + assert end == date(2026, 3, 15) + + def test_monday_returns_previous_week(self): + start, end = _week_boundaries(date(2026, 3, 16)) # Monday + assert start == date(2026, 3, 9) + assert end == date(2026, 3, 15) + + def test_sunday_returns_previous_week(self): + # On Sunday, the "previous completed week" is the week before + start, end = _week_boundaries(date(2026, 3, 22)) # Sunday + assert start == date(2026, 3, 9) + assert end == date(2026, 3, 15) + + +class TestBuildDigestData: + def test_empty_week(self, app_fixture, auth_header, client): + with app_fixture.app_context(): + data = _build_digest_data(1, date(2026, 1, 5), date(2026, 1, 11)) + assert data["total_spent"] == 0 + assert data["transaction_count"] == 0 + + def test_with_expenses(self, app_fixture, auth_header, client): + with app_fixture.app_context(): + cat = Category(user_id=1, name="Food") + db.session.add(cat) + db.session.flush() + + for i in range(3): + db.session.add( + Expense( + user_id=1, + category_id=cat.id, + amount=100 + i * 10, + spent_at=date(2026, 3, 10 + i), + ) + ) + db.session.commit() + + data = _build_digest_data(1, date(2026, 3, 9), date(2026, 3, 15)) + assert data["total_spent"] == 330.0 + assert data["transaction_count"] == 3 + assert len(data["top_categories"]) == 1 + assert data["top_categories"][0]["category"] == "Food" + + +class TestHeuristicSummary: + def test_positive_net_flow(self): + data = { + "week_start": "2026-03-09", + "week_end": "2026-03-15", + "total_spent": 500, + "total_income": 1000, + "net_flow": 500, + "week_over_week_change_pct": -10, + "previous_week_spent": 555, + "top_categories": [{"category": "Food", "total": 300, "count": 5}], + "transaction_count": 5, + } + result = _heuristic_summary(data) + assert "summary" in result + assert "tips" in result + assert result["method"] == "heuristic" + assert "decreased" in result["summary"] + + def test_negative_net_flow_warns(self): + data = { + "week_start": "2026-03-09", + "week_end": "2026-03-15", + "total_spent": 1500, + "total_income": 1000, + "net_flow": -500, + "week_over_week_change_pct": 25, + "previous_week_spent": 1200, + "top_categories": [], + "transaction_count": 10, + } + result = _heuristic_summary(data) + assert any("exceeded" in t for t in result["tips"]) + assert any("jumped" in t for t in result["tips"]) + + +class TestDigestEndpoints: + def test_latest_empty(self, client, auth_header): + r = client.get("/digest/latest", headers=auth_header) + assert r.status_code == 404 + + def test_history_empty(self, client, auth_header): + r = client.get("/digest/history", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + def test_generate_no_transactions(self, client, auth_header): + r = client.post("/digest/generate", headers=auth_header) + assert r.status_code == 404 + + def test_full_flow(self, app_fixture, client, auth_header): + with app_fixture.app_context(): + # Add expenses for last week + start, end = _week_boundaries() + for i in range(5): + day = start + timedelta(days=i) + db.session.add( + Expense( + user_id=1, + amount=50 + i * 10, + spent_at=day, + ) + ) + db.session.commit() + + # Generate digest + r = client.post("/digest/generate", headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert "summary" in data + assert data["method"] == "heuristic" + + # Get latest + r = client.get("/digest/latest", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["id"] == data["id"] + + # Get history + r = client.get("/digest/history", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 1 + + def test_idempotent_generation(self, app_fixture, client, auth_header): + with app_fixture.app_context(): + start, _ = _week_boundaries() + db.session.add( + Expense(user_id=1, amount=100, spent_at=start) + ) + db.session.commit() + + r1 = client.post("/digest/generate", headers=auth_header) + r2 = client.post("/digest/generate", headers=auth_header) + assert r1.status_code == 201 + # Second call returns the same digest (idempotent) + assert r2.status_code == 201 + assert r1.get_json()["id"] == r2.get_json()["id"] + + def test_history_limit(self, client, auth_header): + r = client.get("/digest/history?limit=5", headers=auth_header) + assert r.status_code == 200 From 84562b36f200d7b33aeba82e01766e657d704f28 Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 16:38:12 +0100 Subject: [PATCH 2/5] fix: address Copilot review - url_prefix, jwt identity, imports, bandit nosec, query join --- packages/backend/app/routes/digest.py | 10 +++++----- packages/backend/app/services/digest.py | 3 ++- packages/backend/tests/test_digest.py | 6 +----- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/backend/app/routes/digest.py b/packages/backend/app/routes/digest.py index 50ec76e1..41f0b395 100644 --- a/packages/backend/app/routes/digest.py +++ b/packages/backend/app/routes/digest.py @@ -7,14 +7,14 @@ from ..models import WeeklyDigest from ..services.digest import generate_digest -bp = Blueprint("digest", __name__, url_prefix="/digest") +bp = Blueprint("digest", __name__) @bp.get("/latest") @jwt_required() def get_latest_digest(): """Get the most recent weekly digest for the current user.""" - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) digest = ( WeeklyDigest.query.filter_by(user_id=user_id) .order_by(WeeklyDigest.week_start.desc()) @@ -29,9 +29,9 @@ def get_latest_digest(): @jwt_required() def get_digest_history(): """Get digest history for the current user.""" - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) limit = request.args.get("limit", 10, type=int) - limit = min(limit, 52) # cap at 1 year of weekly digests + limit = max(1, min(limit, 52)) digests = ( WeeklyDigest.query.filter_by(user_id=user_id) @@ -46,7 +46,7 @@ def get_digest_history(): @jwt_required() def trigger_digest(): """Manually trigger digest generation for the current user.""" - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) digest = generate_digest(user_id) if not digest: return jsonify(error="No transactions found for last week"), 404 diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py index 91076308..97794022 100644 --- a/packages/backend/app/services/digest.py +++ b/packages/backend/app/services/digest.py @@ -36,6 +36,7 @@ def _weekly_expenses(user_id, start, end): func.coalesce(func.sum(Expense.amount), 0), func.count(Expense.id), ) + .select_from(Expense) .outerjoin(Category, Expense.category_id == Category.id) .filter( Expense.user_id == user_id, @@ -168,7 +169,7 @@ def _ai_summary(data, api_key, model): headers={"Content-Type": "application/json"}, method="POST", ) - with url_request.urlopen(req, timeout=15) as resp: + with url_request.urlopen(req, timeout=15) as resp: # nosec B310 payload = json.loads(resp.read().decode("utf-8")) text = ( diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py index 2828de0a..b3bd27b4 100644 --- a/packages/backend/tests/test_digest.py +++ b/packages/backend/tests/test_digest.py @@ -1,14 +1,10 @@ """Tests for weekly digest feature.""" -import json from datetime import date, timedelta -import pytest - from app.extensions import db -from app.models import Expense, Category, WeeklyDigest +from app.models import Expense, Category from app.services.digest import ( - generate_digest, _week_boundaries, _build_digest_data, _heuristic_summary, From 9f4ea377ccc50e7e41918fc93054bfb49d19c5c7 Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 18:35:59 +0100 Subject: [PATCH 3/5] feat: add frontend UI, API client, OpenAPI docs for weekly digest - Frontend: Digest page with latest summary, tips, highlights, and history - Frontend: Generate button with loading spinner - Frontend: API client (digest.ts) following existing pattern - Frontend: Route in App.tsx + navigation link in Navbar - OpenAPI: /digest/latest, /digest/history, /digest/generate paths - OpenAPI: WeeklyDigest schema --- app/src/App.tsx | 9 ++ app/src/api/digest.ts | 24 +++++ app/src/components/layout/Navbar.tsx | 1 + app/src/pages/Digest.tsx | 143 +++++++++++++++++++++++++++ packages/backend/app/openapi.yaml | 51 ++++++++++ 5 files changed, 228 insertions(+) create mode 100644 app/src/api/digest.ts create mode 100644 app/src/pages/Digest.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..9202091d 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 Digest from "./pages/Digest"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> { + return api('/digest/latest'); +} + +export async function getDigestHistory(limit = 10): Promise { + return api(`/digest/history?limit=${limit}`); +} + +export async function generateDigest(): Promise { + return api('/digest/generate', { method: 'POST' }); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..ff882e19 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: 'Digest', href: '/digest' }, ]; export function Navbar() { diff --git a/app/src/pages/Digest.tsx b/app/src/pages/Digest.tsx new file mode 100644 index 00000000..1b403844 --- /dev/null +++ b/app/src/pages/Digest.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect, useCallback } from 'react'; +import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { FileText, RefreshCw, Lightbulb, TrendingUp, Calendar } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { getLatestDigest, getDigestHistory, generateDigest, type WeeklyDigest } from '@/api/digest'; + +export default function Digest() { + const { toast } = useToast(); + const [latest, setLatest] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + + const loadData = useCallback(async () => { + try { + const [lat, hist] = await Promise.allSettled([getLatestDigest(), getDigestHistory()]); + if (lat.status === 'fulfilled') setLatest(lat.value); + if (hist.status === 'fulfilled') setHistory(hist.value); + } catch { + // No digest yet is fine + } finally { setLoading(false); } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const handleGenerate = async () => { + setGenerating(true); + try { + const d = await generateDigest(); + setLatest(d); + toast({ title: 'Digest generated!' }); + loadData(); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'No transactions for last week', variant: 'destructive' }); + } finally { setGenerating(false); } + }; + + if (loading) return
Loading...
; + + return ( +
+
+
+

Weekly Digest

+

Your weekly financial summary and insights

+
+ +
+ + {latest ? ( + + +
+
+ + + Week of {latest.week_start} to {latest.week_end} + +
+ {latest.method} +
+
+ + {/* Summary */} +
{latest.summary}
+ + {/* Tips */} + {latest.tips.length > 0 && ( +
+

+ Tips +

+
    + {latest.tips.map((tip, i) => ( +
  • + {tip} +
  • + ))} +
+
+ )} + + {/* Highlights */} + {latest.highlights.length > 0 && ( +
+

+ Highlights +

+
    + {latest.highlights.map((h, i) => ( +
  • + {h} +
  • + ))} +
+
+ )} +
+
+ ) : ( + + + +

No digest yet. Add some expenses and generate your first weekly digest!

+ +
+
+ )} + + {/* History */} + {history.length > 1 && ( +
+

+ Previous Digests +

+ {history.slice(1).map(d => ( + + +
+ + {d.week_start} — {d.week_end} + + {d.method} +
+
+ +

{d.summary}

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..f424c275 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Digest paths: /auth/register: post: @@ -251,6 +252,45 @@ paths: properties: inserted: { type: integer } + /digest/latest: + get: + summary: Get latest weekly digest + tags: [Digest] + security: [{ bearerAuth: [] }] + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/WeeklyDigest' } + '404': { description: No digest available } + /digest/history: + get: + summary: Get digest history + tags: [Digest] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: limit + schema: { type: integer, default: 10, maximum: 52 } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/WeeklyDigest' } } + /digest/generate: + post: + summary: Generate weekly digest + tags: [Digest] + security: [{ bearerAuth: [] }] + responses: + '201': + description: Digest generated + content: + application/json: + schema: { $ref: '#/components/schemas/WeeklyDigest' } + '404': { description: No transactions for last week } /bills: get: summary: List bills @@ -587,3 +627,14 @@ components: message: { type: string } send_at: { type: string, format: date-time } channel: { type: string, enum: [email, whatsapp], default: email } + WeeklyDigest: + type: object + properties: + id: { type: integer } + week_start: { type: string, format: date } + week_end: { type: string, format: date } + summary: { type: string } + tips: { type: array, items: { type: string } } + highlights: { type: array, items: { type: string } } + method: { type: string, enum: [heuristic, gemini] } + created_at: { type: string, format: date-time } From a5666529722cbb45a129d1ac89b76745bba9e46f Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 18:49:00 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20remaining=20Copilot=20revi?= =?UTF-8?q?ew=20=E2=80=94=20race=20condition,=20IntegrityError=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEMO_INSTRUCTIONS.md | 45 ++++++++++ demo.sh | 106 ++++++++++++++++++++++++ packages/backend/app/services/digest.py | 9 +- packages/backend/instance/demo.db | Bin 0 -> 57344 bytes packages/backend/instance/demo2.db | Bin 0 -> 57344 bytes packages/backend/start_demo_server.sh | 27 ++++++ run_demo.sh | 99 ++++++++++++++++++++++ 7 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 DEMO_INSTRUCTIONS.md create mode 100755 demo.sh create mode 100644 packages/backend/instance/demo.db create mode 100644 packages/backend/instance/demo2.db create mode 100755 packages/backend/start_demo_server.sh create mode 100755 run_demo.sh diff --git a/DEMO_INSTRUCTIONS.md b/DEMO_INSTRUCTIONS.md new file mode 100644 index 00000000..f83f7b8e --- /dev/null +++ b/DEMO_INSTRUCTIONS.md @@ -0,0 +1,45 @@ +# Com gravar el video demo pels PRs de FinMind + +## Que hem fet (resum) + +Hem creat 3 features pel backend de FinMind (Python/Flask): + +### PR #609 — Multi-Account Dashboard ($200) +- Crear comptes financers (checking, savings, credit card) +- Veure resum amb net worth (assets - deutes) +- Desactivar comptes (soft-delete) + +### PR #608 — Savings Goals ($250) +- Crear objectius d'estalvi amb target i deadline +- Afegir contribucions +- Veure progrés amb milestones (25/50/75/100%) +- Auto-completa quan arribes al target + +### PR #607 — Weekly Digest ($500) +- Genera resum setmanal de despeses +- Compara amb la setmana anterior (week-over-week %) +- Tips automàtics basats en patrons de despesa + +## Com gravar + +### 1. Obre 2 terminals + +### 2. Terminal 1 — Arrenca el servidor: +```bash +cd /home/clawd/workspace/bounties/finmind/packages/backend +bash start_demo_server.sh +``` +Espera a veure "Running on http://127.0.0.1:5556" + +### 3. Terminal 2 — Comença a gravar pantalla, llavors executa: +```bash +cd /home/clawd/workspace/bounties/finmind +bash run_demo.sh +``` + +### 4. Para la gravació + +### 5. Puja el video com a comentari als 3 PRs: +- https://github.com/rohitdash08/FinMind/pull/607 +- https://github.com/rohitdash08/FinMind/pull/608 +- https://github.com/rohitdash08/FinMind/pull/609 diff --git a/demo.sh b/demo.sh new file mode 100755 index 00000000..bd9e8c8f --- /dev/null +++ b/demo.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# FinMind Feature Demo - PRs #607, #608, #609 +BASE=http://127.0.0.1:5555 + +echo "======================================" +echo " FinMind Feature Demo" +echo "======================================" +echo "" + +# Register and login +echo ">>> Register user" +curl -s -X POST $BASE/auth/register -H "Content-Type: application/json" \ + -d '{"email":"demo@test.com","password":"demo1234"}' | python3 -m json.tool +echo "" + +echo ">>> Login" +TOKEN=$(curl -s -X POST $BASE/auth/login -H "Content-Type: application/json" \ + -d '{"email":"demo@test.com","password":"demo1234"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +echo "Got token: ${TOKEN:0:20}..." +AUTH="Authorization: Bearer $TOKEN" +echo "" + +# Add some expenses for digest +echo "======================================" +echo " PR #607: Weekly Digest" +echo "======================================" +echo "" + +echo ">>> Adding expenses for last week..." +for i in 1 2 3 4 5; do + DAY=$(date -d "last monday + ${i} days" +%Y-%m-%d 2>/dev/null || date -v-7d +%Y-%m-%d) + curl -s -X POST $BASE/expenses -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"amount\":$((50 + i * 15)),\"notes\":\"Expense day $i\",\"spent_at\":\"$DAY\"}" > /dev/null +done +echo "Added 5 expenses" +echo "" + +echo ">>> POST /digest/generate" +curl -s -X POST $BASE/digest/generate -H "$AUTH" | python3 -m json.tool +echo "" + +echo ">>> GET /digest/latest" +curl -s $BASE/digest/latest -H "$AUTH" | python3 -m json.tool +echo "" + +echo ">>> GET /digest/history" +curl -s "$BASE/digest/history?limit=5" -H "$AUTH" | python3 -m json.tool +echo "" + +# Savings Goals +echo "======================================" +echo " PR #608: Savings Goals" +echo "======================================" +echo "" + +echo ">>> POST /goals/ (create goal)" +curl -s -X POST $BASE/goals/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Emergency Fund","target_amount":10000,"currency":"EUR","deadline":"2026-12-31"}' | python3 -m json.tool +echo "" + +echo ">>> POST /goals/1/contribute (add 2500)" +curl -s -X POST $BASE/goals/1/contribute -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"amount":2500,"note":"First deposit"}' | python3 -m json.tool +echo "" + +echo ">>> POST /goals/1/contribute (add 5000)" +curl -s -X POST $BASE/goals/1/contribute -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"amount":5000,"note":"Bonus savings"}' | python3 -m json.tool +echo "" + +echo ">>> GET /goals/1/progress (75% with milestones)" +curl -s $BASE/goals/1/progress -H "$AUTH" | python3 -m json.tool +echo "" + +echo ">>> GET /goals/1/contributions" +curl -s $BASE/goals/1/contributions -H "$AUTH" | python3 -m json.tool +echo "" + +# Multi-account +echo "======================================" +echo " PR #609: Multi-Account Dashboard" +echo "======================================" +echo "" + +echo ">>> POST /accounts/ (checking)" +curl -s -X POST $BASE/accounts/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Main Checking","account_type":"checking","balance":5000,"currency":"EUR"}' | python3 -m json.tool +echo "" + +echo ">>> POST /accounts/ (savings)" +curl -s -X POST $BASE/accounts/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Savings","account_type":"savings","balance":15000,"currency":"EUR"}' | python3 -m json.tool +echo "" + +echo ">>> POST /accounts/ (credit card)" +curl -s -X POST $BASE/accounts/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Credit Card","account_type":"credit","balance":2000,"currency":"EUR"}' | python3 -m json.tool +echo "" + +echo ">>> GET /accounts/overview (net worth)" +curl -s $BASE/accounts/overview -H "$AUTH" | python3 -m json.tool +echo "" + +echo "======================================" +echo " Demo Complete!" +echo "======================================" diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py index 97794022..b1768409 100644 --- a/packages/backend/app/services/digest.py +++ b/packages/backend/app/services/digest.py @@ -229,7 +229,14 @@ def generate_digest(user_id, reference_date=None): method=result.get("method", "unknown"), ) db.session.add(digest) - db.session.commit() + try: + db.session.commit() + except Exception: + db.session.rollback() + # Race condition: another request created it first, return that one + return WeeklyDigest.query.filter_by( + user_id=user_id, week_start=start + ).first() return digest diff --git a/packages/backend/instance/demo.db b/packages/backend/instance/demo.db new file mode 100644 index 0000000000000000000000000000000000000000..f271b38b36149b1f9a81ca12ee9043eee55e806e GIT binary patch literal 57344 zcmeI2&2QUA7QiJtv259ngI%;bwPDZ%6@fG$DN&RR6sYVni4a>(7`t6>Q4qu7$Yx8C z3WrMU1@_>NAg4X_-{^V&jlK4?=U(?3?4j5p^*Pjs1-H)D=o27`d^059`@Q$(&6EA& z`IZwBq2UKj6bgmZ?NmCQ`a%#=snjZa-(v6HYndI~=$)|d^w9C7!`0NofBg4mGar?Jb5_f%O z)V8a}zVKAv7xIoRMyfp8G4$HwdL+wttq=u6f213FeN%rSL^3*dcGoU$b|mY}&Wh)s zWHUKgPQQKG#f;lQ;CKfnIcgD)62?0zSx$gYj$hIP7=`4(4~`RFKuy2xh0KTBx>4KA z%gO^m;oT5DpLqDVPgG72?V4hS$E|@klh{2!B(#5v5np3uvu6x0*LWRhh=S0xnF(<; za1M{6q(2gQ6XDQ#MaBn5K-fJdN7Zi|97D*t0O`sd1|*8Wta#E`%H;0eO`mm=5w&e6 zG+qCI4vw;1R1D;^M?(*d@AcgvCsPhzDW9x(CM?G3@aFqC9U6{@JnSIXL>NbLNJk}0 zoZ+M6vjgCw#zW0Pv?)mrU3K`7Rugf5VPaUwW#*A-$>mUH`s8r+T|el1ON-a4t9H@p zekOL&`M0r);^mOGEs6uD6*|60M7o{+H?&%D?wbNFUMt>Pj(3&h@_QJU z8BOTtd3zy~6UFq|{g^GXO{dukSRIVG@@E;2DL*zl&nV}a0+=)jTjYC{_Wh?lxtYo3 za_P5XY(RU)mE7R$A03~L_4-q}=FHxh@0@5ICBWjx$mfgU2 zhf6#ExXYx#UcL5wPZ#pBC2YGU&Mo9V&!tmQ$8f00e*l5C8%|00;m9AaD_Zll{5Hlz965&mUCTA5=o+ zi|F$q_M7Yn+T&KZUQo1BdA+|!#>~z z1b_e#00KY&2mk>f00e*l5C8%|00>+=0)Ltp)BJBSbGPR|UE+Sii2nZH{onj+XCEjG z1b_e#00KY&2mk>f00e*l5C8%|00_)R0Q&#gIKeC+00e*l5C8%|00;m9AOHk_01yBI z*O>tH|JT`jpgRx%0zd!=00AHX1b_e#00KY&2mpcE2tfZo8z-0r1b_e#00KY&2mk>f z00e*l5C8%|;5rk4{{K3A4|E3tKmZ5;0U!VbfB+Bx0zd!=00AH{8v*G5XX6C3fB+Bx z0zd!=00AHX1b_e#00KY&2wZ0Z(End&?}6?>00;m9AOHk_01yBIKmZ5;0U!VbW+MRo z|7@IK77zdeKmZ5;0U!VbfB+Bx0zd!=0D^;yO2mk>f00e*l5C8%|00;m9 zAOHk_z-$Dd|DTN$%mM;H00;m9AOHk_01yBIKmZ5;0U&Ul39PQYORc1iQ@8%P_OI2S zR({U@l3iH*F_T&Nc>dko4{YG7UgsaIWO5&Ulz#j75LqrEM=jz}LVMqxqj1Ao71LFq1Wx8g?Q+qj+ zyK^V~CKty=K$?zcGv)dRD_mss#g_~X%W>TV-zK3H9SqzVIgIxY@g~!!%&Wr2&d!!z zt#eo5L*#kH?Z-hfN`70nb?xGqN3xE$vf}wC*-TEB({EpPG52Dv=Z;YaEhoSyuehWK zh61dY*#F<;$%xvv6Pm7nKnF)zE-D7{*`uL{#`pTUAtzJrYS+B1cqS~y>G0mN>&l$7ct?1)PVPgJ@Hd9J=c8^+rvsPZw4#3%SfZGOhACl$ky`Tz%IM z`rgvw5_8oqI^EC2E;|1pI?YzV>R`l`Kg)1T`LWq~Mmf(Ez@!X9Q>f!btHYq)^K z(+e1Chkgqko5YKD;r96A)Y-Y1uMbg3QL8m|5@{mG9Xn%6YFzc&aniiesbih^9_(EQ zM>jc-59G7QG>4sY`+F~EC*RD+Yvn15<9amjM%t6(Cuj^W^E@@duxxPs{Gcjg(!XP2ex^L z=wW|}8l4u924P*IZC+S*1K%Ak?fl~|lLCA7+VeeK$j6qj-btKW$bFtmr=pJOh`w~4 YkeE?|$LD6u!<6H%dDhl1My>t-0D+JM=l}o! literal 0 HcmV?d00001 diff --git a/packages/backend/instance/demo2.db b/packages/backend/instance/demo2.db new file mode 100644 index 0000000000000000000000000000000000000000..b07cb58f3b770f310dbb2aeecd9deabd2501f4aa GIT binary patch literal 57344 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 Servidor llest! Ara executa 'bash run_demo.sh' a l'altra terminal\n") +a.run(port=5556, debug=False) +EOF diff --git a/run_demo.sh b/run_demo.sh new file mode 100755 index 00000000..922e376a --- /dev/null +++ b/run_demo.sh @@ -0,0 +1,99 @@ +#!/bin/bash +BASE=http://127.0.0.1:5556 + +echo "" +echo " ╔══════════════════════════════════════╗" +echo " ║ FinMind Feature Demo ║" +echo " ║ PRs #607, #608, #609 ║" +echo " ╚══════════════════════════════════════╝" +echo "" +sleep 1 + +# Auth +echo ">>> Register + Login" +curl -s -X POST $BASE/auth/register -H "Content-Type: application/json" \ + -d '{"email":"demo@finmind.test","password":"demo1234"}' | python3 -m json.tool +TOKEN=$(curl -s -X POST $BASE/auth/login -H "Content-Type: application/json" \ + -d '{"email":"demo@finmind.test","password":"demo1234"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +echo "Token: ${TOKEN:0:30}..." +echo "" +sleep 1 + +# ===== PR #609: Multi-Account ===== +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " PR #609: Multi-Account Dashboard (\$200)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +echo ">>> Create checking account (€5,000)" +curl -s -X POST $BASE/accounts/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Main Checking","account_type":"checking","balance":5000,"currency":"EUR"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Create savings account (€15,000)" +curl -s -X POST $BASE/accounts/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Savings","account_type":"savings","balance":15000,"currency":"EUR"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Create credit card (€2,000 debt)" +curl -s -X POST $BASE/accounts/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Credit Card","account_type":"credit","balance":2000,"currency":"EUR"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> GET /accounts/overview → Net worth calculation" +curl -s $BASE/accounts/overview -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" +sleep 1 + +# ===== PR #608: Savings Goals ===== +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " PR #608: Savings Goals (\$250)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +echo ">>> Create goal: Emergency Fund €10,000" +curl -s -X POST $BASE/goals/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Emergency Fund","target_amount":10000,"currency":"EUR","deadline":"2026-12-31"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Contribute €2,500" +curl -s -X POST $BASE/goals/1/contribute -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"amount":2500,"note":"First deposit"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Contribute €5,000" +curl -s -X POST $BASE/goals/1/contribute -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"amount":5000,"note":"Bonus savings"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> GET /goals/1/progress → 75% with milestones" +curl -s $BASE/goals/1/progress -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" +sleep 1 + +# ===== PR #607: Weekly Digest ===== +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " PR #607: Weekly Digest (\$500)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +echo ">>> Adding 5 expenses from last week..." +for i in 1 2 3 4 5; do + curl -s -X POST $BASE/expenses -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d "{\"amount\":$((50 + i * 15)),\"notes\":\"Expense $i\",\"spent_at\":\"2026-03-$(printf '%02d' $((9 + i)))\"}" > /dev/null +done +echo "Done (5 expenses added)" +sleep 0.5 + +echo ">>> POST /digest/generate → AI-powered weekly summary" +curl -s -X POST $BASE/digest/generate -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" +sleep 0.5 + +echo ">>> GET /digest/latest" +curl -s $BASE/digest/latest -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " ✓ All 3 features working!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" From befec86cf6676de1429edfbb8af60db30d562e69 Mon Sep 17 00:00:00 2001 From: GPradaT Date: Wed, 25 Mar 2026 08:10:21 +0100 Subject: [PATCH 5/5] fix: apply black formatting to pass CI lint check Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/backend/app/services/digest.py | 34 ++++++++++++++++--------- packages/backend/tests/test_digest.py | 4 +-- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py index b1768409..1d4f6ad7 100644 --- a/packages/backend/app/services/digest.py +++ b/packages/backend/app/services/digest.py @@ -48,7 +48,11 @@ def _weekly_expenses(user_id, start, end): .all() ) return [ - {"category": name or "Uncategorized", "total": float(total), "count": int(count)} + { + "category": name or "Uncategorized", + "total": float(total), + "count": int(count), + } for name, total, count in rows ] @@ -130,13 +134,19 @@ def _heuristic_summary(data): if data["top_categories"]: lines.append("Top categories:") for cat in data["top_categories"][:3]: - lines.append(f" - {cat['category']}: {cat['total']:.2f} ({cat['count']} txns)") + lines.append( + f" - {cat['category']}: {cat['total']:.2f} ({cat['count']} txns)" + ) tips = [] if data["net_flow"] < 0: - tips.append("Your expenses exceeded income this week. Review discretionary spending.") + tips.append( + "Your expenses exceeded income this week. Review discretionary spending." + ) if data["week_over_week_change_pct"] > 20: - tips.append("Spending jumped significantly. Check if any large one-time purchases drove this.") + tips.append( + "Spending jumped significantly. Check if any large one-time purchases drove this." + ) if not tips: tips.append("Good week! Keep maintaining your spending habits.") @@ -165,7 +175,8 @@ def _ai_summary(data, api_key, model): ).encode("utf-8") req = url_request.Request( - url=url, data=body, + url=url, + data=body, headers={"Content-Type": "application/json"}, method="POST", ) @@ -182,6 +193,7 @@ def _ai_summary(data, api_key, model): # Try to parse JSON from response try: from .ai import _extract_json_object + parsed = _extract_json_object(text) parsed["method"] = "gemini" return parsed @@ -194,9 +206,7 @@ def generate_digest(user_id, reference_date=None): start, end = _week_boundaries(reference_date) # Check if digest already exists for this week - existing = WeeklyDigest.query.filter_by( - user_id=user_id, week_start=start - ).first() + existing = WeeklyDigest.query.filter_by(user_id=user_id, week_start=start).first() if existing: return existing @@ -213,7 +223,9 @@ def generate_digest(user_id, reference_date=None): try: result = _ai_summary(data, api_key, model) except Exception: - logger.warning("Gemini digest failed, falling back to heuristic", exc_info=True) + logger.warning( + "Gemini digest failed, falling back to heuristic", exc_info=True + ) result = _heuristic_summary(data) else: result = _heuristic_summary(data) @@ -234,9 +246,7 @@ def generate_digest(user_id, reference_date=None): except Exception: db.session.rollback() # Race condition: another request created it first, return that one - return WeeklyDigest.query.filter_by( - user_id=user_id, week_start=start - ).first() + return WeeklyDigest.query.filter_by(user_id=user_id, week_start=start).first() return digest diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py index b3bd27b4..d45f6814 100644 --- a/packages/backend/tests/test_digest.py +++ b/packages/backend/tests/test_digest.py @@ -148,9 +148,7 @@ def test_full_flow(self, app_fixture, client, auth_header): def test_idempotent_generation(self, app_fixture, client, auth_header): with app_fixture.app_context(): start, _ = _week_boundaries() - db.session.add( - Expense(user_id=1, amount=100, spent_at=start) - ) + db.session.add(Expense(user_id=1, amount=100, spent_at=start)) db.session.commit() r1 = client.post("/digest/generate", headers=auth_header)