05/31 Phase 5
This commit is contained in:
@@ -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: <chunk>\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', '<br>')
|
||||
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
|
||||
Reference in New Issue
Block a user