Agent Template

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.

finance budgeting subscriptions fees privacy csv personal-finance audit
ozzo Jun 09, 2026 1 use

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.

Topics finance budgeting subscriptions fees privacy csv personal-finance audit
Template Preview

Template Metadata

Slug
private-spending-auditor
Created By
ozzo
Created
Jun 09, 2026
Usage Count
1

Tags

finance budgeting subscriptions fees privacy csv personal-finance audit

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 &amp; 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&#10;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 &amp; subscriptions <span class="card-badge" id="subs-total"></span></h3>
            <div id="subs-list"></div>
          </div>
          <div class="card">
            <h3>⚠️ Fees &amp; 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 &amp; 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, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}

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})