05/31 Phase 5
This commit is contained in:
@@ -26,6 +26,7 @@ def create_app(config_name=None):
|
|||||||
from app.routes.budgets import budgets_bp
|
from app.routes.budgets import budgets_bp
|
||||||
from app.routes.goals import goals_bp
|
from app.routes.goals import goals_bp
|
||||||
from app.routes.investments import investments_bp
|
from app.routes.investments import investments_bp
|
||||||
|
from app.routes.ai import ai_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
@@ -35,6 +36,7 @@ def create_app(config_name=None):
|
|||||||
app.register_blueprint(budgets_bp)
|
app.register_blueprint(budgets_bp)
|
||||||
app.register_blueprint(goals_bp)
|
app.register_blueprint(goals_bp)
|
||||||
app.register_blueprint(investments_bp)
|
app.register_blueprint(investments_bp)
|
||||||
|
app.register_blueprint(ai_bp)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -6,6 +6,7 @@ from app.models.account import Account
|
|||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.services.fx_service import get_today_rate, get_rate_history
|
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 app.services.account_service import get_total_assets, get_total_liabilities
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
import calendar
|
import calendar
|
||||||
@@ -127,6 +128,9 @@ def index():
|
|||||||
'rates': [float(r.usd_to_vnd) for r in fx_history],
|
'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',
|
return render_template('dashboard/index.html',
|
||||||
period=period,
|
period=period,
|
||||||
period_label=period_label,
|
period_label=period_label,
|
||||||
@@ -145,7 +149,8 @@ def index():
|
|||||||
chart_expense=chart_expense,
|
chart_expense=chart_expense,
|
||||||
recent_txns=recent_txns,
|
recent_txns=recent_txns,
|
||||||
fx=fx,
|
fx=fx,
|
||||||
fx_history_data=fx_history_data)
|
fx_history_data=fx_history_data,
|
||||||
|
ai_insight=ai_insight)
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/api/fx-history')
|
@dashboard_bp.route('/api/fx-history')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}AI History{% endblock %}
|
||||||
|
{% block page_title %}AI History{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar_actions %}
|
||||||
|
<a href="{{ url_for('ai.index') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-stars me-1"></i>Open Chat</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="pcard p-0">
|
||||||
|
{% if insights.items %}
|
||||||
|
<table class="pfm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="padding-left:20px;">Date</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Content</th>
|
||||||
|
<th class="d-none d-md-table-cell text-end" style="padding-right:20px;">Tokens</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in insights.items %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">
|
||||||
|
{{ item.created_at.strftime('%b %d, %H:%M') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="font-size:11px;
|
||||||
|
{% if item.insight_type == 'daily_summary' %}background:#ede9fe;color:#5b21b6;
|
||||||
|
{% elif item.insight_type == 'chat_response' %}background:#dbeafe;color:#1e40af;
|
||||||
|
{% else %}background:#f1f5f9;color:#64748b;{% endif %}">
|
||||||
|
{{ item.insight_type | replace('_',' ') | title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.prompt_summary and item.insight_type == 'chat_response' %}
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-bottom:3px;"><i class="bi bi-person me-1"></i>{{ item.prompt_summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-size:13px;color:var(--text);">{{ item.content | truncate(180) }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell text-end mono" style="font-size:12px;color:var(--muted);padding-right:20px;">
|
||||||
|
{{ item.tokens_used or '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if insights.pages > 1 %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
|
||||||
|
<span>Page {{ insights.page }} of {{ insights.pages }}</span>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
{% if insights.has_prev %}
|
||||||
|
<a href="{{ url_for('ai.history', page=insights.prev_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if insights.has_next %}
|
||||||
|
<a href="{{ url_for('ai.history', page=insights.next_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-stars text-muted" style="font-size:2.5rem;"></i>
|
||||||
|
<p class="text-muted mt-2">No AI history yet. Start a conversation.</p>
|
||||||
|
<a href="{{ url_for('ai.index') }}" class="btn btn-sm btn-primary">Open Chat</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<a href="{{ url_for('ai.history') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-clock-history me-1"></i>History</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Chat panel -->
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="pcard p-0 d-flex flex-column" style="height:600px;background:#0f172a;border-color:#1e293b;">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="padding:14px 16px;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="width:32px;height:32px;border-radius:8px;background:#312e81;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<i class="bi bi-stars" style="color:#818cf8;font-size:15px;"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#f1f5f9;">Finance AI</div>
|
||||||
|
<div style="font-size:11px;color:#475569;" id="statusLine">Powered by Groq · Context: last 90 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<div id="chatMessages">
|
||||||
|
<div class="msg-system">Ask me anything about your finances.</div>
|
||||||
|
{% if recent_chats %}
|
||||||
|
{% for chat in recent_chats|reverse %}
|
||||||
|
<div class="msg-user">{{ chat.prompt_summary }}</div>
|
||||||
|
<div class="msg-ai">{{ chat.content | replace('\n', '<br>') | safe }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div style="padding:12px 16px;border-top:1px solid #1e293b;margin-top:auto;">
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-end;">
|
||||||
|
<textarea id="chatInput" rows="2"
|
||||||
|
style="flex:1;background:#1e293b;border:1px solid #334155;border-radius:10px;color:#f1f5f9;font-size:13.5px;padding:10px 14px;resize:none;font-family:'DM Sans',sans-serif;outline:none;transition:border .2s;"
|
||||||
|
placeholder="Ask about your spending, budgets, goals…"
|
||||||
|
onfocus="this.style.borderColor='#818cf8'" onblur="this.style.borderColor='#334155'"></textarea>
|
||||||
|
<button id="sendBtn" onclick="sendMessage()"
|
||||||
|
style="width:40px;height:40px;border-radius:10px;background:#4f46e5;border:none;color:#fff;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s;">
|
||||||
|
<i class="bi bi-send"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#334155;margin-top:6px;">Enter to send · Shift+Enter for new line</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: daily insight + suggestions -->
|
||||||
|
<div class="col-12 col-lg-4 d-flex flex-column gap-3">
|
||||||
|
|
||||||
|
<!-- Daily insight -->
|
||||||
|
{% if latest_insight %}
|
||||||
|
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#818cf8;">
|
||||||
|
<i class="bi bi-stars me-1"></i>Daily Insight
|
||||||
|
</span>
|
||||||
|
<span style="font-size:10px;color:#475569;">{{ latest_insight.insight_date.strftime('%b %d') }}</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:13px;color:#94a3b8;line-height:1.6;margin:0;">{{ latest_insight.content }}</p>
|
||||||
|
<form method="POST" action="{{ url_for('ai.trigger_insight') }}" class="mt-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" style="font-size:11px;background:none;border:none;color:#475569;cursor:pointer;padding:0;" title="Regenerate today's insight">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Regenerate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
|
||||||
|
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#475569;margin-bottom:8px;">Daily Insight</div>
|
||||||
|
<p style="font-size:13px;color:#64748b;margin-bottom:10px;">No insight generated yet.</p>
|
||||||
|
<form method="POST" action="{{ url_for('ai.trigger_insight') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm" style="background:#312e81;color:#818cf8;border:none;font-size:12px;">
|
||||||
|
<i class="bi bi-stars me-1"></i>Generate Now
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Suggested questions -->
|
||||||
|
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
|
||||||
|
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#475569;margin-bottom:10px;">Suggested Questions</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% 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 %}
|
||||||
|
<button class="suggestion-btn" onclick="fillSuggestion(this.textContent)">{{ s }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="pcard" style="background:#0f172a;border-color:#1e293b;">
|
||||||
|
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#475569;margin-bottom:8px;">Tips</div>
|
||||||
|
<ul style="font-size:12px;color:#64748b;padding-left:16px;margin:0;line-height:1.8;">
|
||||||
|
<li>AI sees your last 90 days of transactions</li>
|
||||||
|
<li>Budget alerts and goal progress are included</li>
|
||||||
|
<li>No personal names are sent to the AI</li>
|
||||||
|
<li>Daily insights auto-generate at midnight</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const chatMessages = document.getElementById('chatMessages');
|
||||||
|
const chatInput = document.getElementById('chatInput');
|
||||||
|
const sendBtn = document.getElementById('sendBtn');
|
||||||
|
const statusLine = document.getElementById('statusLine');
|
||||||
|
|
||||||
|
// Enter to send
|
||||||
|
chatInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fillSuggestion(text) {
|
||||||
|
chatInput.value = text;
|
||||||
|
chatInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollBottom() {
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(text, type) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'msg-' + type;
|
||||||
|
if (type === 'ai') div.innerHTML = text;
|
||||||
|
else div.textContent = text;
|
||||||
|
chatMessages.appendChild(div);
|
||||||
|
scrollBottom();
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const msg = chatInput.value.trim();
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
chatInput.value = '';
|
||||||
|
chatInput.disabled = true;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
||||||
|
|
||||||
|
// User bubble
|
||||||
|
addMessage(msg, 'user');
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
const typingDiv = document.createElement('div');
|
||||||
|
typingDiv.className = 'msg-ai';
|
||||||
|
typingDiv.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
|
||||||
|
chatMessages.appendChild(typingDiv);
|
||||||
|
scrollBottom();
|
||||||
|
|
||||||
|
statusLine.textContent = 'Thinking…';
|
||||||
|
|
||||||
|
// SSE stream
|
||||||
|
const url = '/ai/stream?message=' + encodeURIComponent(msg);
|
||||||
|
const evtSource = new EventSource(url);
|
||||||
|
let aiDiv = null;
|
||||||
|
let buffer = '';
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
evtSource.onmessage = function(e) {
|
||||||
|
if (e.data === '[DONE]') {
|
||||||
|
evtSource.close();
|
||||||
|
chatInput.disabled = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.innerHTML = '<i class="bi bi-send"></i>';
|
||||||
|
statusLine.textContent = 'Powered by Groq · Context: last 90 days';
|
||||||
|
chatInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
typingDiv.remove();
|
||||||
|
aiDiv = document.createElement('div');
|
||||||
|
aiDiv.className = 'msg-ai streaming';
|
||||||
|
chatMessages.appendChild(aiDiv);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace <br> back to newlines for rendering
|
||||||
|
const chunk = e.data.replace(/<br>/g, '\n');
|
||||||
|
buffer += chunk;
|
||||||
|
aiDiv.innerHTML = buffer.replace(/\n/g, '<br>');
|
||||||
|
aiDiv.classList.remove('streaming');
|
||||||
|
scrollBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
evtSource.onerror = function() {
|
||||||
|
evtSource.close();
|
||||||
|
typingDiv.remove();
|
||||||
|
if (!aiDiv) {
|
||||||
|
addMessage('Connection error. Please try again.', 'system');
|
||||||
|
}
|
||||||
|
chatInput.disabled = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.innerHTML = '<i class="bi bi-send"></i>';
|
||||||
|
statusLine.textContent = 'Powered by Groq · Context: last 90 days';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom on load
|
||||||
|
scrollBottom();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="sb-section">AI</div>
|
<div class="sb-section">AI</div>
|
||||||
<a href="#" class="sb-link">
|
<a href="{{ url_for('ai.index') }}" class="sb-link {% if request.blueprint == 'ai' %}active{% endif %}">
|
||||||
<i class="bi bi-stars"></i><span class="lt">AI Assistant</span>
|
<i class="bi bi-stars"></i><span class="lt">AI Assistant</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -139,6 +139,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- AI Daily Insight -->
|
||||||
|
{% if ai_insight %}
|
||||||
|
<div class="pcard mb-4" style="background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);border-color:#334155;color:#f1f5f9;">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-stars" style="color:#818cf8;font-size:16px;"></i>
|
||||||
|
<span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#818cf8;">AI Daily Insight</span>
|
||||||
|
<span style="font-size:10px;color:#475569;">{{ ai_insight.insight_date.strftime('%b %d') }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('ai.index') }}" style="font-size:12px;color:#818cf8;">Ask AI →</a>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:13px;color:#cbd5e1;line-height:1.6;margin:0;">{{ ai_insight.content }}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pcard mb-4" style="background:#0f172a;border-color:#1e293b;color:#94a3b8;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-stars" style="color:#475569;font-size:16px;"></i>
|
||||||
|
<span style="font-size:13px;">AI daily insight not yet generated.</span>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('ai.index') }}" class="btn btn-sm" style="font-size:12px;background:#1e293b;color:#818cf8;border:1px solid #334155;">Open AI</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Accounts + Recent Transactions -->
|
<!-- Accounts + Recent Transactions -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user