05/31 Phase 5

This commit is contained in:
2026-05-31 16:49:18 -04:00
parent 9c846159ef
commit b3d495e57c
9 changed files with 793 additions and 2 deletions
+2
View File
@@ -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 (
+65
View File
@@ -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 -1
View File
@@ -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')
+358
View File
@@ -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
+71
View File
@@ -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 %}
+239
View File
@@ -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 %}
+1 -1
View File
@@ -205,7 +205,7 @@
</a>
<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>
</a>
</div>
+26
View File
@@ -139,6 +139,32 @@
</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 -->
<div class="row g-3">
<div class="col-12 col-lg-4">
+25
View File
@@ -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)