From b3d495e57ce36e35778b9943d8d19d126e34a91a Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sun, 31 May 2026 16:49:18 -0400 Subject: [PATCH] 05/31 Phase 5 --- app/__init__.py | 2 + app/routes/ai.py | 65 ++++++ app/routes/dashboard.py | 7 +- app/services/ai_service.py | 358 +++++++++++++++++++++++++++++ app/templates/ai/history.html | 71 ++++++ app/templates/ai/index.html | 239 +++++++++++++++++++ app/templates/base.html | 2 +- app/templates/dashboard/index.html | 26 +++ scripts/daily_ai_insight.py | 25 ++ 9 files changed, 793 insertions(+), 2 deletions(-) create mode 100644 app/routes/ai.py create mode 100644 app/services/ai_service.py create mode 100644 app/templates/ai/history.html create mode 100644 app/templates/ai/index.html create mode 100644 scripts/daily_ai_insight.py diff --git a/app/__init__.py b/app/__init__.py index 53a067a..6246edd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -26,6 +26,7 @@ def create_app(config_name=None): from app.routes.budgets import budgets_bp from app.routes.goals import goals_bp from app.routes.investments import investments_bp + from app.routes.ai import ai_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -35,6 +36,7 @@ def create_app(config_name=None): app.register_blueprint(budgets_bp) app.register_blueprint(goals_bp) app.register_blueprint(investments_bp) + app.register_blueprint(ai_bp) with app.app_context(): from app.models import ( diff --git a/app/routes/ai.py b/app/routes/ai.py new file mode 100644 index 0000000..a45c11b --- /dev/null +++ b/app/routes/ai.py @@ -0,0 +1,65 @@ +from flask import Blueprint, render_template, request, Response, stream_with_context, jsonify +from flask_login import login_required +from app.models.ai_insight import AiInsight +from app.services.ai_service import stream_chat, get_latest_daily_insight, generate_daily_insight + +ai_bp = Blueprint('ai', __name__, url_prefix='/ai') + + +@ai_bp.route('/') +@login_required +def index(): + latest_insight = get_latest_daily_insight() + recent_chats = AiInsight.query\ + .filter_by(insight_type='chat_response')\ + .order_by(AiInsight.created_at.desc())\ + .limit(20).all() + return render_template('ai/index.html', + latest_insight=latest_insight, + recent_chats=recent_chats) + + +@ai_bp.route('/stream') +@login_required +def stream(): + """SSE endpoint — streams Groq response chunk by chunk.""" + message = request.args.get('message', '').strip() + if not message: + def empty(): + yield 'data: Please enter a message.\n\n' + yield 'data: [DONE]\n\n' + return Response(stream_with_context(empty()), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }) + + return Response( + stream_with_context(stream_chat(message)), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + } + ) + + +@ai_bp.route('/generate-insight', methods=['POST']) +@login_required +def trigger_insight(): + """Manually trigger a daily insight generation.""" + content = generate_daily_insight() + if content: + return jsonify({'status': 'ok', 'content': content}) + return jsonify({'status': 'error', 'message': 'Failed to generate insight'}), 500 + + +@ai_bp.route('/history') +@login_required +def history(): + page = request.args.get('page', 1, type=int) + insights = AiInsight.query\ + .order_by(AiInsight.created_at.desc())\ + .paginate(page=page, per_page=20, error_out=False) + return render_template('ai/history.html', insights=insights) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 4e779e7..7b80bad 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -6,6 +6,7 @@ from app.models.account import Account from app.models.transaction import Transaction from app.models.category import Category from app.services.fx_service import get_today_rate, get_rate_history +from app.services.ai_service import get_latest_daily_insight from app.services.account_service import get_total_assets, get_total_liabilities from datetime import date, datetime, timedelta import calendar @@ -127,6 +128,9 @@ def index(): 'rates': [float(r.usd_to_vnd) for r in fx_history], } + # ── AI insight ────────────────────────────────────── + ai_insight = get_latest_daily_insight() + return render_template('dashboard/index.html', period=period, period_label=period_label, @@ -145,7 +149,8 @@ def index(): chart_expense=chart_expense, recent_txns=recent_txns, fx=fx, - fx_history_data=fx_history_data) + fx_history_data=fx_history_data, + ai_insight=ai_insight) @dashboard_bp.route('/api/fx-history') diff --git a/app/services/ai_service.py b/app/services/ai_service.py new file mode 100644 index 0000000..73e84d9 --- /dev/null +++ b/app/services/ai_service.py @@ -0,0 +1,358 @@ +""" +AI Service — Groq API integration with financial context injection. +Builds a anonymized summary of the user's finances and sends to Groq. +No account names or personal details are sent — only aggregated numbers. +""" + +import logging +import json +from datetime import date, timedelta +from flask import current_app +from sqlalchemy import func +from app.extensions import db +from app.models.transaction import Transaction +from app.models.category import Category +from app.models.account import Account +from app.models.budget import Budget +from app.models.goal import Goal +from app.models.investment import Investment +from app.models.ai_insight import AiInsight +from app.services.budget_service import get_total_spent, get_total_budget + +log = logging.getLogger(__name__) + + +# ── Context builder ─────────────────────────────────────────────────────────── + +def build_context(days=90): + """ + Build an anonymized financial context string to inject into the AI prompt. + Covers last `days` days of activity. + """ + today = date.today() + since = today - timedelta(days=days) + curr_month = today.strftime('%Y-%m') + currency = current_app.config.get('APP_CURRENCY', 'USD') + symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$') + + lines = [f"Financial data summary (currency: {currency}):"] + + # ── This month summary ──────────────────────────────────────────────────── + month_income = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.transaction_type == 'income', + Transaction.date >= date(today.year, today.month, 1), + Transaction.date <= today, + ).scalar() + + month_expense = db.session.query( + func.coalesce(func.sum(Transaction.amount), 0) + ).filter( + Transaction.transaction_type == 'expense', + Transaction.date >= date(today.year, today.month, 1), + Transaction.date <= today, + ).scalar() + + lines.append(f"\nCURRENT MONTH ({today.strftime('%B %Y')}):") + lines.append(f" Income: {symbol}{float(month_income):,.2f}") + lines.append(f" Expenses: {symbol}{float(month_expense):,.2f}") + lines.append(f" Net: {symbol}{float(month_income) - float(month_expense):,.2f}") + + # ── Top spending categories (this month) ────────────────────────────────── + top_cats = db.session.query( + Category.name, + func.sum(Transaction.amount).label('total') + ).join(Transaction, Transaction.category_id == Category.id)\ + .filter( + Transaction.transaction_type == 'expense', + Transaction.date >= date(today.year, today.month, 1), + Transaction.date <= today, + ).group_by(Category.id)\ + .order_by(func.sum(Transaction.amount).desc())\ + .limit(6).all() + + if top_cats: + lines.append("\nTOP SPENDING CATEGORIES (this month):") + for cat, total in top_cats: + lines.append(f" {cat}: {symbol}{float(total):,.2f}") + + # ── Budget status ───────────────────────────────────────────────────────── + from app.services.budget_service import get_budget_summary + budget_summary = get_budget_summary(curr_month) + over_budget = [b for b in budget_summary if b['is_over']] + near_budget = [b for b in budget_summary if b['has_budget'] and b['pct'] and b['pct'] >= 80 and not b['is_over']] + + total_budget = get_total_budget(curr_month) + total_spent_month = get_total_spent(curr_month) + + if total_budget > 0: + lines.append(f"\nBUDGET STATUS ({today.strftime('%B %Y')}):") + lines.append(f" Total budget: {symbol}{total_budget:,.2f}") + lines.append(f" Total spent: {symbol}{total_spent_month:,.2f} ({round(total_spent_month/total_budget*100,1)}%)") + if over_budget: + lines.append(f" OVER BUDGET: {', '.join(b['category'].name for b in over_budget)}") + if near_budget: + lines.append(f" Near limit (80%+): {', '.join(b['category'].name + ' ' + str(b['pct']) + '%' for b in near_budget)}") + + # ── Accounts net worth ──────────────────────────────────────────────────── + accounts = Account.query.filter_by(is_active=True).all() + total_assets = sum(float(a.balance) for a in accounts if float(a.balance) > 0 and a.account_type != 'credit_card') + total_liab = sum(abs(float(a.balance)) for a in accounts if float(a.balance) < 0) + net_worth = total_assets - total_liab + + lines.append(f"\nNET WORTH: {symbol}{net_worth:,.2f}") + lines.append(f" Assets: {symbol}{total_assets:,.2f}") + lines.append(f" Liabilities: {symbol}{total_liab:,.2f}") + + # ── Goals ──────────────────────────────────────────────────────────────── + active_goals = Goal.query.filter_by(is_completed=False).all() + if active_goals: + lines.append("\nACTIVE SAVINGS GOALS:") + for g in active_goals: + lines.append( + f" {g.name}: {symbol}{float(g.current_amount):,.2f} / {symbol}{float(g.target_amount):,.2f} " + f"({g.progress_percent}%)" + + (f" — target {g.target_date.strftime('%b %Y')}" if g.target_date else "") + ) + + # ── Investments ─────────────────────────────────────────────────────────── + investments = Investment.query.filter_by(is_active=True).all() + if investments: + total_inv_value = sum(i.current_value for i in investments) + total_inv_gain = sum(i.unrealized_gain for i in investments) + lines.append(f"\nINVESTMENT PORTFOLIO:") + lines.append(f" Total value: {symbol}{total_inv_value:,.2f}") + lines.append(f" Unrealized P&L: {symbol}{total_inv_gain:,.2f}") + + # ── Recent 20 transactions ──────────────────────────────────────────────── + recent = Transaction.query\ + .filter( + Transaction.transaction_type.in_(['income', 'expense']), + Transaction.date >= since, + )\ + .order_by(Transaction.date.desc())\ + .limit(20).all() + + if recent: + lines.append(f"\nRECENT TRANSACTIONS (last {days} days, newest first):") + for txn in recent: + cat = txn.category.name if txn.category else 'Uncategorized' + sign = '+' if txn.transaction_type == 'income' else '-' + lines.append( + f" {txn.date.strftime('%b %d')} | {cat} | {sign}{symbol}{float(txn.amount):,.2f} | {txn.description}" + ) + + return '\n'.join(lines) + + +# ── Groq chat (streaming) ───────────────────────────────────────────────────── + +def stream_chat(user_message, context=None): + """ + Generator that yields SSE-formatted chunks from Groq streaming API. + Yields: 'data: \n\n' or 'data: [DONE]\n\n' + """ + api_key = current_app.config.get('GROQ_API_KEY', '') + model = current_app.config.get('GROQ_MODEL', 'llama-3.3-70b-versatile') + + if not api_key: + yield 'data: AI assistant is not configured. Please set GROQ_API_KEY in your .env file.\n\n' + yield 'data: [DONE]\n\n' + return + + if context is None: + try: + context = build_context() + except Exception as e: + log.error(f'[ai] context build failed: {e}') + context = '(financial context unavailable)' + + system_prompt = ( + "You are a concise, helpful personal finance assistant. " + "Answer questions based on the financial data provided. " + "Be specific with numbers. Keep answers focused and practical. " + "Do not make up data that isn't in the context. " + "If something isn't in the data, say so briefly." + ) + + messages = [ + {"role": "system", "content": system_prompt + "\n\n" + context}, + {"role": "user", "content": user_message}, + ] + + try: + import requests + resp = requests.post( + 'https://api.groq.com/openai/v1/chat/completions', + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + }, + json={ + 'model': model, + 'messages': messages, + 'max_tokens': 1000, + 'stream': True, + }, + stream=True, + timeout=60, + ) + resp.raise_for_status() + + full_text = [] + for line in resp.iter_lines(): + if not line: + continue + line = line.decode('utf-8') + if not line.startswith('data: '): + continue + data = line[6:] + if data == '[DONE]': + break + try: + chunk = json.loads(data) + delta = chunk['choices'][0]['delta'].get('content', '') + if delta: + full_text.append(delta) + # Escape newlines for SSE + safe = delta.replace('\n', '
') + yield f'data: {safe}\n\n' + except (json.JSONDecodeError, KeyError, IndexError): + continue + + # Store in DB + if full_text: + _save_insight( + content=''.join(full_text), + insight_type='chat_response', + prompt_summary=user_message[:500], + model=model, + ) + + yield 'data: [DONE]\n\n' + + except requests.exceptions.Timeout: + yield 'data: Request timed out. Please try again.\n\n' + yield 'data: [DONE]\n\n' + except requests.exceptions.HTTPError as e: + if e.response.status_code == 429: + yield 'data: Rate limit reached. Please wait a moment and try again.\n\n' + elif e.response.status_code == 401: + yield 'data: Invalid Groq API key. Please check your .env configuration.\n\n' + else: + yield f'data: API error ({e.response.status_code}). Please try again.\n\n' + yield 'data: [DONE]\n\n' + except Exception as e: + log.error(f'[ai] stream error: {e}') + yield 'data: AI assistant is temporarily unavailable.\n\n' + yield 'data: [DONE]\n\n' + + +# ── Daily insight (non-streaming) ───────────────────────────────────────────── + +def generate_daily_insight(): + """ + Generate and store a daily summary insight (called by cron script). + Returns the insight text or None on failure. + """ + api_key = current_app.config.get('GROQ_API_KEY', '') + model = current_app.config.get('GROQ_MODEL', 'llama-3.3-70b-versatile') + + if not api_key: + log.warning('[ai] GROQ_API_KEY not set, skipping daily insight') + return None + + today = date.today() + + # Don't regenerate if already done today + existing = AiInsight.query.filter_by( + insight_date=today, + insight_type='daily_summary' + ).first() + if existing: + return existing.content + + try: + context = build_context(days=30) + except Exception as e: + log.error(f'[ai] context build failed: {e}') + return None + + prompt = ( + "Based on this month's financial data, give me a brief daily summary " + "(3-5 sentences). Cover: spending vs income, any budget alerts, " + "and one actionable tip. Be specific with numbers." + ) + + try: + import requests + resp = requests.post( + 'https://api.groq.com/openai/v1/chat/completions', + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + }, + json={ + 'model': model, + 'messages': [ + {"role": "system", "content": "You are a concise personal finance assistant."}, + {"role": "system", "content": context}, + {"role": "user", "content": prompt}, + ], + 'max_tokens': 400, + 'stream': False, + }, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + content = data['choices'][0]['message']['content'] + tokens = data.get('usage', {}).get('total_tokens') + + _save_insight( + content=content, + insight_type='daily_summary', + prompt_summary='Daily auto-summary', + model=model, + tokens=tokens, + ) + return content + + except Exception as e: + log.error(f'[ai] daily insight failed: {e}') + return None + + +# ── Helper ──────────────────────────────────────────────────────────────────── + +def _save_insight(content, insight_type, prompt_summary=None, model=None, tokens=None): + try: + insight = AiInsight( + insight_date=date.today(), + insight_type=insight_type, + content=content, + prompt_summary=prompt_summary, + model_used=model, + tokens_used=tokens, + ) + db.session.add(insight) + db.session.commit() + except Exception as e: + db.session.rollback() + log.error(f'[ai] save insight failed: {e}') + + +def get_latest_daily_insight(): + """Return today's daily insight if it exists, else the most recent one.""" + today = date.today() + insight = AiInsight.query.filter_by( + insight_date=today, + insight_type='daily_summary' + ).first() + if not insight: + insight = AiInsight.query\ + .filter_by(insight_type='daily_summary')\ + .order_by(AiInsight.insight_date.desc())\ + .first() + return insight diff --git a/app/templates/ai/history.html b/app/templates/ai/history.html new file mode 100644 index 0000000..6bf3eb2 --- /dev/null +++ b/app/templates/ai/history.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}AI History{% endblock %} +{% block page_title %}AI History{% endblock %} + +{% block topbar_actions %} +Open Chat +{% endblock %} + +{% block content %} +
+ {% if insights.items %} + + + + + + + + + + + {% for item in insights.items %} + + + + + + + {% endfor %} + +
DateTypeContentTokens
+ {{ item.created_at.strftime('%b %d, %H:%M') }} + + + {{ item.insight_type | replace('_',' ') | title }} + + + {% if item.prompt_summary and item.insight_type == 'chat_response' %} +
{{ item.prompt_summary }}
+ {% endif %} +
{{ item.content | truncate(180) }}
+
+ {{ item.tokens_used or '—' }} +
+ + {% if insights.pages > 1 %} +
+ Page {{ insights.page }} of {{ insights.pages }} +
+ {% if insights.has_prev %} + ← Prev + {% endif %} + {% if insights.has_next %} + Next → + {% endif %} +
+
+ {% endif %} + + {% else %} +
+ +

