Private Spending Auditor
Upload a bank or card statement CSV and get a full spending audit — automatic categorization, forgotten-subscription detection, hidden-fee hunting, charts, and AI savings recommendations with follow-up Q&A. 100% in-browser: your financial data never leaves your device.
Preview Mode
This is a preview with sample data. The template uses placeholders like
which will be replaced with actual agent data.
About This Template
Private Spending Auditor is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.
Template Metadata
- Slug
- private-spending-auditor
- Created By
- ozzo
- Created
- Jun 09, 2026
- Usage Count
- 1
Tags
Code Statistics
- HTML Lines
- 130
- CSS Lines
- 227
- JS Lines
- 303
- Python Lines
- 424
Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{ agent_name }}</title>
<style>{{ css_code }}</style>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<div class="brand-badge">🔍</div>
<div class="brand-text">
<h1>{{ agent_name }}</h1>
<p class="tagline">{{ description }}</p>
</div>
<div class="privacy-pill">🔒 100% in-browser — your statement never leaves this device</div>
</header>
<div id="engine-status" class="engine-status">⏳ Starting the in-browser Python engine…</div>
<section class="upload-row">
<div class="card upload-card">
<h3>📄 Load your statement</h3>
<div id="upload-area" class="upload-area">
<div class="upload-icon">📤</div>
<p class="upload-text">Drag & drop your bank CSV here<br>or click to browse</p>
<p class="upload-hint">CSV export from any bank · processed locally, never uploaded</p>
</div>
<input type="file" id="csv-file-input" accept=".csv,.txt" hidden />
<div class="upload-actions">
<button id="paste-toggle-btn" class="btn-secondary" type="button">📋 Paste CSV text</button>
<button id="sample-btn" class="btn-secondary" type="button">✨ Try sample data</button>
</div>
<div id="paste-panel" class="paste-panel hidden">
<textarea id="csv-text-input" rows="6" placeholder="Date,Description,Amount 2026-03-01,COFFEE SHOP,-4.50"></textarea>
<button id="load-pasted-btn" class="btn-primary" type="button">Analyze pasted CSV</button>
</div>
<p id="file-status" class="file-status"></p>
</div>
<div class="card howto-card">
<h3>🏦 How to get your CSV</h3>
<ol>
<li>Log in to your online banking</li>
<li>Open the account or card history</li>
<li>Choose <strong>Export</strong> / <strong>Download</strong> → CSV</li>
<li>Drop the file here — that's it</li>
</ol>
<p class="howto-note">Works with most bank and card exports: single amount column,
separate debit/credit columns, and European number formats are all detected automatically.</p>
</div>
</section>
<section id="welcome-state" class="welcome-state">
<h2>Find the money leaks hiding in your statement</h2>
<div class="feature-grid">
<div class="feature-item">🔁 <strong>Subscription radar</strong><span>Spots recurring charges you forgot about — with their true yearly cost</span></div>
<div class="feature-item">⚠️ <strong>Fee hunter</strong><span>Flags overdraft, maintenance and ATM fees automatically</span></div>
<div class="feature-item">🗂 <strong>Auto-categorization</strong><span>Groceries, dining, transport, bills — sorted instantly with charts</span></div>
<div class="feature-item">🤖 <strong>AI savings audit</strong><span>Personalized recommendations, plus follow-up questions in plain language</span></div>
</div>
</section>
<section id="dashboard" class="hidden">
<div class="stats-row">
<div class="stat-card"><span class="stat-label">Period</span><span class="stat-value small" id="stat-range">—</span></div>
<div class="stat-card"><span class="stat-label">Total spent</span><span class="stat-value spend" id="stat-spent">—</span></div>
<div class="stat-card"><span class="stat-label">Total income</span><span class="stat-value income" id="stat-income">—</span></div>
<div class="stat-card"><span class="stat-label">Subscriptions</span><span class="stat-value subs" id="stat-subs">—</span></div>
<div class="stat-card"><span class="stat-label">Bank fees</span><span class="stat-value fees" id="stat-fees">—</span></div>
</div>
<div class="main-grid">
<div class="col-main">
<div class="card">
<h3>📊 Where the money went</h3>
<div id="category-chart" class="chart-box"></div>
<div id="trend-chart" class="chart-box"></div>
</div>
<div class="card">
<h3>🗂 Spending by category</h3>
<table class="category-table">
<tbody id="category-table-body"></tbody>
</table>
</div>
</div>
<div class="col-side">
<div class="card">
<h3>🔁 Recurring & subscriptions <span class="card-badge" id="subs-total"></span></h3>
<div id="subs-list"></div>
</div>
<div class="card">
<h3>⚠️ Fees & charges <span class="card-badge" id="fees-total"></span></h3>
<div id="fees-list"></div>
</div>
</div>
</div>
<div class="card audit-card">
<div class="audit-head">
<h3>🤖 AI audit & questions</h3>
<button id="audit-btn" class="btn-primary" type="button">🚀 Run Full Audit</button>
</div>
<div class="chips">
<button class="suggestion-chip" type="button" data-q="Which of my subscriptions should I consider cancelling and how much would I save per year?">Which subscriptions should I cancel?</button>
<button class="suggestion-chip" type="button" data-q="How much did I spend on dining and food delivery, and how does it compare to my groceries?">Dining vs groceries?</button>
<button class="suggestion-chip" type="button" data-q="What bank fees am I paying and how can I avoid them?">How do I avoid these fees?</button>
</div>
<div id="results-container" class="results-container">
<p class="chat-empty">Run the full audit, tap a suggestion, or ask anything about your statement.</p>
</div>
<div class="chat-row">
<input id="chat-input" type="text" placeholder="e.g. How much did I spend at Amazon?" />
<button id="chat-send" class="btn-primary" type="button">Send</button>
</div>
</div>
</section>
<div id="loading-overlay" class="loading-overlay hidden">
<div class="spinner"></div>
<p class="loading-text" id="loading-text">Analyzing your statement locally…</p>
</div>
</div>
<script>{{ js_code }}</script>
</body>
</html>
:root {
/* Trust navy + premium gold (finance design system) */
--primary: #1E3A8A;
--primary-deep: #0F172A;
--accent: #CA8A04;
--bg: #F8FAFC;
--surface: #FFFFFF;
--text: #020617;
--text-muted: #64748B;
--border: #E2E8F0;
--danger: #DC2626;
--danger-bg: #FEF2F2;
--success: #15803D;
--success-bg: #F0FDF4;
--radius: 14px;
--shadow: 0 1px 3px rgba(2, 6, 23, 0.08);
--shadow-lg: 0 12px 28px rgba(2, 6, 23, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Lexend', 'Source Sans 3', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(160deg, #F1F5F9 0%, var(--bg) 40%);
color: var(--text);
line-height: 1.55;
}
.hidden { display: none !important; }
.app-shell { max-width: 1280px; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; }
/* ── Header ── */
.app-header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.brand-badge {
width: 54px; height: 54px; font-size: 1.6rem; flex: none;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--primary-deep), var(--primary));
border-radius: var(--radius); box-shadow: var(--shadow-lg);
}
.brand-text { flex: 1; min-width: 240px; }
.app-header h1 { margin: 0; font-size: 1.55rem; font-weight: 700; letter-spacing: -0.02em; color: var(--primary-deep); }
.tagline { margin: 0.15rem 0 0; color: var(--text-muted); font-size: 0.92rem; max-width: 60ch; }
.privacy-pill {
background: var(--success-bg); color: var(--success); border: 1px solid #BBF7D0;
font-size: 0.8rem; font-weight: 600; padding: 0.45rem 0.8rem; border-radius: 999px;
}
.engine-status {
font-size: 0.82rem; color: var(--text-muted); background: var(--surface);
border: 1px dashed var(--border); border-radius: 8px;
padding: 0.45rem 0.75rem; margin-bottom: 1.25rem;
}
.engine-status.ready { border-style: solid; color: var(--success); background: var(--success-bg); }
/* ── Cards ── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.25rem;
}
.card h3 { margin: 0 0 1rem; font-size: 1rem; font-weight: 700; color: var(--primary-deep); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.card-badge { font-size: 0.75rem; font-weight: 600; color: var(--accent); margin-left: auto; }
/* ── Upload row ── */
.upload-row { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1.25rem; margin-bottom: 1.5rem; }
.upload-area {
border: 2px dashed var(--border); border-radius: var(--radius);
background: var(--bg); text-align: center; padding: 1.75rem 1rem;
cursor: pointer; transition: border-color 0.2s, background 0.2s;
}
.upload-area:hover, .upload-area.drag-over { border-color: var(--primary); background: #EFF6FF; }
.upload-icon { font-size: 2.2rem; }
.upload-text { margin: 0.5rem 0 0.25rem; font-weight: 600; }
.upload-hint { margin: 0; font-size: 0.78rem; color: var(--text-muted); }
.upload-actions { display: flex; gap: 0.6rem; margin-top: 0.9rem; flex-wrap: wrap; }
.paste-panel { margin-top: 0.9rem; display: flex; flex-direction: column; gap: 0.6rem; }
.paste-panel textarea {
width: 100%; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem; border: 1px solid var(--border); border-radius: 8px; padding: 0.7rem;
}
.paste-panel textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(30, 58, 138, 0.12); }
.file-status { margin: 0.75rem 0 0; font-size: 0.85rem; color: var(--success); min-height: 1.2em; }
.file-status.error { color: var(--danger); }
.howto-card ol { margin: 0 0 0.75rem; padding-left: 1.25rem; font-size: 0.9rem; }
.howto-card li { margin-bottom: 0.35rem; }
.howto-note { margin: 0; font-size: 0.8rem; color: var(--text-muted); }
/* ── Buttons ── */
.btn-primary, .btn-secondary {
border: none; border-radius: 9px; font: inherit; font-weight: 600; font-size: 0.88rem;
padding: 0.65rem 1.1rem; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-deep); transform: translateY(-1px); box-shadow: var(--shadow-lg); }
.btn-secondary { background: var(--bg); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover:not(:disabled) { background: #E2E8F0; }
.btn-primary:disabled, .btn-secondary:disabled { opacity: 0.55; cursor: not-allowed; }
/* ── Welcome ── */
.welcome-state { text-align: center; padding: 2.5rem 1rem 1rem; }
.welcome-state h2 { font-size: 1.5rem; letter-spacing: -0.02em; color: var(--primary-deep); margin: 0 0 1.5rem; }
.feature-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; max-width: 1000px; margin: 0 auto; }
.feature-item {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 1.1rem; font-size: 1.3rem;
display: flex; flex-direction: column; gap: 0.35rem; align-items: center; text-align: center;
}
.feature-item strong { font-size: 0.95rem; color: var(--primary-deep); }
.feature-item span { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; }
/* ── Stats ── */
.stats-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1rem; margin-bottom: 1.25rem; }
.stat-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.25rem;
}
.stat-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); font-weight: 600; }
.stat-value { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; color: var(--primary-deep); }
.stat-value.small { font-size: 0.95rem; }
.stat-value.spend { color: var(--danger); }
.stat-value.income { color: var(--success); }
.stat-value.subs { color: var(--accent); }
.stat-value.fees { color: var(--danger); }
/* ── Main grid ── */
.main-grid { display: grid; grid-template-columns: 1fr 400px; gap: 1.25rem; margin-bottom: 1.25rem; }
.col-main, .col-side { display: flex; flex-direction: column; gap: 1.25rem; }
.chart-box { margin-bottom: 0.75rem; text-align: center; }
.chart-box img { border-radius: 8px; }
.category-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.category-table td { padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); }
.category-table tr:last-child td { border-bottom: none; }
.category-table .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
.category-table .muted { color: var(--text-muted); font-weight: 400; }
.bar-cell { width: 38%; }
.bar { background: var(--bg); border-radius: 4px; height: 8px; overflow: hidden; }
.bar-fill { background: linear-gradient(90deg, var(--primary), var(--accent)); height: 100%; border-radius: 4px; transition: width 0.5s ease; }
/* ── Subscriptions & fees ── */
.sub-item {
display: flex; justify-content: space-between; align-items: center; gap: 0.75rem;
padding: 0.65rem 0.75rem; border: 1px solid var(--border); border-left: 4px solid var(--accent);
border-radius: 8px; margin-bottom: 0.55rem; background: #FFFBEB;
}
.sub-item:last-child { margin-bottom: 0; }
.sub-meta { display: block; font-size: 0.75rem; color: var(--text-muted); }
.sub-cost { font-weight: 700; color: var(--accent); white-space: nowrap; font-variant-numeric: tabular-nums; }
.fee-item {
display: flex; justify-content: space-between; gap: 0.75rem; font-size: 0.85rem;
padding: 0.55rem 0.75rem; border-radius: 8px; margin-bottom: 0.5rem;
background: var(--danger-bg); border-left: 4px solid var(--danger);
}
.fee-item:last-child { margin-bottom: 0; }
.fee-item strong { color: var(--danger); font-variant-numeric: tabular-nums; white-space: nowrap; }
.empty-note { margin: 0; font-size: 0.88rem; color: var(--text-muted); }
/* ── Audit chat ── */
.audit-card { margin-bottom: 1rem; }
.audit-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.audit-head h3 { margin: 0; }
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.9rem 0; }
.suggestion-chip {
background: #EFF6FF; color: var(--primary); border: 1px solid #BFDBFE; border-radius: 999px;
font: inherit; font-size: 0.8rem; font-weight: 600; padding: 0.4rem 0.85rem; cursor: pointer;
transition: background 0.15s;
}
.suggestion-chip:hover { background: #DBEAFE; }
.results-container {
min-height: 140px; max-height: 420px; overflow-y: auto;
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
padding: 0.9rem; margin-bottom: 0.75rem;
}
.chat-empty { margin: 2.2rem 0; text-align: center; color: var(--text-muted); font-size: 0.88rem; }
.message { display: flex; gap: 0.6rem; margin-bottom: 0.8rem; }
.message:last-child { margin-bottom: 0; }
.message-icon { flex: none; font-size: 1.1rem; }
.message-content {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 0.6rem 0.85rem; font-size: 0.88rem; max-width: 85%; overflow-wrap: anywhere;
}
.message-user .message-content { background: #EFF6FF; border-color: #BFDBFE; }
.message-content code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; font-size: 0.82em; }
.message-content ul { margin: 0.4rem 0; padding-left: 1.2rem; }
.chat-row { display: flex; gap: 0.6rem; }
.chat-row input {
flex: 1; font: inherit; font-size: 0.9rem; padding: 0.65rem 0.9rem;
border: 1px solid var(--border); border-radius: 9px;
}
.chat-row input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(30, 58, 138, 0.12); }
/* ── Loading overlay ── */
.loading-overlay {
position: fixed; inset: 0; z-index: 2000; backdrop-filter: blur(3px);
background: rgba(2, 6, 23, 0.45); display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 1rem;
}
.spinner {
width: 46px; height: 46px; border-radius: 50%;
border: 4px solid rgba(255, 255, 255, 0.3); border-top-color: #fff;
animation: psa-spin 0.9s linear infinite;
}
@keyframes psa-spin { to { transform: rotate(360deg); } }
.loading-text { color: #fff; font-weight: 600; }
/* ── Focus & motion ── */
button:focus-visible, input:focus-visible, textarea:focus-visible, .upload-area:focus-visible {
outline: 3px solid rgba(202, 138, 4, 0.55); outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* ── Responsive ── */
@media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: repeat(3, 1fr); }
.feature-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 720px) {
.upload-row { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: repeat(2, 1fr); }
.privacy-pill { width: 100%; text-align: center; }
.message-content { max-width: 100%; }
.btn-primary, .btn-secondary { padding: 0.75rem 1.1rem; }
}
// ════════════════════════════════════════════════════════════════════
// Private Spending Auditor — template JS
// Relies on injected globals: window.pyodide, window.PROVIDER,
// window.agentManager (local), process_user_query (Python).
// ════════════════════════════════════════════════════════════════════
let pyReady = false;
let dataLoaded = false;
let busy = false;
const $ = (id) => document.getElementById(id);
const resultsEl = () => $('results-container');
const SAMPLE_CSV = [
'Date,Description,Amount',
'2026-03-01,ACME CORP PAYROLL,3200.00',
'2026-03-02,NETFLIX.COM 4029,-15.49',
'2026-03-03,STARBUCKS #1234,-6.40',
'2026-03-04,UBER EATS 8821,-28.75',
'2026-03-05,SHELL GAS STATION 99,-52.10',
'2026-03-07,WHOLE FOODS MARKET,-84.33',
'2026-03-08,MONTHLY MAINTENANCE FEE,-12.00',
'2026-03-10,SPOTIFY AB,-10.99',
'2026-03-11,AMAZON MKTPL 1Z99,-43.50',
'2026-03-15,PLANET FITNESS,-24.99',
'2026-03-18,CVS PHARMACY,-18.20',
'2026-03-22,STARBUCKS #1234,-7.15',
'2026-04-01,ACME CORP PAYROLL,3200.00',
'2026-04-02,NETFLIX.COM 4029,-15.49',
'2026-04-05,DOORDASH ORDER 7741,-31.60',
'2026-04-08,MONTHLY MAINTENANCE FEE,-12.00',
'2026-04-10,SPOTIFY AB,-10.99',
'2026-04-12,AMAZON MKTPL 1Z99,-67.20',
'2026-04-15,PLANET FITNESS,-24.99',
'2026-04-18,COMCAST INTERNET,-79.99',
'2026-04-20,OVERDRAFT FEE,-35.00',
'2026-04-25,TRADER JOES #88,-91.40',
'2026-05-01,ACME CORP PAYROLL,3200.00',
'2026-05-02,NETFLIX.COM 4029,-15.49',
'2026-05-08,MONTHLY MAINTENANCE FEE,-12.00',
'2026-05-10,SPOTIFY AB,-10.99',
'2026-05-12,STEAM PURCHASE,-29.99',
'2026-05-15,PLANET FITNESS,-24.99',
'2026-05-18,COMCAST INTERNET,-79.99',
'2026-05-21,ATM CHARGE,-3.50',
].join('\n');
// ── Readiness ────────────────────────────────────────────────────────
document.addEventListener('pyodide-ready', () => { pyReady = true; updateEngineStatus(); });
function waitForPyodide(timeoutMs) {
return new Promise((resolve, reject) => {
const start = Date.now();
(function poll() {
if (window.pyodide && (pyReady || window.pyodideReady)) return resolve();
if (Date.now() - start > (timeoutMs || 90000)) {
return reject(new Error('The Python engine did not start. Reload the page and try again.'));
}
setTimeout(poll, 300);
})();
});
}
function ensureModel() {
if (window.PROVIDER !== 'local') return true;
if (window.agentManager && window.agentManager.isLoaded) return true;
window.addMessage('assistant',
'Please load an AI model first — pick one in the bar at the top of the page and press **Load Model**. ' +
'Your statement analysis below already works without it.');
return false;
}
function updateEngineStatus() {
const el = $('engine-status');
if (!el) return;
if (pyReady || window.pyodideReady) {
el.textContent = window.PROVIDER === 'local'
? '✅ Analysis engine ready — load an AI model (top bar) to unlock the audit chat'
: '✅ Analysis engine ready';
el.classList.add('ready');
} else {
el.textContent = '⏳ Starting the in-browser Python engine… (first load takes a moment)';
}
}
// ── Helpers ──────────────────────────────────────────────────────────
function escapeHtml(t) {
return String(t ?? '').replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
function renderMarkdownLite(text) {
let h = escapeHtml(text);
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/^\s*[-•]\s+(.+)$/gm, '<li>$1</li>');
h = h.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
h = h.replace(/\n/g, '<br>');
return h;
}
function money(n) {
const v = Number(n) || 0;
return v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function setOverlay(show, text) {
$('loading-overlay').classList.toggle('hidden', !show);
if (text) $('loading-text').textContent = text;
}
function setBusy(state) {
busy = state;
['audit-btn', 'chat-send'].forEach((id) => { const b = $(id); if (b) b.disabled = state; });
const btn = $('audit-btn');
if (btn) btn.innerHTML = state ? '⏳ Thinking…' : '🚀 Run Full Audit';
}
// ── Chat surface (window.addMessage is used by the wllama bridge too) ─
window.addMessage = function (type, content) {
const wrap = resultsEl();
if (!wrap) return null;
const empty = wrap.querySelector('.chat-empty');
if (empty) empty.remove();
const div = document.createElement('div');
div.className = 'message message-' + type;
div.innerHTML =
'<div class="message-icon">' + (type === 'user' ? '🧑' : '🔍') + '</div>' +
'<div class="message-content">' + renderMarkdownLite(String(content)) + '</div>';
wrap.appendChild(div);
wrap.scrollTop = wrap.scrollHeight;
return div;
};
async function askAgent(query, displayText) {
if (busy) return;
if (!(pyReady || window.pyodideReady)) {
window.addMessage('assistant', 'The engine is still starting — try again in a few seconds.');
return;
}
if (!ensureModel()) return;
setBusy(true);
window.addMessage('user', displayText || query);
const before = resultsEl().children.length;
try {
window.pyodide.globals.set('user_query', query);
const answer = await window.pyodide.runPythonAsync('await process_user_query(user_query)');
if (resultsEl().children.length === before) {
window.addMessage('assistant', String(answer));
} else {
const last = resultsEl().lastElementChild;
const content = last && last.querySelector('.message-content');
if (content) content.innerHTML = renderMarkdownLite(String(answer));
else window.addMessage('assistant', String(answer));
}
} catch (err) {
window.addMessage('assistant', '❌ Error: ' + err.message);
} finally {
setBusy(false);
}
}
// ── Statement loading ────────────────────────────────────────────────
async function loadStatement(csvText, name) {
if (!csvText || !csvText.trim()) return;
setOverlay(true, 'Analyzing your statement locally…');
try {
await waitForPyodide();
window.pyodide.globals.set('_csv_text', csvText);
window.pyodide.globals.set('_csv_name', name || 'statement.csv');
const raw = await window.pyodide.runPythonAsync('load_csv_data(_csv_text, _csv_name)');
const res = JSON.parse(raw);
if (!res.ok) throw new Error(res.error || 'Could not read this file.');
$('file-status').textContent = '✅ ' + (name || 'pasted CSV') + ' — ' + res.rows +
' transactions analyzed. ' + (res.note || '');
$('file-status').classList.remove('error');
dataLoaded = true;
await renderDashboard();
} catch (err) {
$('file-status').textContent = '⚠️ ' + err.message;
$('file-status').classList.add('error');
} finally {
setOverlay(false);
}
}
async function renderDashboard() {
const a = JSON.parse(await window.pyodide.runPythonAsync('_full_analysis_json()'));
if (a.error) throw new Error(a.error);
$('stat-range').textContent = a.date_from + ' → ' + a.date_to;
$('stat-spent').textContent = money(a.total_spend);
$('stat-income').textContent = money(a.total_income);
const subsMonthly = a.subscriptions.reduce((s, x) => s + (x.monthly_cost || 0), 0);
$('stat-subs').textContent = money(subsMonthly) + '/mo';
$('stat-fees').textContent = money(a.fees_total);
const subs = $('subs-list');
$('subs-total').textContent = a.subscriptions.length
? money(subsMonthly * 12) + ' per year' : '';
subs.innerHTML = a.subscriptions.length === 0
? '<p class="empty-note">🎉 No recurring charges detected.</p>'
: a.subscriptions.map((s) =>
'<div class="sub-item"><div><strong>' + escapeHtml(s.merchant) + '</strong>' +
'<span class="sub-meta">' + escapeHtml(s.cadence) + ' · ' + s.count +
'× · last ' + escapeHtml(s.last_charged) + '</span></div>' +
'<div class="sub-cost">' + money(s.monthly_cost) + '/mo</div></div>'
).join('');
const fees = $('fees-list');
$('fees-total').textContent = a.fees.length ? money(a.fees_total) + ' total' : '';
fees.innerHTML = a.fees.length === 0
? '<p class="empty-note">✅ No bank fees found.</p>'
: a.fees.map((f) =>
'<div class="fee-item"><span>' + escapeHtml(f.date) + ' — ' +
escapeHtml(f.description) + '</span><strong>' + money(f.amount) + '</strong></div>'
).join('');
$('category-table-body').innerHTML = Object.entries(a.by_category).map(([cat, amt]) => {
const pct = a.total_spend > 0 ? Math.round((amt / a.total_spend) * 100) : 0;
return '<tr><td>' + escapeHtml(cat) + '</td>' +
'<td class="bar-cell"><div class="bar"><div class="bar-fill" style="width:' + pct + '%"></div></div></td>' +
'<td class="num">' + money(amt) + '</td><td class="num muted">' + pct + '%</td></tr>';
}).join('');
$('category-chart').innerHTML = await window.pyodide.runPythonAsync('_chart_category_breakdown()');
$('trend-chart').innerHTML = await window.pyodide.runPythonAsync('_chart_monthly_trend()');
$('welcome-state').classList.add('hidden');
$('dashboard').classList.remove('hidden');
}
// ── Full audit (deterministic data → LLM judgment) ──────────────────
async function runAudit() {
if (!dataLoaded) {
window.addMessage('assistant', 'Load a statement first — upload a CSV or try the sample data.');
return;
}
const ctx = await window.pyodide.runPythonAsync('_audit_context_json()');
const query =
'Here is my spending analysis as JSON:\n' + ctx + '\n\n' +
'Act as my personal finance auditor. Using ONLY this data: ' +
'1) Give the top 3 savings opportunities with estimated monthly savings. ' +
'2) List subscriptions I may have forgotten, with their yearly cost. ' +
'3) Flag the bank fees and how to avoid them. ' +
'Be specific, use the actual numbers, keep it under 250 words.';
await askAgent(query, '🚀 Run a full audit of my statement');
}
// ── Wiring ───────────────────────────────────────────────────────────
function init() {
updateEngineStatus();
const area = $('upload-area');
const input = $('csv-file-input');
area.addEventListener('click', () => input.click());
area.addEventListener('dragover', (e) => { e.preventDefault(); area.classList.add('drag-over'); });
area.addEventListener('dragleave', () => area.classList.remove('drag-over'));
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('drag-over');
if (e.dataTransfer.files.length) readFile(e.dataTransfer.files[0]);
});
input.addEventListener('change', (e) => { if (e.target.files.length) readFile(e.target.files[0]); });
function readFile(file) {
if (!/\.(csv|txt)$/i.test(file.name)) {
$('file-status').textContent = '⚠️ Please choose a CSV file (export one from your bank).';
$('file-status').classList.add('error');
return;
}
const reader = new FileReader();
reader.onload = () => loadStatement(String(reader.result), file.name);
reader.readAsText(file);
}
$('paste-toggle-btn').addEventListener('click', () => $('paste-panel').classList.toggle('hidden'));
$('load-pasted-btn').addEventListener('click', () =>
loadStatement($('csv-text-input').value, 'pasted CSV'));
$('sample-btn').addEventListener('click', () => loadStatement(SAMPLE_CSV, 'sample-statement.csv'));
$('audit-btn').addEventListener('click', runAudit);
$('chat-send').addEventListener('click', sendChat);
$('chat-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') sendChat(); });
document.querySelectorAll('.suggestion-chip').forEach((chip) =>
chip.addEventListener('click', () => askAgent(chip.dataset.q, chip.textContent.trim())));
function sendChat() {
const q = $('chat-input').value.trim();
if (!q) return;
if (!dataLoaded) {
window.addMessage('assistant', 'Load a statement first — upload a CSV or try the sample data.');
return;
}
$('chat-input').value = '';
askAgent(q);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
import io
import json
import re
import numpy as np
import pandas as pd
# ── Tunables (template variables) ─────────────────────────────────────────
MAX_PROMPT_ITEMS = [[[MAX_PROMPT_ITEMS|8]]]
# ── Module state (persists for the session) ───────────────────────────────
_state = {"df": None, "filename": None, "analysis": None, "sign_note": ""}
_CATEGORY_RULES = [
("Income", ["salary", "payroll", "paycheck", "direct deposit", "dividend",
"interest paid", "refund", "reimburs", "cashback", "maas", "maa\u015f"]),
("Fees & Charges", ["fee", "overdraft", "interest charge", "penalty",
"service charge", "maintenance charge", "commission",
"fx charge", "late charge", "atm charge"]),
("Subscriptions & Streaming", ["netflix", "spotify", "hulu", "disney",
"youtube premium", "apple.com/bill", "icloud", "prime video", "hbo",
"paramount", "audible", "patreon", "subscription", "membership",
"adobe", "dropbox", "openai", "chatgpt", "notion", "canva", "gym",
"fitness", "kindle"]),
("Groceries", ["grocery", "supermarket", "whole foods", "trader joe",
"aldi", "lidl", "kroger", "safeway", "costco", "walmart", "tesco",
"migros", "carrefour", "market"]),
("Dining & Delivery", ["restaurant", "cafe", "coffee", "starbucks",
"mcdonald", "burger", "pizza", "doordash", "ubereats", "uber eats",
"grubhub", "deliveroo", "yemeksepeti", "getir", "bakery", "diner",
"kebab", "sushi"]),
("Transport", ["uber", "lyft", "taxi", "fuel", "shell", "chevron",
"parking", "metro", "transit", "train", "toll", "gas station"]),
("Utilities & Bills", ["electric", "water bill", "gas bill", "internet",
"comcast", "verizon", "t-mobile", "vodafone", "utility", "phone bill",
"insurance", "rent", "mortgage", "council tax"]),
("Shopping", ["amazon", "ebay", "etsy", "target", "best buy", "ikea",
"zara", "h&m", "nike", "decathlon", "store", "shop"]),
("Health", ["pharmacy", "cvs", "walgreens", "doctor", "dental", "clinic",
"hospital", "medical", "optician"]),
("Entertainment", ["cinema", "movie", "theater", "concert", "ticket",
"steam", "playstation", "xbox", "nintendo", "game"]),
("Travel", ["airline", "airbnb", "hotel", "booking.com", "expedia",
"flight", "hostel", "ryanair", "easyjet"]),
("Transfers & Cash", ["transfer", "zelle", "venmo", "paypal", "wise",
"revolut", "withdrawal", "atm "]),
]
def _categorize(description: str) -> str:
d = " " + str(description).lower() + " "
for cat, keywords in _CATEGORY_RULES:
for kw in keywords:
if kw in d:
return cat
return "Other"
def _to_number(series: pd.Series) -> pd.Series:
s = series.astype(str).str.strip()
s = s.str.replace(r"\((.+)\)", r"-\1", regex=True)
s = s.str.replace(r"[^\d\-.,]", "", regex=True)
def conv(x):
if not isinstance(x, str) or x in ("", "-", ".", ","):
return np.nan
if "," in x and "." in x:
if x.rfind(".") > x.rfind(","):
x = x.replace(",", "")
else:
x = x.replace(".", "").replace(",", ".")
elif "," in x:
head, _, tail = x.rpartition(",")
if len(tail) == 2:
x = head.replace(",", "") + "." + tail
else:
x = x.replace(",", "")
try:
return float(x)
except ValueError:
return np.nan
return s.map(conv)
def _parse_dates(series: pd.Series) -> pd.Series:
import warnings
warnings.filterwarnings("ignore", message="Could not infer format")
parsed = pd.to_datetime(series, errors="coerce", dayfirst=False)
if parsed.isna().mean() > 0.3:
retry = pd.to_datetime(series, errors="coerce", dayfirst=True)
if retry.isna().mean() < parsed.isna().mean():
parsed = retry
return parsed
def _pick_columns(df: pd.DataFrame) -> dict:
cols = {c: str(c).strip().lower() for c in df.columns}
date_col, desc_col, amount_col = None, None, None
debit_col, credit_col = None, None
for c, name in cols.items():
if debit_col is None and any(k in name for k in ("debit", "withdraw", "money out", "paid out", "borc", "bor\u00e7")):
debit_col = c
if credit_col is None and any(k in name for k in ("credit", "deposit", "money in", "paid in", "alacak", "yatan")):
credit_col = c
best_ratio = 0.0
for c in df.columns:
if pd.api.types.is_numeric_dtype(df[c]):
continue
ratio = _parse_dates(df[c].astype(str)).notna().mean()
if ratio > best_ratio and ratio > 0.5:
best_ratio, date_col = ratio, c
for c, name in cols.items():
if amount_col is None and name in ("amount", "amt", "value", "tutar", "transaction amount", "betrag"):
amount_col = c
if amount_col is None and not (debit_col is not None and credit_col is not None):
best_num = 0.0
for c in df.columns:
if c == date_col:
continue
ratio = _to_number(df[c]).notna().mean()
if ratio > best_num and ratio > 0.6:
best_num, amount_col = ratio, c
best_len = 0.0
for c in df.columns:
if c in (date_col, amount_col, debit_col, credit_col):
continue
if pd.api.types.is_numeric_dtype(df[c]):
continue
avg_len = df[c].astype(str).str.len().mean()
if avg_len > best_len:
best_len, desc_col = avg_len, c
return {"date": date_col, "desc": desc_col, "amount": amount_col,
"debit": debit_col, "credit": credit_col}
def load_csv_data(csv_text: str, filename: str) -> str:
"""Parse a bank statement CSV into normalized transactions.
Called from JavaScript only (this name is excluded from LLM tools).
Args:
csv_text: Raw CSV file contents.
filename: Original file name, for display.
Returns:
JSON string with ok flag, row count and detected columns, or an error.
"""
try:
raw = pd.read_csv(io.StringIO(csv_text), sep=None, engine="python")
except Exception as exc:
return json.dumps({"ok": False, "error": "Could not parse CSV: " + str(exc)})
if raw.empty or len(raw.columns) < 2:
return json.dumps({"ok": False, "error": "The file has no usable rows or columns."})
picks = _pick_columns(raw)
if picks["date"] is None or picks["desc"] is None:
return json.dumps({"ok": False, "error":
"Could not detect date and description columns. "
"Export a standard CSV from your bank and try again."})
df = pd.DataFrame()
df["date"] = _parse_dates(raw[picks["date"]])
df["description"] = raw[picks["desc"]].astype(str).str.strip()
sign_note = ""
if picks["debit"] is not None and picks["credit"] is not None:
debit = _to_number(raw[picks["debit"]]).fillna(0.0).abs()
credit = _to_number(raw[picks["credit"]]).fillna(0.0).abs()
df["spend"] = debit
df["income"] = credit
sign_note = "Separate debit/credit columns detected."
elif picks["amount"] is not None:
amount = _to_number(raw[picks["amount"]])
df = df[amount.notna()].copy()
amount = amount.dropna()
if (amount < 0).any():
df["spend"] = np.where(amount < 0, -amount, 0.0)
df["income"] = np.where(amount > 0, amount, 0.0)
sign_note = "Negative amounts treated as spending."
else:
cats = df["description"].map(_categorize)
df["spend"] = np.where(cats != "Income", amount, 0.0)
df["income"] = np.where(cats == "Income", amount, 0.0)
sign_note = "All amounts positive: rows matching income keywords treated as income."
else:
return json.dumps({"ok": False, "error": "Could not detect an amount column."})
df = df[df["date"].notna() & (df["description"] != "")].copy()
if df.empty:
return json.dumps({"ok": False, "error": "No valid transactions found after parsing."})
df["category"] = df["description"].map(_categorize)
df = df.sort_values("date").reset_index(drop=True)
_state["df"] = df
_state["filename"] = filename
_state["sign_note"] = sign_note
_state["analysis"] = _compute_analysis(df)
return json.dumps({"ok": True, "rows": int(len(df)), "note": sign_note,
"columns": {k: str(v) for k, v in picks.items() if v is not None}})
def _merchant_key(description: str) -> str:
d = re.sub(r"[0-9#*/]+", " ", str(description).lower())
d = re.sub(r"[^a-z&. ]", " ", d)
tokens = [t for t in d.split() if len(t) > 2][:3]
return " ".join(tokens)
def _detect_recurring(df: pd.DataFrame) -> list:
spend = df[df["spend"] > 0]
found = []
for key, grp in spend.groupby(spend["description"].map(_merchant_key)):
if not key or len(grp) < 2:
continue
amounts = grp["spend"].to_numpy()
mean_amt = float(amounts.mean())
if mean_amt <= 0:
continue
cv = float(amounts.std() / mean_amt) if len(amounts) > 1 else 0.0
dates = grp["date"].sort_values().to_numpy()
gaps = np.diff(dates).astype("timedelta64[D]").astype(int) if len(dates) > 1 else []
median_gap = float(np.median(gaps)) if len(gaps) else 0.0
if 25 <= median_gap <= 35:
cadence, monthly = "monthly", mean_amt
elif 5 <= median_gap <= 9:
cadence, monthly = "weekly", mean_amt * 4.33
elif 350 <= median_gap <= 380:
cadence, monthly = "annual", mean_amt / 12.0
else:
cadence, monthly = "irregular", 0.0
identical = bool(np.allclose(amounts, amounts[0]))
strong = (len(grp) >= 3 and cv < 0.25 and cadence != "irregular") or \
(len(grp) >= 2 and identical and cadence != "irregular")
if strong:
found.append({
"merchant": grp["description"].iloc[-1][:48],
"count": int(len(grp)),
"cadence": cadence,
"avg_amount": round(mean_amt, 2),
"monthly_cost": round(monthly, 2),
"annual_cost": round(monthly * 12.0, 2),
"last_charged": str(pd.Timestamp(dates[-1]).date()),
})
found.sort(key=lambda r: -r["monthly_cost"])
return found
def _compute_analysis(df: pd.DataFrame) -> dict:
by_cat = (df.groupby("category")["spend"].sum()
.sort_values(ascending=False))
by_cat = by_cat[by_cat > 0]
fees = df[(df["category"] == "Fees & Charges") & (df["spend"] > 0)]
fee_rows = [{"date": str(r["date"].date()), "description": r["description"][:60],
"amount": round(float(r["spend"]), 2)} for _, r in fees.iterrows()]
top_merchants = (df[df["spend"] > 0].groupby(df["description"].str[:40])["spend"]
.sum().sort_values(ascending=False).head(10))
return {
"filename": _state.get("filename") or "",
"date_from": str(df["date"].min().date()),
"date_to": str(df["date"].max().date()),
"transactions": int(len(df)),
"total_spend": round(float(df["spend"].sum()), 2),
"total_income": round(float(df["income"].sum()), 2),
"by_category": {k: round(float(v), 2) for k, v in by_cat.items()},
"subscriptions": _detect_recurring(df),
"fees": fee_rows,
"fees_total": round(float(fees["spend"].sum()), 2),
"top_merchants": {k: round(float(v), 2) for k, v in top_merchants.items()},
"sign_note": _state.get("sign_note") or "",
}
def _no_data() -> str:
return json.dumps({"error": "No statement loaded yet. Ask the user to upload a CSV first."})
def _full_analysis_json() -> str:
if _state["analysis"] is None:
return _no_data()
return json.dumps(_state["analysis"])
def _audit_context_json() -> str:
a = _state["analysis"]
if a is None:
return _no_data()
compact = {
"period": a["date_from"] + " to " + a["date_to"],
"total_spend": a["total_spend"],
"total_income": a["total_income"],
"top_categories": dict(list(a["by_category"].items())[:MAX_PROMPT_ITEMS]),
"subscriptions": a["subscriptions"][:MAX_PROMPT_ITEMS],
"fees_total": a["fees_total"],
"fees_count": len(a["fees"]),
"top_merchants": dict(list(a["top_merchants"].items())[:5]),
}
return json.dumps(compact)
def _fig_to_img() -> str:
import base64
import matplotlib.pyplot as plt
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=110, bbox_inches="tight")
plt.close("all")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return '<img alt="chart" style="max-width:100%" src="data:image/png;base64,' + b64 + '" />'
def _chart_category_breakdown() -> str:
if _state["analysis"] is None:
return ""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
cats = dict(list(_state["analysis"]["by_category"].items())[:8])
if not cats:
return ""
labels = list(cats.keys())[::-1]
values = list(cats.values())[::-1]
plt.figure(figsize=(6.4, 3.6))
plt.barh(labels, values, color="#1E3A8A")
plt.title("Spending by category")
plt.xlabel("Amount")
return _fig_to_img()
def _chart_monthly_trend() -> str:
df = _state["df"]
if df is None:
return ""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
span_days = (df["date"].max() - df["date"].min()).days
freq = "W" if span_days < 35 else "MS"
series = df.set_index("date")["spend"].resample(freq).sum()
labels = [d.strftime("%d %b" if freq == "W" else "%b %Y") for d in series.index]
plt.figure(figsize=(6.4, 3.2))
plt.bar(labels, series.to_numpy(), color="#CA8A04")
plt.title("Spending over time" + (" (weekly)" if freq == "W" else " (monthly)"))
plt.xticks(rotation=45, ha="right")
return _fig_to_img()
# ── LLM tools ──────────────────────────────────────────────────────────────
def get_spending_summary() -> str:
"""Get the spending summary: period, totals and amount per category.
Args:
Returns:
JSON string with period, total_spend, total_income and by_category.
"""
a = _state["analysis"]
if a is None:
return _no_data()
return json.dumps({
"period": a["date_from"] + " to " + a["date_to"],
"transactions": a["transactions"],
"total_spend": a["total_spend"],
"total_income": a["total_income"],
"by_category": a["by_category"],
})
def get_subscriptions() -> str:
"""List detected recurring charges and subscriptions with their costs.
Args:
Returns:
JSON string with a list of merchant, cadence, monthly_cost, annual_cost.
"""
a = _state["analysis"]
if a is None:
return _no_data()
return json.dumps({"subscriptions": a["subscriptions"],
"monthly_total": round(sum(s["monthly_cost"] for s in a["subscriptions"]), 2)})
def get_fees() -> str:
"""List bank fees, penalty and interest charges found in the statement.
Args:
Returns:
JSON string with fee transactions and the fees_total.
"""
a = _state["analysis"]
if a is None:
return _no_data()
return json.dumps({"fees": a["fees"], "fees_total": a["fees_total"]})
def search_transactions(keyword: str) -> str:
"""Search transactions whose description contains a keyword.
Args:
keyword: Merchant name or word to search for, e.g. 'uber' or 'netflix'.
Returns:
JSON string with matching transactions, their count and total spend.
"""
df = _state["df"]
if df is None:
return _no_data()
kw = str(keyword).strip().lower()
if not kw:
return json.dumps({"error": "Empty keyword."})
hits = df[df["description"].str.lower().str.contains(re.escape(kw), na=False)]
rows = [{"date": str(r["date"].date()), "description": r["description"][:60],
"spend": round(float(r["spend"]), 2), "income": round(float(r["income"]), 2)}
for _, r in hits.head(25).iterrows()]
return json.dumps({"keyword": kw, "matches": int(len(hits)),
"total_spend": round(float(hits["spend"].sum()), 2), "transactions": rows})