import os, json, zipfile, textwrap
base = "/mnt/data/billbuddy_ai"
os.makedirs(base, exist_ok=True)
os.makedirs(os.path.join(base, "data"), exist_ok=True)
server_py = r'''#!/usr/bin/env python3
"""BillBuddy AI — local-first invoice and receipt organizer.
Uses only Python's standard library. Optional AI analysis runs through Ollama on this computer.
"""
import base64, csv, io, json, mimetypes, os, re, threading, urllib.error, urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(file).resolve().parent
DATA_DIR = ROOT / "data"
RECORDS_FILE = DATA_DIR / "records.json"
HOST = "127.0.0.1"
PORT = int(os.environ.get("PORT", "8765"))
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5vl:3b")
MAX_BODY = 15 * 1024 * 1024
LOCK = threading.Lock()
def load_records():
DATA_DIR.mkdir(exist_ok=True)
if not RECORDS_FILE.exists():
return []
try:
data = json.loads(RECORDS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
def save_records(records):
DATA_DIR.mkdir(exist_ok=True)
temp = RECORDS_FILE.with_suffix(".tmp")
temp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(RECORDS_FILE)
def ollama_request(payload, timeout=120):
req = urllib.request.Request(
OLLAMA_URL.rstrip("/") + "/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def parse_json_from_text(text):
text = text.strip()
if text.startswith("
text = re.sub(r"^```
(?:json)?\s*", "", text)
text = re.sub(r"\s*
```$", "", text)
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
text = text[start:end+1]
return json.loads(text)
def analyze_document(body):
extracted_text = str(body.get("text", "")).strip()
image = body.get("image")
mime = str(body.get("mime", "image/jpeg"))
model = str(body.get("model") or DEFAULT_MODEL).strip()
if not extracted_text and not image:
raise ValueError("Add receipt text or upload a receipt image first.")
prompt = """You are a careful invoice and receipt data-entry assistant. Extract only information visible in the supplied receipt/image or text. Never invent missing details. Return ONLY valid JSON with these keys:
{"merchant":"","document_type":"Receipt or Invoice or Other","date":"YYYY-MM-DD or empty","currency":"currency code or symbol or empty","subtotal":null,"tax":null,"total":null,"category":"one of Food, Travel, Software, Office supplies, Utilities, Shopping, Healthcare, Education, Other","payment_method":"","invoice_number":"","notes":"","confidence":"high, medium, or low"}
Use numbers for amounts and null if unknown. If the date is ambiguous, leave it empty. Keep notes short."""
user_content = prompt
if extracted_text:
user_content += "\n\nDocument text:\n" + extracted_text[:30000]
message = {"role": "user", "content": user_content}
if image:
message["images"] = [image]
result = ollama_request({
"model": model,
"messages": [message],
"stream": False,
"format": "json",
"options": {"temperature": 0.1}
})
content = result.get("message", {}).get("content", "")
data = parse_json_from_text(content)
defaults = {
"merchant": "", "document_type": "Receipt", "date": "", "currency": "",
"subtotal": None, "tax": None, "total": None, "category": "Other",
"payment_method": "", "invoice_number": "", "notes": "", "confidence": "low"
}
for key, value in defaults.items():
data.setdefault(key, value)
allowed = {"Food", "Travel", "Software", "Office supplies", "Utilities", "Shopping", "Healthcare", "Education", "Other"}
if data.get("category") not in allowed:
data["category"] = "Other"
return data, model
class Handler(BaseHTTPRequestHandler):
server_version = "BillBuddyLocal/1.0"
def log_message(self, fmt, *args):
print("%s - %s" % (self.address_string(), fmt % args))
def _send(self, status, body, content_type="application/json; charset=utf-8"):
raw = body if isinstance(body, bytes) else body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)
def _json(self, status, obj):
self._send(status, json.dumps(obj, ensure_ascii=False), "application/json; charset=utf-8")
def _read_json(self):
length = int(self.headers.get("Content-Length", "0"))
if length > MAX_BODY:
raise ValueError("Upload too large. Keep requests under 15 MB.")
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8") or "{}")
def do_GET(self):
path = urlparse(self.path).path
if path == "/api/records":
with LOCK:
self._json(200, {"records": load_records()})
return
if path == "/api/health":
status = {"app": "ok", "ollama": False, "model": DEFAULT_MODEL}
try:
req = urllib.request.Request(OLLAMA_URL.rstrip("/") + "/api/tags")
with urllib.request.urlopen(req, timeout=2) as response:
tags = json.loads(response.read().decode("utf-8"))
status["ollama"] = True
status["models"] = [m.get("name", "") for m in tags.get("models", [])]
except Exception as exc:
status["error"] = str(exc)
self._json(200, status)
return
if path in ("/", "/index.html"):
file_path = ROOT / "index.html"
elif path == "/app.js":
file_path = ROOT / "app.js"
elif path == "/styles.css":
file_path = ROOT / "styles.css"
else:
self._json(404, {"error": "Not found"})
return
if not file_path.exists():
self._json(404, {"error": "Application file missing"})
return
ctype = mimetypes.guess_type(str(file_path))[0] or "text/plain"
self._send(200, file_path.read_bytes(), ctype + ("; charset=utf-8" if ctype.startswith("text/") or "javascript" in ctype else ""))
def do_POST(self):
path = urlparse(self.path).path
try:
body = self._read_json()
if path == "/api/analyze":
data, model = analyze_document(body)
data["id"] = datetime.now().strftime("%Y%m%d%H%M%S%f")
data["created_at"] = datetime.now().isoformat(timespec="seconds")
data["source_name"] = str(body.get("filename", "Manual entry"))[:200]
data["ai_model"] = model
data["status"] = "review"
with LOCK:
records = load_records()
records.insert(0, data)
save_records(records)
self._json(200, {"record": data})
return
if path == "/api/record":
record = body.get("record")
if not isinstance(record, dict) or not record.get("id"):
self._json(400, {"error": "Record is missing an ID."})
return
with LOCK:
records = load_records()
for i, old in enumerate(records):
if old.get("id") == record["id"]:
records[i] = record
break
else:
records.insert(0, record)
save_records(records)
self._json(200, {"record": record})
return
if path == "/api/delete":
rid = str(body.get("id", ""))
with LOCK:
records = [r for r in load_records() if r.get("id") != rid]
save_records(records)
self._json(200, {"ok": True})
return
if path == "/api/clear":
with LOCK:
save_records([])
self._json(200, {"ok": True})
return
self._json(404, {"error": "Not found"})
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:1200]
self._json(502, {"error": "Ollama returned an error: " + detail})
except urllib.error.URLError as exc:
self._json(503, {"error": "Can't reach local Ollama. Install/start Ollama and download a compatible vision model. Details: " + str(exc.reason)})
except (ValueError, json.JSONDecodeError) as exc:
self._json(400, {"error": str(exc)})
except Exception as exc:
self._json(500, {"error": "Could not process this document: " + str(exc)})
def do_DELETE(self):
self._json(405, {"error": "Use POST /api/delete"})
if __name__ == "__main__":
DATA_DIR.mkdir(exist_ok=True)
if not RECORDS_FILE.exists():
save_records([])
print(f"BillBuddy AI running at http://{HOST}:{PORT}")
print(f"Ollama endpoint: {OLLAMA_URL} | default model: {DEFAULT_MODEL}")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
'''
index_html = r'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#101b18">
<title>BillBuddy AI — Private expense desk</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand"><div class="brand-mark">B</div><div><strong>billbuddy<span>.ai</span></strong><small>PRIVATE EXPENSE DESK</small></div></div>
<div class="side-label">WORKSPACE</div>
<button class="nav-item active" data-view="overview"><span>◫</span> Overview</button>
<button class="nav-item" data-view="records"><span>▤</span> All documents <b id="nav-count">0</b></button>
<button class="nav-item" data-view="add"><span>+</span> Add document</button>
<div class="sidebar-bottom">
<div class="privacy-card"><div class="privacy-icon">⌁</div><strong>Your data stays yours.</strong><p>Documents and records are stored on this computer. AI requests go to your local Ollama service.</p><div class="local-status"><i id="status-dot"></i><span id="status-text">Checking local AI…</span></div></div>
<div class="version">BILLBUDDY AI <span>·</span> LOCAL-FIRST MVP</div>
</div>
</aside>
<main class="main">
<header class="topbar"><div class="breadcrumb">Workspace <span>/</span> <b id="crumb">Overview</b></div><div class="top-actions"><span class="local-pill"><i></i> LOCAL MODE</span><div class="avatar">BB</div></div></header>
<section class="page" id="overview-view">
<div class="welcome-row"><div><div class="eyebrow">YOUR MONEY, ORGANIZED</div><h1>Good work deserves<br><em>clear books.</em></h1><p class="subtitle">Your receipts, invoices, and expenses — organized with private, local AI.</p></div><button class="primary-btn" data-go="add">+ Add a document <span>↗</span></button></div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-top"><span>Total tracked</span><span class="stat-icon green">₹</span></div><div class="stat-value" id="stat-total">₹0.00</div><div class="stat-foot">Across saved documents</div></div>
<div class="stat-card"><div class="stat-top"><span>Documents</span><span class="stat-icon violet">▤</span></div><div class="stat-value" id="stat-docs">0</div><div class="stat-foot">Receipts and invoices</div></div>
<div class="stat-card"><div class="stat-top"><span>Need review</span><span class="stat-icon amber">⌕</span></div><div class="stat-value" id="stat-review">0</div><div class="stat-foot">Check AI-extracted details</div></div>
<div class="stat-card"><div class="stat-top"><span>Top category</span><span class="stat-icon blue">◈</span></div><div class="stat-value category-stat" id="stat-category">—</div><div class="stat-foot">By tracked amount</div></div>
</div>
<div class="content-grid">
<section class="panel recent-panel"><div class="panel-head"><div><h2>Recent documents</h2><p>Your latest processed receipts and invoices</p></div><button class="text-btn" data-go="records">View all <span>→</span></button></div><div id="recent-list" class="document-list"><div class="empty-state"><div class="empty-icon">▤</div><strong>No documents yet</strong><p>Add a receipt to start your expense desk.</p><button class="outline-btn" data-go="add">Add first document</button></div></div></section>
<section class="panel category-panel"><div class="panel-head"><div><h2>Spending snapshot</h2><p>Where your money is going</p></div><span class="tiny-tag">ALL TIME</span></div><div id="category-chart" class="category-chart"><div class="chart-empty">Your category breakdown will appear here.</div></div><div class="chart-note"><span class="legend-dot"></span> Tracked document totals</div></section>
</div>
<div class="bottom-note"><span class="sparkle">✳</span><div><strong>AI is your assistant, not your accountant.</strong><p>Always review extracted amounts, dates, and tax details before using them for bookkeeping or filing.</p></div></div>
</section>
<section class="page hidden" id="add-view">
<div class="page-heading"><div class="eyebrow">DOCUMENT INTAKE</div><h1>Add a document</h1><p class="subtitle">Upload a receipt image or paste its text. Local AI will extract the details for you to review.</p></div>
<div class="intake-grid">
<section class="panel upload-panel"><div class="panel-head"><div><h2>Upload receipt</h2><p>JPG, PNG, or WEBP image · up to 10 MB</p></div><span class="step-badge">01</span></div>
<label class="dropzone" id="dropzone" for="file-input"><input id="file-input" type="file" accept="image/png,image/jpeg,image/webp"><div class="upload-symbol">↑</div><strong>Drop a receipt here</strong><p>or <u>browse files</u></p><small>Your image is sent only to your local Ollama model.</small><div id="file-preview" class="file-preview hidden"></div></label>
<div class="or-divider"><span>OR ENTER TEXT</span></div>
<label class="field-label" for="receipt-text">Receipt / invoice text</label><textarea id="receipt-text" rows="6" placeholder="Paste text from an invoice or receipt… Example: Green Basket, 12 Oct 2026, Groceries ₹840.50, GST ₹42.00, Total ₹882.50"></textarea>
<div class="field-row"><label class="field-label" for="model-name">Local Ollama model</label><input id="model-name" value="qwen2.5vl:3b" spellcheck="false"></div>
<button id="analyze-btn" class="primary-btn full-btn">✦ Extract with local AI <span>→</span></button><p class="microcopy">No cloud AI API key. First-time model download requires internet; afterward, inference can run locally.</p>
<div id="analyze-error" class="alert hidden"></div>
</section>
<aside class="panel how-panel"><div class="panel-head"><div><h2>How it works</h2><p>Private by design</p></div></div><div class="steps"><div class="step"><span>1</span><div><strong>Choose a document</strong><p>Upload a receipt photo or paste text copied from a document.</p></div></div><div class="step"><span>2</span><div><strong>Local AI reads it</strong><p>The model extracts merchant, date, totals, tax, and category on your computer.</p></div></div><div class="step"><span>3</span><div><strong>You review the result</strong><p>Correct any mistakes and save the record to your local expense list.</p></div></div></div><div class="model-tip"><span>✳</span><div><strong>Open models, your choice</strong><p>Change the model to another compatible Ollama vision model. Text-only input also works with text models.</p></div></div><div class="privacy-line"><span>⌁</span> Local storage <b>·</b> Local inference <b>·</b> No account needed</div></aside>
</div>
</section>
<section class="page hidden" id="records-view">
<div class="page-heading records-heading"><div><div class="eyebrow">YOUR DOCUMENT LIBRARY</div><h1>All documents</h1><p class="subtitle">Search, review, export, or remove saved records.</p></div><button id="export-btn" class="outline-btn">↓ Export CSV</button></div>
<section class="panel table-panel"><div class="table-toolbar"><div class="searchbox"><span>⌕</span><input id="search-input" placeholder="Search merchant, category, invoice…"></div><select id="category-filter"><option value="">All categories</option><option>Food</option><option>Travel</option><option>Software</option><option>Office supplies</option><option>Utilities</option><option>Shopping</option><option>Healthcare</option><option>Education</option><option>Other</option></select></div><div class="table-wrap"><table><thead><tr><th>MERCHANT / DOCUMENT</th><th>DATE</th><th>CATEGORY</th><th>TOTAL</th><th>STATUS</th><th></th></tr></thead><tbody id="records-body"></tbody></table></div><div id="records-empty" class="empty-state hidden"><div class="empty-icon">▤</div><strong>No matching documents</strong><p>Try another search or add your first document.</p></div></section>
</section>
<footer class="footer"><span>BillBuddy AI <b>·</b> Built with open AI</span><span>Made for independent work <span class="footer-heart">♥</span></span></footer>
</main>
</div>
<div id="modal-backdrop" class="modal-backdrop hidden"><section class="review-modal"><div class="modal-head"><div><div class="eyebrow">AI EXTRACTION COMPLETE</div><h2>Review document</h2><p>Confirm these details before saving.</p></div><button id="close-modal" class="close-btn" aria-label="Close">×</button></div><form id="review-form"><input type="hidden" id="edit-id"><div class="review-grid">
<label class="field-label">Merchant<input id="f-merchant" required placeholder="Merchant name"></label>
<label class="field-label">Document type<select id="f-type"><option>Receipt</option><option>Invoice</option><option>Other</option></select></label>
<label class="field-label">Date<input id="f-date" type="date"></label>
<label class="field-label">Category<select id="f-category"><option>Food</option><option>Travel</option><option>Software</option><option>Office supplies</option><option>Utilities</option><option>Shopping</option><option>Healthcare</option><option>Education</option><option>Other</option></select></label>
<label class="field-label">Currency<input id="f-currency" placeholder="INR / ₹ / USD"></label>
<label class="field-label">Total amount<input id="f-total" type="number" step="0.01" min="0" placeholder="0.00"></label>
<label class="field-label">Subtotal<input id="f-subtotal" type="number" step="0.01" min="0" placeholder="Optional"></label>
<label class="field-label">Tax<input id="f-tax" type="number" step="0.01" min="0" placeholder="Optional"></label>
<label class="field-label">Payment method<input id="f-payment" placeholder="Cash, card, UPI…"></label>
<label class="field-label">Invoice number<input id="f-invoice" placeholder="Optional"></label>
<label class="field-label wide-field">Notes<textarea id="f-notes" rows="2" placeholder="Optional notes"></textarea></label>
</div><div class="review-meta" id="review-meta"></div><div class="modal-actions"><button type="button" id="cancel-review" class="outline-btn">Cancel</button><button type="submit" class="primary-btn">Save document <span>✓</span></button></div></form></section></div>
<div id="toast" class="toast hidden"></div>
<script src="/app.js"></script>
</body></html>
'''
styles_css = r'''@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap');
:root{--bg:#f5f7f4;--panel:#fff;--ink:#18241f;--muted:#78827c;--line:#e7ebe6;--green:#286d4e;--green-dark:#174b36;--mint:#e6f3eb;--radius:18px;--shadow:0 5px 25px rgba(22,43,31,.035)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:'DM Sans',sans-serif;font-size:14px}button,input,textarea,select{font:inherit}button{cursor:pointer}.app-shell{min-height:100vh;display:flex}.sidebar{width:246px;position:fixed;inset:0 auto 0 0;background:#101b18;color:#f3f8f4;padding:28px 18px 17px;display:flex;flex-direction:column;z-index:5}.brand{display:flex;align-items:center;gap:11px;padding:0 8px 43px}.brand-mark{width:39px;height:39px;border-radius:12px;background:#c4edcf;color:#163c2a;display:grid;place-items:center;font:800 23px Manrope}.brand strong{font:800 19px Manrope;letter-spacing:-.7px}.brand strong span{color:#a2d9b1}.brand small{display:block;color:#879990;font-size:8px;letter-spacing:1.65px;margin-top:3px}.side-label{font-size:10px;font-weight:700;color:#708178;letter-spacing:1.7px;padding:0 12px 13px}.nav-item{border:0;background:transparent;color:#a9b7af;width:100%;display:flex;align-items:center;gap:13px;text-align:left;padding:12px 13px;border-radius:10px;margin:2px 0;font-size:13px}.nav-item span{font-size:18px;width:18px;text-align:center}.nav-item.active{background:#263a30;color:#e6f8eb}.nav-item b{margin-left:auto;font-size:10px;background:#3b5044;border-radius:7px;padding:3px 7px}.sidebar-bottom{margin-top:auto}.privacy-card{background:#1b2a23;border:1px solid #2b3d32;border-radius:14px;padding:16px 14px}.privacy-icon{color:#a9dfb8;font-size:22px;margin-bottom:11px}.privacy-card strong{font-size:12px}.privacy-card p{font-size:11px;line-height:1.65;color:#9eafa4;margin:8px 0 14px}.local-status{border-top:1px solid #314238;padding-top:12px;display:flex;align-items:center;gap:7px;color:#a9d7b6;font-size:10px}.local-status i,.local-pill i{width:7px;height:7px;border-radius:50%;background:#e6b65c;display:inline-block}.local-status i.online{background:#7bd49a;box-shadow:0 0 0 3px #254b35}.version{font-size:8px;letter-spacing:1px;color:#697b70;margin:18px 5px 0}.version span{margin:0 5px}.main{margin-left:246px;min-height:100vh;width:calc(100% - 246px);display:flex;flex-direction:column}.topbar{height:70px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 46px;background:rgba(255,255,255,.72)}.breadcrumb{font-size:12px;color:#8a948e}.breadcrumb span{padding:0 10px;color:#c1c8c2}.breadcrumb b{font-weight:600;color:#2e3a33}.top-actions{display:flex;align-items:center;gap:16px}.local-pill{font-size:9px;font-weight:700;letter-spacing:1px;color:#376d4f;background:#e5f2e9;padding:8px 10px;border-radius:8px}.local-pill i{background:#4a9c68;margin-right:6px}.avatar{width:31px;height:31px;border-radius:10px;background:#e2eee5;color:#2b6546;display:grid;place-items:center;font-size:10px;font-weight:700}.page{padding:40px 46px 26px;flex:1;max-width:1600px;width:100%;margin:0 auto}.hidden{display:none!important}.welcome-row{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:32px}.eyebrow{font-size:9px;font-weight:700;letter-spacing:1.9px;color:#64806d;margin-bottom:12px}.welcome-row h1,.page-heading h1{font:700 clamp(31px,3vw,43px)/1.16 Manrope,sans-serif;letter-spacing:-1.8px;margin:0;color:#17251d}.welcome-row h1 em{font-style:normal;color:#3b855b}.subtitle{color:#849087;font-size:13px;line-height:1.7;margin:13px 0 0}.primary-btn{border:0;background:var(--green);color:white;border-radius:10px;padding:13px 17px;font-weight:600;font-size:12px;box-shadow:0 4px 10px #286d4e1c;transition:background .2s,transform .2s;white-space:nowrap}.primary-btn:hover{background:var(--green-dark);transform:translateY(-1px)}.primary-btn span{padding-left:13px;font-size:15px}.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:22px}.stat-card,.panel{background:var(--panel);border:1px solid #e9ede8;border-radius:var(--radius);box-shadow:var(--shadow)}.stat-card{padding:19px 20px 17px;min-width:0}.stat-top{display:flex;justify-content:space-between;align-items:center;color:#7b877e;font-size:11px}.stat-icon{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-weight:700;font-size:15px}.green{background:#e5f4e9;color:#398157}.violet{background:#efebfa;color:#7660ad}.amber{background:#fff3dc;color:#b7832b}.blue{background:#e7f0fa;color:#4f7db2}.stat-value{font:700 26px Manrope;letter-spacing:-.9px;margin:17px 0 7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.category-stat{font-size:20px}.stat-foot{font-size:10px;color:#9ba49d}.content-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(270px,1fr);gap:19px}.panel{padding:22px}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:19px}.panel-head h2{font:700 15px Manrope;margin:0;letter-spacing:-.3px}.panel-head p{font-size:11px;color:#8d9890;margin:7px 0 0}.text-btn{border:0;background:none;color:#3c7854;font-size:11px;font-weight:600;padding:4px}.text-btn span{font-size:15px;margin-left:7px}.document-list{min-height:160px}.doc-row{display:flex;align-items:center;gap:12px;padding:13px 0;border-bottom:1px solid #f0f2ef}.doc-row:last-child{border-bottom:0}.doc-icon{width:38px;height:40px;border-radius:10px;background:#edf5ef;color:#43835a;display:grid;place-items:center;font-size:17px;flex-shrink:0}.doc-main{min-width:0;flex:1}.doc-main strong{display:block;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.doc-main small{display:block;color:#929c94;font-size:10px;margin-top:5px}.doc-amount{text-align:right;white-space:nowrap}.doc-amount strong{font-size:12px}.doc-amount small{display:block;margin-top:5px;font-size:9px;color:#9aa49c}.tiny-tag{font-size:8px;letter-spacing:1.1px;font-weight:700;color:#7e8a80;background:#f2f5f1;padding:7px 8px;border-radius:6px}.category-chart{display:flex;flex-direction:column;gap:15px;min-height:175px;padding-top:3px}.chart-empty{color:#a0a9a2;font-size:11px;line-height:1.7;margin:auto;text-align:center;max-width:200px}.cat-row{display:grid;grid-template-columns:92px 1fr 62px;align-items:center;gap:8px;font-size:10px}.cat-name{color:#68756b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar-bg{height:6px;background:#edf1ed;border-radius:10px;overflow:hidden}.bar-fill{height:100%;background:#65a77a;border-radius:10px}.cat-amount{text-align:right;font-size:10px;font-weight:600}.chart-note{border-top:1px solid #edf0ec;padding-top:15px;margin-top:18px;font-size:10px;color:#8a958d}.legend-dot{display:inline-block;width:7px;height:7px;background:#65a77a;border-radius:50%;margin-right:6px}.empty-state{display:flex;align-items:center;flex-direction:column;text-align:center;padding:26px 12px;color:#88938b}.empty-icon{width:43px;height:43px;border-radius:13px;background:#eff5f0;color:#639372;display:grid;place-items:center;font-size:20px;margin-bottom:11px}.empty-state strong{font-size:12px;color:#435047}.empty-state p{font-size:11px;margin:7px 0 13px;line-height:1.6}.outline-btn{border:1px solid #dfe7df;border-radius:9px;background:white;color:#496652;font-size:11px;font-weight:600;padding:10px 13px}.outline-btn:hover{background:#f5faf6}.bottom-note{display:flex;align-items:flex-start;gap:12px;border:1px solid #dfece2;background:#eef7f0;border-radius:13px;padding:15px 18px;margin-top:21px}.sparkle{font-size:18px;color:#5b9a6c}.bottom-note strong{font-size:11px;color:#2e5c3d}.bottom-note p{font-size:10px;color:#728578;margin:5px 0 0;line-height:1.6}.footer{display:flex;justify-content:space-between;gap:20px;padding:17px 46px 22px;color:#a0aaa2;font-size:9px}.footer b{margin:0 5px;color:#c5ccc6}.footer-heart{color:#7eaa88;margin-left:4px}.page-heading{margin-bottom:25px}.page-heading h1{font-size:35px}.intake-grid{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.8fr);gap:20px;align-items:start}.upload-panel,.how-panel{padding:24px}.step-badge{background:#eef5ef;color:#50835f;font-size:10px;font-weight:700;padding:7px 9px;border-radius:7px}.dropzone{display:flex;align-items:center;flex-direction:column;justify-content:center;min-height:202px;border:1px dashed #b9cdbd;border-radius:14px;background:#fbfdfb;text-align:center;cursor:pointer;padding:20px;transition:.2s}.dropzone:hover,.dropzone.dragover{border-color:#4b9462;background:#f1faf3}.dropzone input{display:none}.upload-symbol{width:39px;height:39px;border-radius:12px;background:#e5f3e8;color:#3d8054;display:grid;place-items:center;font-size:24px;margin-bottom:12px}.dropzone strong{font-size:12px}.dropzone p{font-size:11px;color:#859087;margin:7px 0 11px}.dropzone u{color:#36774e}.dropzone small{font-size:9px;color:#a0aaa3}.file-preview{font-size:10px;color:#38794e;margin-top:10px;font-weight:600;word-break:break-word}.or-divider{display:flex;align-items:center;gap:12px;color:#9ba59e;font-size:8px;letter-spacing:1.2px;margin:21px 0}.or-divider:before,.or-divider:after{content:"";height:1px;background:#e8ede8;flex:1}.field-label{display:flex;flex-direction:column;gap:7px;font-size:10px;font-weight:600;color:#68776b;margin-bottom:14px}.field-label input,.field-label select,.field-label textarea,.field-row input,.table-toolbar input,.table-toolbar select{border:1px solid #e1e8e1;border-radius:9px;background:#fff;padding:11px 12px;outline:none;color:#29382e;font-size:11px;width:100%;min-width:0}.field-label textarea{resize:vertical;line-height:1.6}.field-label input:focus,.field-label select:focus,.field-label textarea:focus,.field-row input:focus{border-color:#8dbb99;box-shadow:0 0 0 3px #e6f4e9}.field-row{margin:15px 0 13px}.field-row .field-label{margin-bottom:8px}.full-btn{width:100%;display:flex;justify-content:space-between;align-items:center;margin-top:8px}.microcopy{font-size:9px;line-height:1.65;color:#9aa59c;margin:12px 0 0}.alert{margin-top:13px;padding:11px 12px;background:#fff1ee;border:1px solid #f1d4cd;border-radius:9px;color:#9c4a3a;font-size:11px;line-height:1.5}.steps{display:flex;flex-direction:column;gap:21px;padding:6px 0 24px}.step{display:flex;gap:13px;align-items:flex-start}.step>span{width:27px;height:27px;border-radius:9px;background:#e8f4eb;color:#397d50;display:grid;place-items:center;font-weight:700;font-size:10px;flex-shrink:0}.step strong{font-size:11px}.step p{font-size:10px;line-height:1.65;color:#8b978e;margin:6px 0 0}.model-tip{display:flex;gap:11px;background:#f3f7f1;border-radius:11px;padding:14px 12px}.model-tip>span{color:#4c9261;font-size:17px}.model-tip strong{font-size:10px}.model-tip p{font-size:10px;color:#859187;line-height:1.6;margin:5px 0 0}.privacy-line{margin-top:19px;text-align:center;color:#8b988e;font-size:9px}.privacy-line span{color:#4d9362;margin-right:4px}.privacy-line b{color:#cad2ca;margin:0 5px}.records-heading{display:flex;align-items:flex-end;justify-content:space-between}.table-panel{padding:0;overflow:hidden}.table-toolbar{display:flex;align-items:center;gap:11px;padding:17px 19px;border-bottom:1px solid #edf0ed}.searchbox{display:flex;align-items:center;gap:8px;flex:1;border:1px solid #e1e8e1;border-radius:9px;padding:0 11px;color:#829087}.searchbox input{border:0!important;box-shadow:none!important;padding:11px 0!important}.table-toolbar select{width:170px}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;white-space:nowrap}th{text-align:left;background:#fbfcfb;color:#9aa49c;font-size:8px;letter-spacing:1px;font-weight:700;padding:13px 18px;border-bottom:1px solid #edf0ed}td{padding:15px 18px;border-bottom:1px solid #f0f2f0;font-size:10px;color:#5d6b60}td strong{font-size:11px;color:#344239}.table-merchant{display:flex;align-items:center;gap:10px}.table-merchant .doc-icon{width:31px;height:32px;font-size:14px}.table-merchant small{display:block;color:#a0a9a2;font-size:9px;margin-top:4px}.status-tag{display:inline-block;background:#fff4dd;color:#a87b2b;border-radius:6px;padding:5px 7px;font-size:8px;font-weight:700}.status-tag.reviewed{background:#e6f5e9;color:#3b8153}.action-btn{border:0;background:transparent;color:#718578;font-size:15px;padding:5px}.action-btn:hover{color:#bd5548}.modal-backdrop{position:fixed;inset:0;background:rgba(12,25,17,.48);z-index:20;display:flex;align-items:center;justify-content:center;padding:22px;overflow-y:auto}.review-modal{background:#fff;border-radius:19px;max-width:710px;width:100%;padding:27px;box-shadow:0 25px 90px #10251a35;max-height:95vh;overflow-y:auto}.modal-head{display:flex;justify-content:space-between;gap:20px;margin-bottom:23px}.modal-head h2{font:700 23px Manrope;letter-spacing:-.6px;margin:0}.modal-head p{font-size:11px;color:#879289;margin:7px 0 0}.close-btn{border:0;background:#f2f5f2;border-radius:9px;width:32px;height:32px;font-size:21px;color:#68766c}.review-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 15px}.review-grid .field-label{margin-bottom:13px}.wide-field{grid-column:1/-1}.review-meta{font-size:9px;color:#98a29a;padding:5px 0 14px}.modal-actions{display:flex;justify-content:flex-end;gap:9px;border-top:1px solid #edf0ed;padding-top:16px}.toast{position:fixed;right:24px;bottom:24px;z-index:30;background:#173e2a;color:white;border-radius:11px;padding:13px 17px;box-shadow:0 9px 35px #132b1e33;font-size:11px;max-width:360px}.toast.error{background:#8e3d32}@media(max-width:1100px){.sidebar{width:215px}.main{margin-left:215px;width:calc(100% - 215px)}.page{padding:32px 28px 22px}.topbar{padding:0 28px}.footer{padding:17px 28px 22px}.stats-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.content-grid{grid-template-columns:1fr}.category-panel{min-height:260px}.category-chart{display:grid;grid-template-columns:1fr 1fr;align-content:start}.cat-row{grid-template-columns:75px 1fr 56px}}@media(max-width:760px){.sidebar{width:65px;padding:20px 8px}.brand{padding:0 5px 32px}.brand>div:last-child,.side-label,.nav-item:not(.active) b,.nav-item{font-size:0}.brand-mark{width:40px;height:40px;flex-shrink:0}.nav-item{justify-content:center;padding:12px 0;font-size:0}.nav-item span{font-size:19px}.nav-item.active{background:#263a30}.nav-item b{display:none}.privacy-card{padding:10px 6px;text-align:center}.privacy-card strong,.privacy-card p,.local-status span,.version{display:none}.privacy-icon{margin:0;font-size:18px}.local-status{border:0;justify-content:center;padding-top:8px}.main{margin-left:65px;width:calc(100% - 65px)}.topbar{height:59px;padding:0 16px}.page{padding:27px 16px 20px}.welcome-row{align-items:flex-start;flex-direction:column;margin-bottom:22px}.welcome-row h1{font-size:35px}.welcome-row .primary-btn{width:100%;display:flex;justify-content:space-between}.stats-grid{gap:10px}.stat-card{padding:14px}.stat-value{font-size:22px}.content-grid{gap:13px}.panel{padding:17px}.intake-grid{grid-template-columns:1fr}.page-heading h1{font-size:30px}.footer{padding:16px;flex-direction:column;gap:8px}.local-pill{font-size:8px}.records-heading{align-items:flex-start;gap:15px;flex-direction:column}.table-toolbar{padding:12px;flex-direction:column;align-items:stretch}.table-toolbar select{width:100%}.review-modal{padding:20px}.review-grid{grid-template-columns:1fr}.wide-field{grid-column:auto}.modal-backdrop{padding:10px}.category-chart{grid-template-columns:1fr}.breadcrumb{font-size:10px}.top-actions{gap:8px}}'''
app_js = r'''const $ = (id) => document.getElementById(id);
let records = [];
let selectedFile = null;
let pendingAnalysis = null;
const currencyFmt = (value, currency) => {
if (value === null || value === undefined || value === '') return '—';
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
const c = (currency || 'INR').toUpperCase();
const symbols = {INR:'₹',USD:'$',EUR:'€',GBP:'£',JPY:'¥',CAD:'CA$',AUD:'A$'};
return (symbols[c] || (currency && currency.length <= 3 ? currency + ' ' : '')) + n.toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
};
function escapeHtml(s=''){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
async function api(path, body) {
const res = await fetch(path, {method: body === undefined ? 'GET' : 'POST',headers: body === undefined ? {} : {'Content-Type':'application/json'},body: body === undefined ? undefined : JSON.stringify(body)});
let data={}; try{data=await res.json()}catch{}
if(!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
function toast(message,error=false){const el=$('toast');el.textContent=message;el.classList.toggle('error',error);el.classList.remove('hidden');clearTimeout(toast.timer);toast.timer=setTimeout(()=>el.classList.add('hidden'),3500);}
function showView(name){
['overview','add','records'].forEach(v=>$(v+'-view').classList.toggle('hidden',v!==name));
document.querySelectorAll('.nav-item').forEach(b=>b.classList.toggle('active',b.dataset.view===name));
$('crumb').textContent={overview:'Overview',add:'Add document',records:'All documents'}[name]||'Overview';
if(name==='add') checkHealth();
if(name==='records') renderRecords();
window.scrollTo({top:0,behavior:'smooth'});
}
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>showView(b.dataset.view)));
document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>showView(b.dataset.go)));
async function checkHealth(){
try{
const h=await api('/api/health');
const dot=$('status-dot'), text=$('status-text');
dot.classList.toggle('online',h.ollama);
text.textContent=h.ollama?`Ollama ready · ${h.models?.length||0} model(s)`:'Ollama not detected';
if(h.ollama && h.models?.length && !$('model-name').dataset.userChanged){
const vision=h.models.find(m=>/qwen.*vl|llama.*vision|llava|gemma.*3/i.test(m));
if(vision) $('model-name').value=vision;
}
}catch{$('status-text').textContent='Local AI status unavailable';}
}
$('model-name').addEventListener('input',()=>{$('model-name').dataset.userChanged='1';});
async function loadRecords(){try{const d=await api('/api/records');records=d.records||[];renderAll();}catch(e){toast(e.message,true)}}
function totalOf(r){const n=Number(r.total);return Number.isFinite(n)?n:0;}
function renderAll(){
$('nav-count').textContent=records.length;
$('stat-docs').textContent=records.length;
$('stat-review').textContent=records.filter(r=>r.status!=='reviewed').length;
const sum=records.reduce((a,r)=>a+totalOf(r),0);
$('stat-total').textContent=currencyFmt(sum,records.find(r=>r.currency)?.currency||'INR');
const cats={};records.forEach(r=>{const c=r.category||'Other';cats[c]=(cats[c]||0)+totalOf(r)});
const top=Object.entries(cats).sort((a,b)=>b[1]-a[1])[0];
$('stat-category').textContent=top?top[0]:'—';
const recent=$('recent-list');
if(!records.length){recent.innerHTML='<div class="empty-state"><div class="empty-icon">▤</div><strong>No documents yet</strong><p>Add a receipt to start your expense desk.</p><button class="outline-btn" data-go="add">Add first document</button></div>';recent.querySelector('[data-go]')?.addEventListener('click',()=>showView('add'));}
else recent.innerHTML=records.slice(0,5).map(r=>`<div class="doc-row"><div class="doc-icon">${r.document_type==='Invoice'?'▧':'▤'}</div><div class="doc-main"><strong>${escapeHtml(r.merchant||'Unknown merchant')}</strong><small>${escapeHtml(r.category||'Other')} · ${escapeHtml(r.date||'Date unknown')}</small></div><div class="doc-amount"><strong>${currencyFmt(r.total,r.currency)}</strong><small>${r.status==='reviewed'?'Reviewed':'Needs review'}</small></div></div>`).join('');
const chart=$('category-chart');
const entries=Object.entries(cats).sort((a,b)=>b[1]-a[1]).slice(0,6), max=entries[0]?.[1]||1;
chart.innerHTML=entries.length?entries.map(([c,v])=>`<div class="cat-row"><span class="cat-name">${escapeHtml(c)}</span><div class="bar-bg"><div class="bar-fill" style="width:${Math.max(2,v/max*100)}%"></div></div><span class="cat-amount">${currencyFmt(v,records.find(r=>r.currency)?.currency||'INR')}</span></div>`).join(''):'<div class="chart-empty">Your category breakdown will appear here.</div>';
renderRecords();
}
function renderRecords(){
const q=($('search-input')?.value||'').toLowerCase(), cat=$('category-filter')?.value||'';
const filtered=records.filter(r=>(!cat||r.category===cat)&&[r.merchant,r.category,r.invoice_number,r.source_name,r.notes].some(v=>String(v||'').toLowerCase().includes(q)));
$('records-body').innerHTML=filtered.map(r=>`<tr><td><div class="table-merchant"><div class="doc-icon">${r.document_type==='Invoice'?'▧':'▤'}</div><div><strong>${escapeHtml(r.merchant||'Unknown merchant')}</strong><small>${escapeHtml(r.document_type||'Receipt')}${r.invoice_number?' · '+escapeHtml(r.invoice_number):''}</small></div></div></td><td>${escapeHtml(r.date||'—')}</td><td>${escapeHtml(r.category||'Other')}</td><td><strong>${currencyFmt(r.total,r.currency)}</strong></td><td><span class="status-tag ${r.status==='reviewed'?'reviewed':''}">${r.status==='reviewed'?'REVIEWED':'REVIEW'}</span></td><td><button class="action-btn edit-record" data-id="${escapeHtml(r.id)}" title="Review/edit">✎</button><button class="action-btn delete-record" data-id="${escapeHtml(r.id)}" title="Delete">×</button></td></tr>`).join('');
$('records-empty').classList.toggle('hidden',filtered.length!==0);
$('records-body').querySelectorAll('.edit-record').forEach(b=>b.addEventListener('click',()=>openReview(records.find(r=>r.id===b.dataset.id))));
$('records-body').querySelectorAll('.delete-record').forEach(b=>b.addEventListener('click',()=>deleteRecord(b.dataset.id)));
}
$('search-input').addEventListener('input',renderRecords);$('category-filter').addEventListener('change',renderRecords);
function setFile(file){
if(!file)return;
if(!/^image\/(jpeg|png|webp)$/.test(file.type)){toast('Please select a JPG, PNG, or WEBP image.',true);return;}
if(file.size>10*1024*1024){toast('Image is larger than 10 MB. Choose a smaller file.',true);return;}
selectedFile=file;
$('file-preview').textContent='✓ '+file.name+' · '+(file.size/1024).toFixed(0)+' KB';
$('file-preview').classList.remove('hidden');
}
$('file-input').addEventListener('change',e=>setFile(e.target.files[0]));
const dz=$('dropzone');
dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('dragover')});
dz.addEventListener('dragleave',()=>dz.classList.remove('dragover'));
dz.addEventListener('drop',e=>{e.preventDefault();dz.classList.remove('dragover');setFile(e.dataTransfer.files[0])});
function fileToBase64(file){return new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(String(r.result).split(',')[1]);r.onerror=reject;r.readAsDataURL(file);});}
$('analyze-btn').addEventListener('click',async()=>{
const text=$('receipt-text').value.trim();
if(!text&&!selectedFile){showError('Upload a receipt image or paste some receipt text first.');return;}
const btn=$('analyze-btn');btn.disabled=true;btn.innerHTML='◌ Analyzing locally… <span>please wait</span>';$('analyze-error').classList.add('hidden');
try{
const body={text,model:$('model-name').value.trim()||'qwen2.5vl:3b',filename:selectedFile?.name||'Manual entry'};
if(selectedFile){body.image=await fileToBase64(selectedFile);body.mime=selectedFile.type;}
const result=await api('/api/analyze',body);
pendingAnalysis=result.record;openReview(pendingAnalysis);await loadRecords();
$('receipt-text').value='';selectedFile=null;$('file-input').value='';$('file-preview').classList.add('hidden');
}catch(e){showError(e.message);}
finally{btn.disabled=false;btn.innerHTML='✦ Extract with local AI <span>→</span>';}
});
function showError(msg){$('analyze-error').textContent=msg;$('analyze-error').classList.remove('hidden');}
function openReview(r){
if(!r)return;
$('edit-id').value=r.id||'';
const fields={merchant:r.merchant,type:r.document_type||'Receipt',date:r.date,currency:r.currency,total:r.total,subtotal:r.subtotal,tax:r.tax,payment:r.payment_method,invoice:r.invoice_number,notes:r.notes,category:r.category||'Other'};
Object.entries(fields).forEach(([k,v])=>{const el=$('f-'+k);if(el)el.value=(v===null||v===undefined)?'':v;});
$('review-meta').textContent=`Source: ${r.source_name||'Manual entry'} · AI model: ${r.ai_model||'Not recorded'} · Confidence: ${r.confidence||'not specified'}. Please verify all amounts.`;
$('modal-backdrop').classList.remove('hidden');
}
function closeReview(){$('modal-backdrop').classList.add('hidden');}
$('close-modal').addEventListener('click',closeReview);$('cancel-review').addEventListener('click',closeReview);
$('modal-backdrop').addEventListener('click',e=>{if(e.target===$('modal-backdrop'))closeReview()});
$('review-form').addEventListener('submit',async e=>{
e.preventDefault();
const id=$('edit-id').value;
const old=records.find(r=>r.id===id)||pendingAnalysis||{};
const num=id=>$(id).value.trim()===''?null:Number($(id).value);
const record={...old,merchant:$('f-merchant').value.trim(),document_type:$('f-type').value,date:$('f-date').value,currency:$('f-currency').value.trim()||'INR',total:num('f-total'),subtotal:num('f-subtotal'),tax:num('f-tax'),payment_method:$('f-payment').value.trim(),invoice_number:$('f-invoice').value.trim(),notes:$('f-notes').value.trim(),category:$('f-category').value,status:'reviewed'};
try{await api('/api/record',{record});closeReview();await loadRecords();showView('records');toast('Document saved on this computer.');}
catch(err){toast(err.message,true);}
});
async function deleteRecord(id){if(!confirm('Delete this document from this computer? This cannot be undone.'))return;try{await api('/api/delete',{id});await loadRecords();toast('Document deleted.');}catch(e){toast(e.message,true)}}
$('export-btn').addEventListener('click',()=>{
const cols=['merchant','document_type','date','currency','subtotal','tax','total','category','payment_method','invoice_number','notes','confidence','status','source_name','created_at'];
const quote=v=>'"'+String(v??'').replace(/"/g,'""')+'"';
const csv=[cols.join(','),...records.map(r=>cols.map(c=>quote(r[c])).join(','))].join('\r\n');
const url=URL.createObjectURL(new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8;'}));const a=document.createElement('a');a.href=url;a.download='billbuddy-expenses.csv';a.click();URL.revokeObjectURL(url);
});
loadRecords();checkHealth();
'''
readme = r'''# BillBuddy AI — Local-first invoice & receipt organizer
A small side-hustle MVP for freelancers and small businesses. It uses an open-weight model through [Ollama](https://ollama.com/) to extract invoice/receipt fields, stores records locally in a JSON file, and exports them as CSV.
## What works
- Responsive dashboard with total tracked, document count, review queue, category breakdown.
- Upload a receipt image (JPG, PNG, WEBP) for vision-model extraction.
- Paste receipt text and extract fields with a local text or vision model.
- Review/edit merchant, date, currency, subtotal, tax, total, category, payment method, invoice number, and notes.
- Local JSON persistence, search/filter, delete, CSV export.
- Local Ollama status check and model selection.
## Privacy and open innovation
- The app server binds to `127.0.0.1`, so it is intended for use on the same computer.
- Documents and extracted records are stored in `data/records.json`.
- AI inference is sent to the local Ollama endpoint, not a hosted AI API.
- Open-weight models can be swapped, and prompts can be changed to match a freelancer's categories or accounting workflow.
- Once Python, Ollama, and the model are installed, document analysis can run without internet access. Downloading/installing the model needs internet the first time.
- No API key or per-document cloud AI fee is required. Hardware still consumes power and larger models need more RAM/VRAM.
- The Google Fonts import is cosmetic and may contact Google when online. The app itself does not require fonts to work; remove the `@import` line in `styles.css` for stricter offline use.
## Requirements
- Python 3.9+ (standard library only)
- Ollama installed and running: https://ollama.com/
- A compatible vision model for image uploads. Example: `qwen2.5vl:3b`
- A text model also works when you paste receipt text; image uploads need a vision-capable model.
## Run locally
1. Install Ollama and start it.
2. Download a compatible vision model in a terminal:
bash
ollama pull qwen2.5vl:3b
3. Extract this project ZIP and open a terminal in the `billbuddy_ai` folder.
4. Run:
bash
python server.py
5. Open http://127.0.0.1:8765
If you use a different model:
bash
OLLAMA_MODEL=your-model-name python server.py
The model selector in the app can also be changed per request. For an alternate Ollama server URL:
bash
OLLAMA_URL=http://127.0.0.1:11434 python server.py
## Important MVP limitations
- The model's extracted values can be wrong; verify all amounts and dates before bookkeeping or tax use.
- Image uploads are supported; direct PDF upload and automatic bank/accounting integrations are not implemented yet.
- This is a local single-user prototype, not audited accounting software. Back up `data/records.json` yourself.
- The app does not encrypt the local JSON database. Use normal device encryption and a strong device login for sensitive records.
- The first version stores a record after extraction and opens a review modal. If extraction is cancelled, the draft may already exist with `status: review`; delete it from All Documents if needed.
## Suggested demo flow
1. Open the app and show the local AI status.
2. Upload a sample receipt image or paste sample receipt text.
3. Show extracted fields, edit one field, and save.
4. Show the dashboard and category breakdown.
5. Export CSV.
6. Explain that the open-weight model runs locally and that the prompt/model can be adapted without being locked to a cloud provider.
## Side Hustle positioning
**Customer:** freelancers, solo consultants, and micro-business owners who manage a small volume of receipts.
**Value proposition:** “Turn receipts into an organized, exportable expense log without uploading sensitive financial documents to a third-party AI service.”
**Possible pricing to test:** a one-time local license or a paid setup/customization package. Validate willingness to pay with a few target customers before setting a final price.
'''
open(os.path.join(base,"server.py"),"w",encoding="utf-8").write(server_py)
open(os.path.join(base,"index.html"),"w",encoding="utf-8").write(index_html)
open(os.path.join(base,"styles.css"),"w",encoding="utf-8").write(styles_css)
open(os.path.join(base,"app.js"),"w",encoding="utf-8").write(app_js)
open(os.path.join(base,"README.md"),"w",encoding="utf-8").write(readme)
open(os.path.join(base,"data","records.json"),"w",encoding="utf-8").write("[]\n")
zip_path="/mnt/data/BillBuddy_AI_MVP.zip"
with zipfile.ZipFile(zip_path,"w",zipfile.ZIP_DEFLATED) as z:
for root, dirs, files in os.walk(base):
for f in files:
p=os.path.join(root,f)
z.write(p,os.path.relpath(p,os.path.dirname(base)))
print("Created", zip_path, "with", os.path.getsize(zip_path), "bytes")
Top comments (0)