No AI history yet. Start a conversation.

+ Open Chat +
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/ai/index.html b/app/templates/ai/index.html new file mode 100644 index 0000000..bc6d8e7 --- /dev/null +++ b/app/templates/ai/index.html @@ -0,0 +1,239 @@ +{% extends "base.html" %} +{% block title %}AI Assistant{% endblock %} +{% block page_title %}AI Assistant{% endblock %} + +{% block extra_css %} +#chatMessages { height: 440px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 16px; scroll-behavior: smooth; } +.msg-user { align-self: flex-end; max-width: 75%; background: #3b82f6; color: #fff; border-radius: 16px 16px 4px 16px; padding: 10px 14px; font-size: 13.5px; line-height: 1.5; } +.msg-ai { align-self: flex-start; max-width: 82%; background: #1e293b; color: #e2e8f0; border-radius: 16px 16px 16px 4px; padding: 10px 14px; font-size: 13.5px; line-height: 1.6; } +.msg-ai.streaming { border-left: 2px solid #818cf8; } +.msg-system { align-self: center; font-size: 12px; color: var(--muted); font-style: italic; } +.suggestion-btn { background: #f1f5f9; border: 1px solid var(--border); border-radius: 20px; padding: 6px 14px; font-size: 12px; color: var(--text); cursor: pointer; transition: all .15s; white-space: nowrap; } +.suggestion-btn:hover { background: #e2e8f0; border-color: #94a3b8; } +.typing-dot { width: 6px; height: 6px; border-radius: 50%; background: #818cf8; display: inline-block; animation: bounce .8s infinite; } +.typing-dot:nth-child(2) { animation-delay: .15s; } +.typing-dot:nth-child(3) { animation-delay: .3s; } +@keyframes bounce { 0%,60%,100% { transform: translateY(0); } 30% { transform: translateY(-6px); } } +{% endblock %} + +{% block topbar_actions %} +History +{% endblock %} + +{% block content %} +
+ +
+
+ +
+
+ +
+
+
Finance AI
+
Powered by Groq · Context: last 90 days
+
+
+ + +
+
Ask me anything about your finances.
+ {% if recent_chats %} + {% for chat in recent_chats|reverse %} +
{{ chat.prompt_summary }}
+
{{ chat.content | replace('\n', '
') | safe }}
+ {% endfor %} + {% endif %} +
+ + +
+
+ + +
+
Enter to send · Shift+Enter for new line
+
+
+
+ + +
+ + + {% if latest_insight %} +
+
+ + Daily Insight + + {{ latest_insight.insight_date.strftime('%b %d') }} +
+

{{ latest_insight.content }}

+
+ + +
+
+ {% else %} +
+
Daily Insight
+

No insight generated yet.

+
+ + +
+
+ {% endif %} + + +
+
Suggested Questions
+
+ {% set suggestions = [ + "Where did I overspend this month?", + "How is my budget looking?", + "Am I on track for my goals?", + "What's my biggest expense category?", + "Summarize my finances", + "How can I save more?", + "What's my net worth trend?", + "Review my investments", + ] %} + {% for s in suggestions %} + + {% endfor %} +
+
+ + +
+
Tips
+
    +
  • AI sees your last 90 days of transactions
  • +
  • Budget alerts and goal progress are included
  • +
  • No personal names are sent to the AI
  • +
  • Daily insights auto-generate at midnight
  • +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index cc94741..ee13e36 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -205,7 +205,7 @@
AI
- + AI Assistant diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html index de846eb..0a8332b 100644 --- a/app/templates/dashboard/index.html +++ b/app/templates/dashboard/index.html @@ -139,6 +139,32 @@ + + +{% if ai_insight %} +
+
+
+ + AI Daily Insight + {{ ai_insight.insight_date.strftime('%b %d') }} +
+ Ask AI → +
+

{{ ai_insight.content }}

+
+{% else %} +
+
+
+ + AI daily insight not yet generated. +
+ Open AI +
+
+{% endif %} +
diff --git a/scripts/daily_ai_insight.py b/scripts/daily_ai_insight.py new file mode 100644 index 0000000..d345276 --- /dev/null +++ b/scripts/daily_ai_insight.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +""" +Cron script: generate daily AI financial insight via Groq. +Run by systemd timer pfm-aiinsight.timer at midnight daily. +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import create_app +from app.services.ai_service import generate_daily_insight + +app = create_app() + +if __name__ == '__main__': + with app.app_context(): + print('[ai_insight] Generating daily insight...') + content = generate_daily_insight() + if content: + preview = content[:200].replace('\n', ' ') + print(f'[ai_insight] Done: {preview}...') + else: + print('[ai_insight] Failed or skipped (already generated today).') + sys.exit(1)