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.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 (
|
||||
|
||||
@@ -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.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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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