<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aditya Narayan</title>
    <description>The latest articles on DEV Community by Aditya Narayan (@aditya_narayan_7a94f8bd1c).</description>
    <link>https://dev.to/aditya_narayan_7a94f8bd1c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4171708%2Fdb566787-785c-492b-ae06-76c67b160512.png</url>
      <title>DEV Community: Aditya Narayan</title>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aditya_narayan_7a94f8bd1c"/>
    <language>en</language>
    <item>
      <title>GHOST IN THE MACHINE</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:35:23 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/ghost-in-the-machine-5gl7</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/ghost-in-the-machine-5gl7</guid>
      <description>&lt;p&gt;👻 Ghost in the Machine — Some Programs Never Really Die&lt;/p&gt;

&lt;p&gt;What happens when you give a forgotten piece of technology a voice?&lt;/p&gt;

&lt;p&gt;I built Ghost in the Machine, an offline AI séance where you can summon the spirit of an abandoned programming language, a forgotten computer, or a lost corner of software history.&lt;/p&gt;

&lt;p&gt;The ghost lives in your terminal. It speaks in cryptic messages, tells stories from another era, and invites you to remember the technologies that helped shape our digital world.&lt;/p&gt;

&lt;p&gt;Under the hood, it uses an open-weight language model running locally, with an open, modifiable Python-based experience. No cloud API is required during offline use.&lt;/p&gt;

&lt;p&gt;Why open AI?&lt;/p&gt;

&lt;p&gt;Because a ghost shouldn't depend on a server that might disappear.&lt;/p&gt;

&lt;p&gt;Running locally means conversations can stay on your machine. Swappable models let anyone experiment with a different personality, and an open implementation means the experience can be studied, modified, and preserved.&lt;/p&gt;

&lt;p&gt;There's something fitting about using open technology to honor forgotten technology: software that people can inspect, carry forward, and bring back to life.&lt;/p&gt;

&lt;p&gt;This Halloween, we're not just summoning ghosts.&lt;/p&gt;

&lt;p&gt;We're giving the past a place to speak.&lt;/p&gt;

&lt;p&gt;Some programs never really die. They just wait for someone to run them again.&lt;/p&gt;

&lt;h1&gt;
  
  
  OpenSourceAI #LocalAI #Halloween #GhostInTheMachine #BuildInPublic
&lt;/h1&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Why innovation matters</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Tue, 20 Oct 2026 16:04:44 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/why-innovation-matters-2a5g</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/why-innovation-matters-2a5g</guid>
      <description>&lt;p&gt;LifeEase AI: An Open-Source Assistant That Respects Your Privacy&lt;/p&gt;

&lt;p&gt;Everyday tasks like organizing files, summarizing documents, and planning work can consume valuable time. That's why I built the idea for LifeEase AI, a personal assistant designed to make these tasks simpler, faster, and more private.&lt;/p&gt;

&lt;p&gt;LifeEase AI uses open-source tools and locally running AI models to help users organize their digital lives. Instead of relying entirely on cloud-based AI services, it aims to process personal information on the user's own device.&lt;/p&gt;

&lt;p&gt;Why open innovation matters&lt;/p&gt;

&lt;p&gt;Open innovation gives developers the freedom to inspect, customize, and improve the technology they use. With open-weight AI models and open-source tools, LifeEase AI can offer greater privacy, reduce dependence on external services, and potentially operate without recurring API costs.&lt;/p&gt;

&lt;p&gt;The biggest advantage is control. Users can choose their models, adapt the software to their needs, and keep sensitive documents on their own machines. Once configured, local inference can work without an internet connection, provided the model and required dependencies are already installed.&lt;/p&gt;

&lt;p&gt;My goal is to show that AI doesn't have to be expensive or dependent on centralized services to be useful. Open technology makes practical automation more accessible, transparent, and customizable.&lt;/p&gt;

&lt;p&gt;Build once. Automate everyday life. Keep control of your data.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Why innovation matters!</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Tue, 20 Oct 2026 16:03:46 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/why-innovation-matters-4136</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/why-innovation-matters-4136</guid>
      <description>&lt;p&gt;LifeEase AI: An Open-Source Assistant That Respects Your Privacy&lt;/p&gt;

&lt;p&gt;Everyday tasks like organizing files, summarizing documents, and planning work can consume valuable time. That's why I built the idea for LifeEase AI, a personal assistant designed to make these tasks simpler, faster, and more private.&lt;/p&gt;

&lt;p&gt;LifeEase AI uses open-source tools and locally running AI models to help users organize their digital lives. Instead of relying entirely on cloud-based AI services, it aims to process personal information on the user's own device.&lt;/p&gt;

&lt;p&gt;Why open innovation matters&lt;/p&gt;

&lt;p&gt;Open innovation gives developers the freedom to inspect, customize, and improve the technology they use. With open-weight AI models and open-source tools, LifeEase AI can offer greater privacy, reduce dependence on external services, and potentially operate without recurring API costs.&lt;/p&gt;

&lt;p&gt;The biggest advantage is control. Users can choose their models, adapt the software to their needs, and keep sensitive documents on their own machines. Once configured, local inference can work without an internet connection, provided the model and required dependencies are already installed.&lt;/p&gt;

&lt;p&gt;My goal is to show that AI doesn't have to be expensive or dependent on centralized services to be useful. Open technology makes practical automation more accessible, transparent, and customizable.&lt;/p&gt;

&lt;p&gt;Build once. Automate everyday life. Keep control of your data.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>BUILDING BILLBUDDY MVP</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Wed, 14 Oct 2026 06:38:20 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/building-billbuddy-mvp-9og</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/building-billbuddy-mvp-9og</guid>
      <description>&lt;p&gt;import os, json, zipfile, textwrap&lt;/p&gt;

&lt;p&gt;base = "/mnt/data/billbuddy_ai"&lt;br&gt;
os.makedirs(base, exist_ok=True)&lt;br&gt;
os.makedirs(os.path.join(base, "data"), exist_ok=True)&lt;/p&gt;

&lt;p&gt;server_py = r'''#!/usr/bin/env python3&lt;br&gt;
"""BillBuddy AI — local-first invoice and receipt organizer.&lt;br&gt;
Uses only Python's standard library. Optional AI analysis runs through Ollama on this computer.&lt;br&gt;
"""&lt;br&gt;
import base64, csv, io, json, mimetypes, os, re, threading, urllib.error, urllib.request&lt;br&gt;
from datetime import datetime&lt;br&gt;
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer&lt;br&gt;
from pathlib import Path&lt;br&gt;
from urllib.parse import urlparse&lt;/p&gt;

&lt;p&gt;ROOT = Path(&lt;strong&gt;file&lt;/strong&gt;).resolve().parent&lt;br&gt;
DATA_DIR = ROOT / "data"&lt;br&gt;
RECORDS_FILE = DATA_DIR / "records.json"&lt;br&gt;
HOST = "127.0.0.1"&lt;br&gt;
PORT = int(os.environ.get("PORT", "8765"))&lt;br&gt;
OLLAMA_URL = os.environ.get("OLLAMA_URL", "&lt;a href="http://127.0.0.1:11434%22" rel="noopener noreferrer"&gt;http://127.0.0.1:11434"&lt;/a&gt;)&lt;br&gt;
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5vl:3b")&lt;br&gt;
MAX_BODY = 15 * 1024 * 1024&lt;br&gt;
LOCK = threading.Lock()&lt;/p&gt;

&lt;p&gt;def load_records():&lt;br&gt;
    DATA_DIR.mkdir(exist_ok=True)&lt;br&gt;
    if not RECORDS_FILE.exists():&lt;br&gt;
        return []&lt;br&gt;
    try:&lt;br&gt;
        data = json.loads(RECORDS_FILE.read_text(encoding="utf-8"))&lt;br&gt;
        return data if isinstance(data, list) else []&lt;br&gt;
    except Exception:&lt;br&gt;
        return []&lt;/p&gt;

&lt;p&gt;def save_records(records):&lt;br&gt;
    DATA_DIR.mkdir(exist_ok=True)&lt;br&gt;
    temp = RECORDS_FILE.with_suffix(".tmp")&lt;br&gt;
    temp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")&lt;br&gt;
    temp.replace(RECORDS_FILE)&lt;/p&gt;

&lt;p&gt;def ollama_request(payload, timeout=120):&lt;br&gt;
    req = urllib.request.Request(&lt;br&gt;
        OLLAMA_URL.rstrip("/") + "/api/chat",&lt;br&gt;
        data=json.dumps(payload).encode("utf-8"),&lt;br&gt;
        headers={"Content-Type": "application/json"},&lt;br&gt;
        method="POST",&lt;br&gt;
    )&lt;br&gt;
    with urllib.request.urlopen(req, timeout=timeout) as response:&lt;br&gt;
        return json.loads(response.read().decode("utf-8"))&lt;/p&gt;

&lt;p&gt;def parse_json_from_text(text):&lt;br&gt;
    text = text.strip()&lt;br&gt;
    if text.startswith("&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        text = re.sub(r"^```

(?:json)?\s*", "", text)
        text = re.sub(r"\s*

```$", "", text)
    start, end = text.find("{"), text.rfind("}")
    if start &amp;gt;= 0 and end &amp;gt; 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 &amp;gt; 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'''&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset="utf-8"&amp;gt;&amp;lt;meta name="viewport" content="width=device-width, initial-scale=1"&amp;gt;
  &amp;lt;meta name="theme-color" content="#101b18"&amp;gt;
  &amp;lt;title&amp;gt;BillBuddy AI — Private expense desk&amp;lt;/title&amp;gt;
  &amp;lt;link rel="stylesheet" href="/styles.css"&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;div class="app-shell"&amp;gt;
    &amp;lt;aside class="sidebar"&amp;gt;
      &amp;lt;div class="brand"&amp;gt;&amp;lt;div class="brand-mark"&amp;gt;B&amp;lt;/div&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;billbuddy&amp;lt;span&amp;gt;.ai&amp;lt;/span&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;PRIVATE EXPENSE DESK&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
      &amp;lt;div class="side-label"&amp;gt;WORKSPACE&amp;lt;/div&amp;gt;
      &amp;lt;button class="nav-item active" data-view="overview"&amp;gt;&amp;lt;span&amp;gt;◫&amp;lt;/span&amp;gt; Overview&amp;lt;/button&amp;gt;
      &amp;lt;button class="nav-item" data-view="records"&amp;gt;&amp;lt;span&amp;gt;▤&amp;lt;/span&amp;gt; All documents &amp;lt;b id="nav-count"&amp;gt;0&amp;lt;/b&amp;gt;&amp;lt;/button&amp;gt;
      &amp;lt;button class="nav-item" data-view="add"&amp;gt;&amp;lt;span&amp;gt;＋&amp;lt;/span&amp;gt; Add document&amp;lt;/button&amp;gt;
      &amp;lt;div class="sidebar-bottom"&amp;gt;
        &amp;lt;div class="privacy-card"&amp;gt;&amp;lt;div class="privacy-icon"&amp;gt;⌁&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;Your data stays yours.&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Documents and records are stored on this computer. AI requests go to your local Ollama service.&amp;lt;/p&amp;gt;&amp;lt;div class="local-status"&amp;gt;&amp;lt;i id="status-dot"&amp;gt;&amp;lt;/i&amp;gt;&amp;lt;span id="status-text"&amp;gt;Checking local AI…&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div class="version"&amp;gt;BILLBUDDY AI &amp;lt;span&amp;gt;·&amp;lt;/span&amp;gt; LOCAL-FIRST MVP&amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/aside&amp;gt;
    &amp;lt;main class="main"&amp;gt;
      &amp;lt;header class="topbar"&amp;gt;&amp;lt;div class="breadcrumb"&amp;gt;Workspace &amp;lt;span&amp;gt;/&amp;lt;/span&amp;gt; &amp;lt;b id="crumb"&amp;gt;Overview&amp;lt;/b&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="top-actions"&amp;gt;&amp;lt;span class="local-pill"&amp;gt;&amp;lt;i&amp;gt;&amp;lt;/i&amp;gt; LOCAL MODE&amp;lt;/span&amp;gt;&amp;lt;div class="avatar"&amp;gt;BB&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/header&amp;gt;
      &amp;lt;section class="page" id="overview-view"&amp;gt;
        &amp;lt;div class="welcome-row"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;YOUR MONEY, ORGANIZED&amp;lt;/div&amp;gt;&amp;lt;h1&amp;gt;Good work deserves&amp;lt;br&amp;gt;&amp;lt;em&amp;gt;clear books.&amp;lt;/em&amp;gt;&amp;lt;/h1&amp;gt;&amp;lt;p class="subtitle"&amp;gt;Your receipts, invoices, and expenses — organized with private, local AI.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button class="primary-btn" data-go="add"&amp;gt;＋ Add a document &amp;lt;span&amp;gt;↗&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div class="stats-grid"&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Total tracked&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon green"&amp;gt;₹&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value" id="stat-total"&amp;gt;₹0.00&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;Across saved documents&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Documents&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon violet"&amp;gt;▤&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value" id="stat-docs"&amp;gt;0&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;Receipts and invoices&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Need review&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon amber"&amp;gt;⌕&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value" id="stat-review"&amp;gt;0&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;Check AI-extracted details&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Top category&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon blue"&amp;gt;◈&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value category-stat" id="stat-category"&amp;gt;—&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;By tracked amount&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class="content-grid"&amp;gt;
          &amp;lt;section class="panel recent-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;Recent documents&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Your latest processed receipts and invoices&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button class="text-btn" data-go="records"&amp;gt;View all &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div id="recent-list" class="document-list"&amp;gt;&amp;lt;div class="empty-state"&amp;gt;&amp;lt;div class="empty-icon"&amp;gt;▤&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;No documents yet&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Add a receipt to start your expense desk.&amp;lt;/p&amp;gt;&amp;lt;button class="outline-btn" data-go="add"&amp;gt;Add first document&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/section&amp;gt;
          &amp;lt;section class="panel category-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;Spending snapshot&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Where your money is going&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;span class="tiny-tag"&amp;gt;ALL TIME&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div id="category-chart" class="category-chart"&amp;gt;&amp;lt;div class="chart-empty"&amp;gt;Your category breakdown will appear here.&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="chart-note"&amp;gt;&amp;lt;span class="legend-dot"&amp;gt;&amp;lt;/span&amp;gt; Tracked document totals&amp;lt;/div&amp;gt;&amp;lt;/section&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class="bottom-note"&amp;gt;&amp;lt;span class="sparkle"&amp;gt;✳&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;AI is your assistant, not your accountant.&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Always review extracted amounts, dates, and tax details before using them for bookkeeping or filing.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
      &amp;lt;/section&amp;gt;
      &amp;lt;section class="page hidden" id="add-view"&amp;gt;
        &amp;lt;div class="page-heading"&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;DOCUMENT INTAKE&amp;lt;/div&amp;gt;&amp;lt;h1&amp;gt;Add a document&amp;lt;/h1&amp;gt;&amp;lt;p class="subtitle"&amp;gt;Upload a receipt image or paste its text. Local AI will extract the details for you to review.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div class="intake-grid"&amp;gt;
          &amp;lt;section class="panel upload-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;Upload receipt&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;JPG, PNG, or WEBP image · up to 10 MB&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;span class="step-badge"&amp;gt;01&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;label class="dropzone" id="dropzone" for="file-input"&amp;gt;&amp;lt;input id="file-input" type="file" accept="image/png,image/jpeg,image/webp"&amp;gt;&amp;lt;div class="upload-symbol"&amp;gt;↑&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;Drop a receipt here&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;or &amp;lt;u&amp;gt;browse files&amp;lt;/u&amp;gt;&amp;lt;/p&amp;gt;&amp;lt;small&amp;gt;Your image is sent only to your local Ollama model.&amp;lt;/small&amp;gt;&amp;lt;div id="file-preview" class="file-preview hidden"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/label&amp;gt;
            &amp;lt;div class="or-divider"&amp;gt;&amp;lt;span&amp;gt;OR ENTER TEXT&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;label class="field-label" for="receipt-text"&amp;gt;Receipt / invoice text&amp;lt;/label&amp;gt;&amp;lt;textarea id="receipt-text" rows="6" placeholder="Paste text from an invoice or receipt…&amp;amp;#10;&amp;amp;#10;Example: Green Basket, 12 Oct 2026, Groceries ₹840.50, GST ₹42.00, Total ₹882.50"&amp;gt;&amp;lt;/textarea&amp;gt;
            &amp;lt;div class="field-row"&amp;gt;&amp;lt;label class="field-label" for="model-name"&amp;gt;Local Ollama model&amp;lt;/label&amp;gt;&amp;lt;input id="model-name" value="qwen2.5vl:3b" spellcheck="false"&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;button id="analyze-btn" class="primary-btn full-btn"&amp;gt;✦ Extract with local AI &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;p class="microcopy"&amp;gt;No cloud AI API key. First-time model download requires internet; afterward, inference can run locally.&amp;lt;/p&amp;gt;
            &amp;lt;div id="analyze-error" class="alert hidden"&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;/section&amp;gt;
          &amp;lt;aside class="panel how-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;How it works&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Private by design&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="steps"&amp;gt;&amp;lt;div class="step"&amp;gt;&amp;lt;span&amp;gt;1&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;Choose a document&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Upload a receipt photo or paste text copied from a document.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="step"&amp;gt;&amp;lt;span&amp;gt;2&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;Local AI reads it&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;The model extracts merchant, date, totals, tax, and category on your computer.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="step"&amp;gt;&amp;lt;span&amp;gt;3&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;You review the result&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Correct any mistakes and save the record to your local expense list.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="model-tip"&amp;gt;&amp;lt;span&amp;gt;✳&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;Open models, your choice&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Change the model to another compatible Ollama vision model. Text-only input also works with text models.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="privacy-line"&amp;gt;&amp;lt;span&amp;gt;⌁&amp;lt;/span&amp;gt; Local storage &amp;lt;b&amp;gt;·&amp;lt;/b&amp;gt; Local inference &amp;lt;b&amp;gt;·&amp;lt;/b&amp;gt; No account needed&amp;lt;/div&amp;gt;&amp;lt;/aside&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/section&amp;gt;
      &amp;lt;section class="page hidden" id="records-view"&amp;gt;
        &amp;lt;div class="page-heading records-heading"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;YOUR DOCUMENT LIBRARY&amp;lt;/div&amp;gt;&amp;lt;h1&amp;gt;All documents&amp;lt;/h1&amp;gt;&amp;lt;p class="subtitle"&amp;gt;Search, review, export, or remove saved records.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button id="export-btn" class="outline-btn"&amp;gt;↓ Export CSV&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;section class="panel table-panel"&amp;gt;&amp;lt;div class="table-toolbar"&amp;gt;&amp;lt;div class="searchbox"&amp;gt;&amp;lt;span&amp;gt;⌕&amp;lt;/span&amp;gt;&amp;lt;input id="search-input" placeholder="Search merchant, category, invoice…"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;select id="category-filter"&amp;gt;&amp;lt;option value=""&amp;gt;All categories&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Food&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Travel&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Software&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Office supplies&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Utilities&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Shopping&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Healthcare&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Education&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Other&amp;lt;/option&amp;gt;&amp;lt;/select&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="table-wrap"&amp;gt;&amp;lt;table&amp;gt;&amp;lt;thead&amp;gt;&amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;MERCHANT / DOCUMENT&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;DATE&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;CATEGORY&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;TOTAL&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;STATUS&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;&amp;lt;/thead&amp;gt;&amp;lt;tbody id="records-body"&amp;gt;&amp;lt;/tbody&amp;gt;&amp;lt;/table&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div id="records-empty" class="empty-state hidden"&amp;gt;&amp;lt;div class="empty-icon"&amp;gt;▤&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;No matching documents&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Try another search or add your first document.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/section&amp;gt;
      &amp;lt;/section&amp;gt;
      &amp;lt;footer class="footer"&amp;gt;&amp;lt;span&amp;gt;BillBuddy AI &amp;lt;b&amp;gt;·&amp;lt;/b&amp;gt; Built with open AI&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;Made for independent work &amp;lt;span class="footer-heart"&amp;gt;♥&amp;lt;/span&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/footer&amp;gt;
    &amp;lt;/main&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div id="modal-backdrop" class="modal-backdrop hidden"&amp;gt;&amp;lt;section class="review-modal"&amp;gt;&amp;lt;div class="modal-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;AI EXTRACTION COMPLETE&amp;lt;/div&amp;gt;&amp;lt;h2&amp;gt;Review document&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Confirm these details before saving.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button id="close-modal" class="close-btn" aria-label="Close"&amp;gt;×&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;form id="review-form"&amp;gt;&amp;lt;input type="hidden" id="edit-id"&amp;gt;&amp;lt;div class="review-grid"&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Merchant&amp;lt;input id="f-merchant" required placeholder="Merchant name"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Document type&amp;lt;select id="f-type"&amp;gt;&amp;lt;option&amp;gt;Receipt&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Invoice&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Other&amp;lt;/option&amp;gt;&amp;lt;/select&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Date&amp;lt;input id="f-date" type="date"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Category&amp;lt;select id="f-category"&amp;gt;&amp;lt;option&amp;gt;Food&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Travel&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Software&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Office supplies&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Utilities&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Shopping&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Healthcare&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Education&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Other&amp;lt;/option&amp;gt;&amp;lt;/select&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Currency&amp;lt;input id="f-currency" placeholder="INR / ₹ / USD"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Total amount&amp;lt;input id="f-total" type="number" step="0.01" min="0" placeholder="0.00"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Subtotal&amp;lt;input id="f-subtotal" type="number" step="0.01" min="0" placeholder="Optional"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Tax&amp;lt;input id="f-tax" type="number" step="0.01" min="0" placeholder="Optional"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Payment method&amp;lt;input id="f-payment" placeholder="Cash, card, UPI…"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Invoice number&amp;lt;input id="f-invoice" placeholder="Optional"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label wide-field"&amp;gt;Notes&amp;lt;textarea id="f-notes" rows="2" placeholder="Optional notes"&amp;gt;&amp;lt;/textarea&amp;gt;&amp;lt;/label&amp;gt;
  &amp;lt;/div&amp;gt;&amp;lt;div class="review-meta" id="review-meta"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="modal-actions"&amp;gt;&amp;lt;button type="button" id="cancel-review" class="outline-btn"&amp;gt;Cancel&amp;lt;/button&amp;gt;&amp;lt;button type="submit" class="primary-btn"&amp;gt;Save document &amp;lt;span&amp;gt;✓&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/form&amp;gt;&amp;lt;/section&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;div id="toast" class="toast hidden"&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;script src="/app.js"&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
'''

styles_css = r'''@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&amp;amp;family=Manrope:wght@400;500;600;700;800&amp;amp;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&amp;gt;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&amp;gt;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&amp;gt;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) =&amp;gt; document.getElementById(id);
let records = [];
let selectedFile = null;
let pendingAnalysis = null;
const currencyFmt = (value, currency) =&amp;gt; {
  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 &amp;amp;&amp;amp; currency.length &amp;lt;= 3 ? currency + ' ' : '')) + n.toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
};
function escapeHtml(s=''){return String(s).replace(/[&amp;amp;&amp;lt;&amp;gt;"']/g,c=&amp;gt;({'&amp;amp;':'&amp;amp;amp;','&amp;lt;':'&amp;amp;lt;','&amp;gt;':'&amp;amp;gt;','"':'&amp;amp;quot;',"'":'&amp;amp;#39;'}[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(()=&amp;gt;el.classList.add('hidden'),3500);}
function showView(name){
  ['overview','add','records'].forEach(v=&amp;gt;$(v+'-view').classList.toggle('hidden',v!==name));
  document.querySelectorAll('.nav-item').forEach(b=&amp;gt;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=&amp;gt;b.addEventListener('click',()=&amp;gt;showView(b.dataset.view)));
document.querySelectorAll('[data-go]').forEach(b=&amp;gt;b.addEventListener('click',()=&amp;gt;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 &amp;amp;&amp;amp; h.models?.length &amp;amp;&amp;amp; !$('model-name').dataset.userChanged){
      const vision=h.models.find(m=&amp;gt;/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',()=&amp;gt;{$('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=&amp;gt;r.status!=='reviewed').length;
  const sum=records.reduce((a,r)=&amp;gt;a+totalOf(r),0);
  $('stat-total').textContent=currencyFmt(sum,records.find(r=&amp;gt;r.currency)?.currency||'INR');
  const cats={};records.forEach(r=&amp;gt;{const c=r.category||'Other';cats[c]=(cats[c]||0)+totalOf(r)});
  const top=Object.entries(cats).sort((a,b)=&amp;gt;b[1]-a[1])[0];
  $('stat-category').textContent=top?top[0]:'—';
  const recent=$('recent-list');
  if(!records.length){recent.innerHTML='&amp;lt;div class="empty-state"&amp;gt;&amp;lt;div class="empty-icon"&amp;gt;▤&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;No documents yet&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Add a receipt to start your expense desk.&amp;lt;/p&amp;gt;&amp;lt;button class="outline-btn" data-go="add"&amp;gt;Add first document&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;';recent.querySelector('[data-go]')?.addEventListener('click',()=&amp;gt;showView('add'));}
  else recent.innerHTML=records.slice(0,5).map(r=&amp;gt;`&amp;lt;div class="doc-row"&amp;gt;&amp;lt;div class="doc-icon"&amp;gt;${r.document_type==='Invoice'?'▧':'▤'}&amp;lt;/div&amp;gt;&amp;lt;div class="doc-main"&amp;gt;&amp;lt;strong&amp;gt;${escapeHtml(r.merchant||'Unknown merchant')}&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;${escapeHtml(r.category||'Other')} · ${escapeHtml(r.date||'Date unknown')}&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="doc-amount"&amp;gt;&amp;lt;strong&amp;gt;${currencyFmt(r.total,r.currency)}&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;${r.status==='reviewed'?'Reviewed':'Needs review'}&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;`).join('');
  const chart=$('category-chart');
  const entries=Object.entries(cats).sort((a,b)=&amp;gt;b[1]-a[1]).slice(0,6), max=entries[0]?.[1]||1;
  chart.innerHTML=entries.length?entries.map(([c,v])=&amp;gt;`&amp;lt;div class="cat-row"&amp;gt;&amp;lt;span class="cat-name"&amp;gt;${escapeHtml(c)}&amp;lt;/span&amp;gt;&amp;lt;div class="bar-bg"&amp;gt;&amp;lt;div class="bar-fill" style="width:${Math.max(2,v/max*100)}%"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;span class="cat-amount"&amp;gt;${currencyFmt(v,records.find(r=&amp;gt;r.currency)?.currency||'INR')}&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;`).join(''):'&amp;lt;div class="chart-empty"&amp;gt;Your category breakdown will appear here.&amp;lt;/div&amp;gt;';
  renderRecords();
}
function renderRecords(){
  const q=($('search-input')?.value||'').toLowerCase(), cat=$('category-filter')?.value||'';
  const filtered=records.filter(r=&amp;gt;(!cat||r.category===cat)&amp;amp;&amp;amp;[r.merchant,r.category,r.invoice_number,r.source_name,r.notes].some(v=&amp;gt;String(v||'').toLowerCase().includes(q)));
  $('records-body').innerHTML=filtered.map(r=&amp;gt;`&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&amp;lt;div class="table-merchant"&amp;gt;&amp;lt;div class="doc-icon"&amp;gt;${r.document_type==='Invoice'?'▧':'▤'}&amp;lt;/div&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;${escapeHtml(r.merchant||'Unknown merchant')}&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;${escapeHtml(r.document_type||'Receipt')}${r.invoice_number?' · '+escapeHtml(r.invoice_number):''}&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${escapeHtml(r.date||'—')}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${escapeHtml(r.category||'Other')}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&amp;lt;strong&amp;gt;${currencyFmt(r.total,r.currency)}&amp;lt;/strong&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&amp;lt;span class="status-tag ${r.status==='reviewed'?'reviewed':''}"&amp;gt;${r.status==='reviewed'?'REVIEWED':'REVIEW'}&amp;lt;/span&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&amp;lt;button class="action-btn edit-record" data-id="${escapeHtml(r.id)}" title="Review/edit"&amp;gt;✎&amp;lt;/button&amp;gt;&amp;lt;button class="action-btn delete-record" data-id="${escapeHtml(r.id)}" title="Delete"&amp;gt;×&amp;lt;/button&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;`).join('');
  $('records-empty').classList.toggle('hidden',filtered.length!==0);
  $('records-body').querySelectorAll('.edit-record').forEach(b=&amp;gt;b.addEventListener('click',()=&amp;gt;openReview(records.find(r=&amp;gt;r.id===b.dataset.id))));
  $('records-body').querySelectorAll('.delete-record').forEach(b=&amp;gt;b.addEventListener('click',()=&amp;gt;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&amp;gt;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=&amp;gt;setFile(e.target.files[0]));
const dz=$('dropzone');
dz.addEventListener('dragover',e=&amp;gt;{e.preventDefault();dz.classList.add('dragover')});
dz.addEventListener('dragleave',()=&amp;gt;dz.classList.remove('dragover'));
dz.addEventListener('drop',e=&amp;gt;{e.preventDefault();dz.classList.remove('dragover');setFile(e.dataTransfer.files[0])});
function fileToBase64(file){return new Promise((resolve,reject)=&amp;gt;{const r=new FileReader();r.onload=()=&amp;gt;resolve(String(r.result).split(',')[1]);r.onerror=reject;r.readAsDataURL(file);});}
$('analyze-btn').addEventListener('click',async()=&amp;gt;{
  const text=$('receipt-text').value.trim();
  if(!text&amp;amp;&amp;amp;!selectedFile){showError('Upload a receipt image or paste some receipt text first.');return;}
  const btn=$('analyze-btn');btn.disabled=true;btn.innerHTML='◌ Analyzing locally… &amp;lt;span&amp;gt;please wait&amp;lt;/span&amp;gt;';$('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 &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;';}
});
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])=&amp;gt;{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=&amp;gt;{if(e.target===$('modal-backdrop'))closeReview()});
$('review-form').addEventListener('submit',async e=&amp;gt;{
  e.preventDefault();
  const id=$('edit-id').value;
  const old=records.find(r=&amp;gt;r.id===id)||pendingAnalysis||{};
  const num=id=&amp;gt;$(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',()=&amp;gt;{
  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=&amp;gt;'"'+String(v??'').replace(/"/g,'""')+'"';
  const csv=[cols.join(','),...records.map(r=&amp;gt;cols.map(c=&amp;gt;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 &amp;amp; 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:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
   ollama pull qwen2.5vl:3b&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
3. Extract this project ZIP and open a terminal in the `billbuddy_ai` folder.
4. Run:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
   python server.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
5. Open http://127.0.0.1:8765

If you use a different model:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
OLLAMA_MODEL=your-model-name python server.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The model selector in the app can also be changed per request. For an alternate Ollama server URL:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
OLLAMA_URL=&lt;a href="http://127.0.0.1:11434" rel="noopener noreferrer"&gt;http://127.0.0.1:11434&lt;/a&gt; python server.py&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


## 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")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Building Bill Buddy MVP</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Wed, 14 Oct 2026 06:36:04 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/building-bill-buddy-mvp-5gc</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/building-bill-buddy-mvp-5gc</guid>
      <description>&lt;p&gt;import os, json, zipfile, textwrap&lt;/p&gt;

&lt;p&gt;base = "/mnt/data/billbuddy_ai"&lt;br&gt;
os.makedirs(base, exist_ok=True)&lt;br&gt;
os.makedirs(os.path.join(base, "data"), exist_ok=True)&lt;/p&gt;

&lt;p&gt;server_py = r'''#!/usr/bin/env python3&lt;br&gt;
"""BillBuddy AI — local-first invoice and receipt organizer.&lt;br&gt;
Uses only Python's standard library. Optional AI analysis runs through Ollama on this computer.&lt;br&gt;
"""&lt;br&gt;
import base64, csv, io, json, mimetypes, os, re, threading, urllib.error, urllib.request&lt;br&gt;
from datetime import datetime&lt;br&gt;
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer&lt;br&gt;
from pathlib import Path&lt;br&gt;
from urllib.parse import urlparse&lt;/p&gt;

&lt;p&gt;ROOT = Path(&lt;strong&gt;file&lt;/strong&gt;).resolve().parent&lt;br&gt;
DATA_DIR = ROOT / "data"&lt;br&gt;
RECORDS_FILE = DATA_DIR / "records.json"&lt;br&gt;
HOST = "127.0.0.1"&lt;br&gt;
PORT = int(os.environ.get("PORT", "8765"))&lt;br&gt;
OLLAMA_URL = os.environ.get("OLLAMA_URL", "&lt;a href="http://127.0.0.1:11434%22" rel="noopener noreferrer"&gt;http://127.0.0.1:11434"&lt;/a&gt;)&lt;br&gt;
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5vl:3b")&lt;br&gt;
MAX_BODY = 15 * 1024 * 1024&lt;br&gt;
LOCK = threading.Lock()&lt;/p&gt;

&lt;p&gt;def load_records():&lt;br&gt;
    DATA_DIR.mkdir(exist_ok=True)&lt;br&gt;
    if not RECORDS_FILE.exists():&lt;br&gt;
        return []&lt;br&gt;
    try:&lt;br&gt;
        data = json.loads(RECORDS_FILE.read_text(encoding="utf-8"))&lt;br&gt;
        return data if isinstance(data, list) else []&lt;br&gt;
    except Exception:&lt;br&gt;
        return []&lt;/p&gt;

&lt;p&gt;def save_records(records):&lt;br&gt;
    DATA_DIR.mkdir(exist_ok=True)&lt;br&gt;
    temp = RECORDS_FILE.with_suffix(".tmp")&lt;br&gt;
    temp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")&lt;br&gt;
    temp.replace(RECORDS_FILE)&lt;/p&gt;

&lt;p&gt;def ollama_request(payload, timeout=120):&lt;br&gt;
    req = urllib.request.Request(&lt;br&gt;
        OLLAMA_URL.rstrip("/") + "/api/chat",&lt;br&gt;
        data=json.dumps(payload).encode("utf-8"),&lt;br&gt;
        headers={"Content-Type": "application/json"},&lt;br&gt;
        method="POST",&lt;br&gt;
    )&lt;br&gt;
    with urllib.request.urlopen(req, timeout=timeout) as response:&lt;br&gt;
        return json.loads(response.read().decode("utf-8"))&lt;/p&gt;

&lt;p&gt;def parse_json_from_text(text):&lt;br&gt;
    text = text.strip()&lt;br&gt;
    if text.startswith("&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        text = re.sub(r"^```

(?:json)?\s*", "", text)
        text = re.sub(r"\s*

```$", "", text)
    start, end = text.find("{"), text.rfind("}")
    if start &amp;gt;= 0 and end &amp;gt; 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 &amp;gt; 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'''&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset="utf-8"&amp;gt;&amp;lt;meta name="viewport" content="width=device-width, initial-scale=1"&amp;gt;
  &amp;lt;meta name="theme-color" content="#101b18"&amp;gt;
  &amp;lt;title&amp;gt;BillBuddy AI — Private expense desk&amp;lt;/title&amp;gt;
  &amp;lt;link rel="stylesheet" href="/styles.css"&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;div class="app-shell"&amp;gt;
    &amp;lt;aside class="sidebar"&amp;gt;
      &amp;lt;div class="brand"&amp;gt;&amp;lt;div class="brand-mark"&amp;gt;B&amp;lt;/div&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;billbuddy&amp;lt;span&amp;gt;.ai&amp;lt;/span&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;PRIVATE EXPENSE DESK&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
      &amp;lt;div class="side-label"&amp;gt;WORKSPACE&amp;lt;/div&amp;gt;
      &amp;lt;button class="nav-item active" data-view="overview"&amp;gt;&amp;lt;span&amp;gt;◫&amp;lt;/span&amp;gt; Overview&amp;lt;/button&amp;gt;
      &amp;lt;button class="nav-item" data-view="records"&amp;gt;&amp;lt;span&amp;gt;▤&amp;lt;/span&amp;gt; All documents &amp;lt;b id="nav-count"&amp;gt;0&amp;lt;/b&amp;gt;&amp;lt;/button&amp;gt;
      &amp;lt;button class="nav-item" data-view="add"&amp;gt;&amp;lt;span&amp;gt;＋&amp;lt;/span&amp;gt; Add document&amp;lt;/button&amp;gt;
      &amp;lt;div class="sidebar-bottom"&amp;gt;
        &amp;lt;div class="privacy-card"&amp;gt;&amp;lt;div class="privacy-icon"&amp;gt;⌁&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;Your data stays yours.&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Documents and records are stored on this computer. AI requests go to your local Ollama service.&amp;lt;/p&amp;gt;&amp;lt;div class="local-status"&amp;gt;&amp;lt;i id="status-dot"&amp;gt;&amp;lt;/i&amp;gt;&amp;lt;span id="status-text"&amp;gt;Checking local AI…&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div class="version"&amp;gt;BILLBUDDY AI &amp;lt;span&amp;gt;·&amp;lt;/span&amp;gt; LOCAL-FIRST MVP&amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/aside&amp;gt;
    &amp;lt;main class="main"&amp;gt;
      &amp;lt;header class="topbar"&amp;gt;&amp;lt;div class="breadcrumb"&amp;gt;Workspace &amp;lt;span&amp;gt;/&amp;lt;/span&amp;gt; &amp;lt;b id="crumb"&amp;gt;Overview&amp;lt;/b&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="top-actions"&amp;gt;&amp;lt;span class="local-pill"&amp;gt;&amp;lt;i&amp;gt;&amp;lt;/i&amp;gt; LOCAL MODE&amp;lt;/span&amp;gt;&amp;lt;div class="avatar"&amp;gt;BB&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/header&amp;gt;
      &amp;lt;section class="page" id="overview-view"&amp;gt;
        &amp;lt;div class="welcome-row"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;YOUR MONEY, ORGANIZED&amp;lt;/div&amp;gt;&amp;lt;h1&amp;gt;Good work deserves&amp;lt;br&amp;gt;&amp;lt;em&amp;gt;clear books.&amp;lt;/em&amp;gt;&amp;lt;/h1&amp;gt;&amp;lt;p class="subtitle"&amp;gt;Your receipts, invoices, and expenses — organized with private, local AI.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button class="primary-btn" data-go="add"&amp;gt;＋ Add a document &amp;lt;span&amp;gt;↗&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div class="stats-grid"&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Total tracked&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon green"&amp;gt;₹&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value" id="stat-total"&amp;gt;₹0.00&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;Across saved documents&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Documents&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon violet"&amp;gt;▤&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value" id="stat-docs"&amp;gt;0&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;Receipts and invoices&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Need review&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon amber"&amp;gt;⌕&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value" id="stat-review"&amp;gt;0&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;Check AI-extracted details&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;div class="stat-card"&amp;gt;&amp;lt;div class="stat-top"&amp;gt;&amp;lt;span&amp;gt;Top category&amp;lt;/span&amp;gt;&amp;lt;span class="stat-icon blue"&amp;gt;◈&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="stat-value category-stat" id="stat-category"&amp;gt;—&amp;lt;/div&amp;gt;&amp;lt;div class="stat-foot"&amp;gt;By tracked amount&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class="content-grid"&amp;gt;
          &amp;lt;section class="panel recent-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;Recent documents&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Your latest processed receipts and invoices&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button class="text-btn" data-go="records"&amp;gt;View all &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div id="recent-list" class="document-list"&amp;gt;&amp;lt;div class="empty-state"&amp;gt;&amp;lt;div class="empty-icon"&amp;gt;▤&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;No documents yet&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Add a receipt to start your expense desk.&amp;lt;/p&amp;gt;&amp;lt;button class="outline-btn" data-go="add"&amp;gt;Add first document&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/section&amp;gt;
          &amp;lt;section class="panel category-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;Spending snapshot&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Where your money is going&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;span class="tiny-tag"&amp;gt;ALL TIME&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div id="category-chart" class="category-chart"&amp;gt;&amp;lt;div class="chart-empty"&amp;gt;Your category breakdown will appear here.&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="chart-note"&amp;gt;&amp;lt;span class="legend-dot"&amp;gt;&amp;lt;/span&amp;gt; Tracked document totals&amp;lt;/div&amp;gt;&amp;lt;/section&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class="bottom-note"&amp;gt;&amp;lt;span class="sparkle"&amp;gt;✳&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;AI is your assistant, not your accountant.&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Always review extracted amounts, dates, and tax details before using them for bookkeeping or filing.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
      &amp;lt;/section&amp;gt;
      &amp;lt;section class="page hidden" id="add-view"&amp;gt;
        &amp;lt;div class="page-heading"&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;DOCUMENT INTAKE&amp;lt;/div&amp;gt;&amp;lt;h1&amp;gt;Add a document&amp;lt;/h1&amp;gt;&amp;lt;p class="subtitle"&amp;gt;Upload a receipt image or paste its text. Local AI will extract the details for you to review.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div class="intake-grid"&amp;gt;
          &amp;lt;section class="panel upload-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;Upload receipt&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;JPG, PNG, or WEBP image · up to 10 MB&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;span class="step-badge"&amp;gt;01&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;label class="dropzone" id="dropzone" for="file-input"&amp;gt;&amp;lt;input id="file-input" type="file" accept="image/png,image/jpeg,image/webp"&amp;gt;&amp;lt;div class="upload-symbol"&amp;gt;↑&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;Drop a receipt here&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;or &amp;lt;u&amp;gt;browse files&amp;lt;/u&amp;gt;&amp;lt;/p&amp;gt;&amp;lt;small&amp;gt;Your image is sent only to your local Ollama model.&amp;lt;/small&amp;gt;&amp;lt;div id="file-preview" class="file-preview hidden"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/label&amp;gt;
            &amp;lt;div class="or-divider"&amp;gt;&amp;lt;span&amp;gt;OR ENTER TEXT&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;label class="field-label" for="receipt-text"&amp;gt;Receipt / invoice text&amp;lt;/label&amp;gt;&amp;lt;textarea id="receipt-text" rows="6" placeholder="Paste text from an invoice or receipt…&amp;amp;#10;&amp;amp;#10;Example: Green Basket, 12 Oct 2026, Groceries ₹840.50, GST ₹42.00, Total ₹882.50"&amp;gt;&amp;lt;/textarea&amp;gt;
            &amp;lt;div class="field-row"&amp;gt;&amp;lt;label class="field-label" for="model-name"&amp;gt;Local Ollama model&amp;lt;/label&amp;gt;&amp;lt;input id="model-name" value="qwen2.5vl:3b" spellcheck="false"&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;button id="analyze-btn" class="primary-btn full-btn"&amp;gt;✦ Extract with local AI &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;p class="microcopy"&amp;gt;No cloud AI API key. First-time model download requires internet; afterward, inference can run locally.&amp;lt;/p&amp;gt;
            &amp;lt;div id="analyze-error" class="alert hidden"&amp;gt;&amp;lt;/div&amp;gt;
          &amp;lt;/section&amp;gt;
          &amp;lt;aside class="panel how-panel"&amp;gt;&amp;lt;div class="panel-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;h2&amp;gt;How it works&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Private by design&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="steps"&amp;gt;&amp;lt;div class="step"&amp;gt;&amp;lt;span&amp;gt;1&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;Choose a document&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Upload a receipt photo or paste text copied from a document.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="step"&amp;gt;&amp;lt;span&amp;gt;2&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;Local AI reads it&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;The model extracts merchant, date, totals, tax, and category on your computer.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="step"&amp;gt;&amp;lt;span&amp;gt;3&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;You review the result&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Correct any mistakes and save the record to your local expense list.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="model-tip"&amp;gt;&amp;lt;span&amp;gt;✳&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;Open models, your choice&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Change the model to another compatible Ollama vision model. Text-only input also works with text models.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="privacy-line"&amp;gt;&amp;lt;span&amp;gt;⌁&amp;lt;/span&amp;gt; Local storage &amp;lt;b&amp;gt;·&amp;lt;/b&amp;gt; Local inference &amp;lt;b&amp;gt;·&amp;lt;/b&amp;gt; No account needed&amp;lt;/div&amp;gt;&amp;lt;/aside&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/section&amp;gt;
      &amp;lt;section class="page hidden" id="records-view"&amp;gt;
        &amp;lt;div class="page-heading records-heading"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;YOUR DOCUMENT LIBRARY&amp;lt;/div&amp;gt;&amp;lt;h1&amp;gt;All documents&amp;lt;/h1&amp;gt;&amp;lt;p class="subtitle"&amp;gt;Search, review, export, or remove saved records.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button id="export-btn" class="outline-btn"&amp;gt;↓ Export CSV&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;section class="panel table-panel"&amp;gt;&amp;lt;div class="table-toolbar"&amp;gt;&amp;lt;div class="searchbox"&amp;gt;&amp;lt;span&amp;gt;⌕&amp;lt;/span&amp;gt;&amp;lt;input id="search-input" placeholder="Search merchant, category, invoice…"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;select id="category-filter"&amp;gt;&amp;lt;option value=""&amp;gt;All categories&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Food&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Travel&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Software&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Office supplies&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Utilities&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Shopping&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Healthcare&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Education&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Other&amp;lt;/option&amp;gt;&amp;lt;/select&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="table-wrap"&amp;gt;&amp;lt;table&amp;gt;&amp;lt;thead&amp;gt;&amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;MERCHANT / DOCUMENT&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;DATE&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;CATEGORY&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;TOTAL&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;STATUS&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;&amp;lt;/thead&amp;gt;&amp;lt;tbody id="records-body"&amp;gt;&amp;lt;/tbody&amp;gt;&amp;lt;/table&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div id="records-empty" class="empty-state hidden"&amp;gt;&amp;lt;div class="empty-icon"&amp;gt;▤&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;No matching documents&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Try another search or add your first document.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/section&amp;gt;
      &amp;lt;/section&amp;gt;
      &amp;lt;footer class="footer"&amp;gt;&amp;lt;span&amp;gt;BillBuddy AI &amp;lt;b&amp;gt;·&amp;lt;/b&amp;gt; Built with open AI&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;Made for independent work &amp;lt;span class="footer-heart"&amp;gt;♥&amp;lt;/span&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/footer&amp;gt;
    &amp;lt;/main&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div id="modal-backdrop" class="modal-backdrop hidden"&amp;gt;&amp;lt;section class="review-modal"&amp;gt;&amp;lt;div class="modal-head"&amp;gt;&amp;lt;div&amp;gt;&amp;lt;div class="eyebrow"&amp;gt;AI EXTRACTION COMPLETE&amp;lt;/div&amp;gt;&amp;lt;h2&amp;gt;Review document&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Confirm these details before saving.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;button id="close-modal" class="close-btn" aria-label="Close"&amp;gt;×&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;form id="review-form"&amp;gt;&amp;lt;input type="hidden" id="edit-id"&amp;gt;&amp;lt;div class="review-grid"&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Merchant&amp;lt;input id="f-merchant" required placeholder="Merchant name"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Document type&amp;lt;select id="f-type"&amp;gt;&amp;lt;option&amp;gt;Receipt&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Invoice&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Other&amp;lt;/option&amp;gt;&amp;lt;/select&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Date&amp;lt;input id="f-date" type="date"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Category&amp;lt;select id="f-category"&amp;gt;&amp;lt;option&amp;gt;Food&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Travel&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Software&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Office supplies&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Utilities&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Shopping&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Healthcare&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Education&amp;lt;/option&amp;gt;&amp;lt;option&amp;gt;Other&amp;lt;/option&amp;gt;&amp;lt;/select&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Currency&amp;lt;input id="f-currency" placeholder="INR / ₹ / USD"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Total amount&amp;lt;input id="f-total" type="number" step="0.01" min="0" placeholder="0.00"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Subtotal&amp;lt;input id="f-subtotal" type="number" step="0.01" min="0" placeholder="Optional"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Tax&amp;lt;input id="f-tax" type="number" step="0.01" min="0" placeholder="Optional"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Payment method&amp;lt;input id="f-payment" placeholder="Cash, card, UPI…"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label"&amp;gt;Invoice number&amp;lt;input id="f-invoice" placeholder="Optional"&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;label class="field-label wide-field"&amp;gt;Notes&amp;lt;textarea id="f-notes" rows="2" placeholder="Optional notes"&amp;gt;&amp;lt;/textarea&amp;gt;&amp;lt;/label&amp;gt;
  &amp;lt;/div&amp;gt;&amp;lt;div class="review-meta" id="review-meta"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="modal-actions"&amp;gt;&amp;lt;button type="button" id="cancel-review" class="outline-btn"&amp;gt;Cancel&amp;lt;/button&amp;gt;&amp;lt;button type="submit" class="primary-btn"&amp;gt;Save document &amp;lt;span&amp;gt;✓&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/form&amp;gt;&amp;lt;/section&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;div id="toast" class="toast hidden"&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;script src="/app.js"&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
'''

styles_css = r'''@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&amp;amp;family=Manrope:wght@400;500;600;700;800&amp;amp;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&amp;gt;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&amp;gt;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&amp;gt;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) =&amp;gt; document.getElementById(id);
let records = [];
let selectedFile = null;
let pendingAnalysis = null;
const currencyFmt = (value, currency) =&amp;gt; {
  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 &amp;amp;&amp;amp; currency.length &amp;lt;= 3 ? currency + ' ' : '')) + n.toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
};
function escapeHtml(s=''){return String(s).replace(/[&amp;amp;&amp;lt;&amp;gt;"']/g,c=&amp;gt;({'&amp;amp;':'&amp;amp;amp;','&amp;lt;':'&amp;amp;lt;','&amp;gt;':'&amp;amp;gt;','"':'&amp;amp;quot;',"'":'&amp;amp;#39;'}[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(()=&amp;gt;el.classList.add('hidden'),3500);}
function showView(name){
  ['overview','add','records'].forEach(v=&amp;gt;$(v+'-view').classList.toggle('hidden',v!==name));
  document.querySelectorAll('.nav-item').forEach(b=&amp;gt;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=&amp;gt;b.addEventListener('click',()=&amp;gt;showView(b.dataset.view)));
document.querySelectorAll('[data-go]').forEach(b=&amp;gt;b.addEventListener('click',()=&amp;gt;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 &amp;amp;&amp;amp; h.models?.length &amp;amp;&amp;amp; !$('model-name').dataset.userChanged){
      const vision=h.models.find(m=&amp;gt;/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',()=&amp;gt;{$('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=&amp;gt;r.status!=='reviewed').length;
  const sum=records.reduce((a,r)=&amp;gt;a+totalOf(r),0);
  $('stat-total').textContent=currencyFmt(sum,records.find(r=&amp;gt;r.currency)?.currency||'INR');
  const cats={};records.forEach(r=&amp;gt;{const c=r.category||'Other';cats[c]=(cats[c]||0)+totalOf(r)});
  const top=Object.entries(cats).sort((a,b)=&amp;gt;b[1]-a[1])[0];
  $('stat-category').textContent=top?top[0]:'—';
  const recent=$('recent-list');
  if(!records.length){recent.innerHTML='&amp;lt;div class="empty-state"&amp;gt;&amp;lt;div class="empty-icon"&amp;gt;▤&amp;lt;/div&amp;gt;&amp;lt;strong&amp;gt;No documents yet&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;Add a receipt to start your expense desk.&amp;lt;/p&amp;gt;&amp;lt;button class="outline-btn" data-go="add"&amp;gt;Add first document&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;';recent.querySelector('[data-go]')?.addEventListener('click',()=&amp;gt;showView('add'));}
  else recent.innerHTML=records.slice(0,5).map(r=&amp;gt;`&amp;lt;div class="doc-row"&amp;gt;&amp;lt;div class="doc-icon"&amp;gt;${r.document_type==='Invoice'?'▧':'▤'}&amp;lt;/div&amp;gt;&amp;lt;div class="doc-main"&amp;gt;&amp;lt;strong&amp;gt;${escapeHtml(r.merchant||'Unknown merchant')}&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;${escapeHtml(r.category||'Other')} · ${escapeHtml(r.date||'Date unknown')}&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;div class="doc-amount"&amp;gt;&amp;lt;strong&amp;gt;${currencyFmt(r.total,r.currency)}&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;${r.status==='reviewed'?'Reviewed':'Needs review'}&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;`).join('');
  const chart=$('category-chart');
  const entries=Object.entries(cats).sort((a,b)=&amp;gt;b[1]-a[1]).slice(0,6), max=entries[0]?.[1]||1;
  chart.innerHTML=entries.length?entries.map(([c,v])=&amp;gt;`&amp;lt;div class="cat-row"&amp;gt;&amp;lt;span class="cat-name"&amp;gt;${escapeHtml(c)}&amp;lt;/span&amp;gt;&amp;lt;div class="bar-bg"&amp;gt;&amp;lt;div class="bar-fill" style="width:${Math.max(2,v/max*100)}%"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;span class="cat-amount"&amp;gt;${currencyFmt(v,records.find(r=&amp;gt;r.currency)?.currency||'INR')}&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;`).join(''):'&amp;lt;div class="chart-empty"&amp;gt;Your category breakdown will appear here.&amp;lt;/div&amp;gt;';
  renderRecords();
}
function renderRecords(){
  const q=($('search-input')?.value||'').toLowerCase(), cat=$('category-filter')?.value||'';
  const filtered=records.filter(r=&amp;gt;(!cat||r.category===cat)&amp;amp;&amp;amp;[r.merchant,r.category,r.invoice_number,r.source_name,r.notes].some(v=&amp;gt;String(v||'').toLowerCase().includes(q)));
  $('records-body').innerHTML=filtered.map(r=&amp;gt;`&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&amp;lt;div class="table-merchant"&amp;gt;&amp;lt;div class="doc-icon"&amp;gt;${r.document_type==='Invoice'?'▧':'▤'}&amp;lt;/div&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;${escapeHtml(r.merchant||'Unknown merchant')}&amp;lt;/strong&amp;gt;&amp;lt;small&amp;gt;${escapeHtml(r.document_type||'Receipt')}${r.invoice_number?' · '+escapeHtml(r.invoice_number):''}&amp;lt;/small&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${escapeHtml(r.date||'—')}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${escapeHtml(r.category||'Other')}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&amp;lt;strong&amp;gt;${currencyFmt(r.total,r.currency)}&amp;lt;/strong&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&amp;lt;span class="status-tag ${r.status==='reviewed'?'reviewed':''}"&amp;gt;${r.status==='reviewed'?'REVIEWED':'REVIEW'}&amp;lt;/span&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&amp;lt;button class="action-btn edit-record" data-id="${escapeHtml(r.id)}" title="Review/edit"&amp;gt;✎&amp;lt;/button&amp;gt;&amp;lt;button class="action-btn delete-record" data-id="${escapeHtml(r.id)}" title="Delete"&amp;gt;×&amp;lt;/button&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;`).join('');
  $('records-empty').classList.toggle('hidden',filtered.length!==0);
  $('records-body').querySelectorAll('.edit-record').forEach(b=&amp;gt;b.addEventListener('click',()=&amp;gt;openReview(records.find(r=&amp;gt;r.id===b.dataset.id))));
  $('records-body').querySelectorAll('.delete-record').forEach(b=&amp;gt;b.addEventListener('click',()=&amp;gt;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&amp;gt;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=&amp;gt;setFile(e.target.files[0]));
const dz=$('dropzone');
dz.addEventListener('dragover',e=&amp;gt;{e.preventDefault();dz.classList.add('dragover')});
dz.addEventListener('dragleave',()=&amp;gt;dz.classList.remove('dragover'));
dz.addEventListener('drop',e=&amp;gt;{e.preventDefault();dz.classList.remove('dragover');setFile(e.dataTransfer.files[0])});
function fileToBase64(file){return new Promise((resolve,reject)=&amp;gt;{const r=new FileReader();r.onload=()=&amp;gt;resolve(String(r.result).split(',')[1]);r.onerror=reject;r.readAsDataURL(file);});}
$('analyze-btn').addEventListener('click',async()=&amp;gt;{
  const text=$('receipt-text').value.trim();
  if(!text&amp;amp;&amp;amp;!selectedFile){showError('Upload a receipt image or paste some receipt text first.');return;}
  const btn=$('analyze-btn');btn.disabled=true;btn.innerHTML='◌ Analyzing locally… &amp;lt;span&amp;gt;please wait&amp;lt;/span&amp;gt;';$('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 &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;';}
});
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])=&amp;gt;{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=&amp;gt;{if(e.target===$('modal-backdrop'))closeReview()});
$('review-form').addEventListener('submit',async e=&amp;gt;{
  e.preventDefault();
  const id=$('edit-id').value;
  const old=records.find(r=&amp;gt;r.id===id)||pendingAnalysis||{};
  const num=id=&amp;gt;$(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',()=&amp;gt;{
  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=&amp;gt;'"'+String(v??'').replace(/"/g,'""')+'"';
  const csv=[cols.join(','),...records.map(r=&amp;gt;cols.map(c=&amp;gt;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 &amp;amp; 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:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
   ollama pull qwen2.5vl:3b&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
3. Extract this project ZIP and open a terminal in the `billbuddy_ai` folder.
4. Run:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
   python server.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
5. Open http://127.0.0.1:8765

If you use a different model:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
OLLAMA_MODEL=your-model-name python server.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The model selector in the app can also be changed per request. For an alternate Ollama server URL:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
OLLAMA_URL=&lt;a href="http://127.0.0.1:11434" rel="noopener noreferrer"&gt;http://127.0.0.1:11434&lt;/a&gt; python server.py&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


## 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")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Touch Grassss</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Sat, 10 Oct 2026 11:40:04 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/touch-grassss-50o3</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/touch-grassss-50o3</guid>
      <description>&lt;p&gt;This Week's Theme: Touch Grass&lt;br&gt;
Build something with open-weight models or open-source AI that gets people off the screen and into the world.&lt;/p&gt;

&lt;p&gt;That can mean running an open-weight model, building on an open-source agent harness or framework, running inference locally, or all three. Whatever you pick, the open pieces should be what makes your project work.&lt;/p&gt;

&lt;p&gt;Hiking, gardening, birding, run clubs, fall foliage: if it gets someone outside, it counts. The best builds here should make the screen the shortest part of the experience. A few ideas to get you going:&lt;/p&gt;

&lt;p&gt;A bird call identifier that works on the trail with no signal&lt;/p&gt;

&lt;p&gt;A garden planner that tells you what to plant this week based on your local frost dates&lt;/p&gt;

&lt;p&gt;A run club route builder that finds the best fall foliage near you&lt;/p&gt;

&lt;p&gt;Bonus points if you take it outside, use it, and tell us how it went.&lt;/p&gt;

&lt;p&gt;This theme is perfect for building something where AI helps people experience the real world instead of spending more time on their phones. The strongest project will have one small AI-powered interaction, then send the user outside.&lt;/p&gt;

&lt;p&gt;Here are five build ideas, with an emphasis on open-weight models and a realistic hackathon MVP.&lt;/p&gt;

&lt;p&gt;Five project ideas for “Touch Grass”&lt;br&gt;
Essential Birdwatching Tips for Beginners | AvianScope&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;WildWhisper — Your offline nature companion
Best overall
Identify bird calls, recognize common nature sounds, and learn what to listen for on a walk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: Open audio classification model running locally.&lt;/p&gt;

&lt;p&gt;Open stack: Python, Hugging Face Transformers, an open audio model.&lt;/p&gt;

&lt;p&gt;MVP: Record a short sound, identify likely bird species, show confidence and one field observation tip.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: The app asks users to put away their phones and listen for the bird again.&lt;/p&gt;

&lt;p&gt;Yellow Wood Guiding - Private Tours&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;LeafQuest — AI-powered outdoor adventures
Turn an ordinary neighborhood walk into a scavenger hunt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: A small open-weight vision-language model generates challenges from photos of local surroundings.&lt;/p&gt;

&lt;p&gt;Open stack: Ollama, an open vision-language model such as Qwen2.5-VL, and a simple web app.&lt;/p&gt;

&lt;p&gt;MVP: Generate five quests: find a leaf with three colors, spot an insect, notice a cloud shape, and more.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: The user collects discoveries, not screen time.&lt;/p&gt;

&lt;p&gt;MaLu-Zahrady&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SowSimple — Your weekly garden coach
Tell people what they can plant now, using their local climate and garden conditions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: An open-weight language model converts planting rules into a simple weekly plan.&lt;/p&gt;

&lt;p&gt;Open stack: Ollama, Qwen or Gemma, and a locally stored gardening knowledge base.&lt;/p&gt;

&lt;p&gt;MVP: Enter your location, available sunlight, and garden type to get three planting tasks.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: Every recommendation ends with a real gardening task.&lt;/p&gt;

&lt;p&gt;Gemini AI Runner Photo Prompts | 25+ Copy-Paste Ideas&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RunRoot — Discover your city's green routes
Help runners discover peaceful routes through parks and tree-lined streets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: An open-weight model ranks route options based on preferences.&lt;/p&gt;

&lt;p&gt;Open stack: OpenStreetMap, OSRM or GraphHopper, and a local LLM.&lt;/p&gt;

&lt;p&gt;MVP: Suggest a 3 km, 5 km, or 10 km loop with green spaces and safe pedestrian paths.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: One tap starts the run; the phone stays in the pocket.&lt;/p&gt;

&lt;p&gt;How to Rewild Your Garden UK | Step-by-Step Guide&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;TinyNature — A real-world curiosity generator
Make a five-minute outdoor break feel like a tiny expedition.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: A small local language model generates location- and season-aware observation prompts.&lt;/p&gt;

&lt;p&gt;Open stack: llama.cpp, an open-weight model, and a curated offline nature guide.&lt;/p&gt;

&lt;p&gt;MVP: Pick a setting—balcony, garden, park, or street—and receive one observation mission.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: No feed, no streaks, no endless scrolling. Just one prompt and a reason to explore.&lt;/p&gt;

&lt;p&gt;My recommendation: build LeafQuest&lt;br&gt;
Why? It is visual, fun to demo, easy for judges to understand, and naturally rewards spending time outside. You can also build a useful MVP without requiring a large dataset or a complicated backend.&lt;/p&gt;

&lt;p&gt;Осенний бизнес: 13 прибыльных идей&lt;br&gt;
Macro shot of a red ladybug on a green leaf&lt;br&gt;
Striped shadows from trees on a park alley in spring. Bright sunny spring day in the park.&lt;br&gt;
The 30-second user experience&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get one mission&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;“Find a leaf with two different shades of green.”&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go explore&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The screen locks away conceptually: users explore without checking their phones every few seconds.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Discover something&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Optionally snap a photo. A local vision model helps identify the discovery and shares one surprising fact.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Finish and put it away&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save the discovery and get a single optional next mission.&lt;/p&gt;

&lt;p&gt;Suggested technical stack&lt;br&gt;
Component&lt;/p&gt;

&lt;p&gt;Choice&lt;/p&gt;

&lt;p&gt;Language model&lt;/p&gt;

&lt;p&gt;Qwen2.5 or Gemma open-weight family&lt;/p&gt;

&lt;p&gt;Local inference&lt;/p&gt;

&lt;p&gt;Ollama&lt;/p&gt;

&lt;p&gt;Vision&lt;/p&gt;

&lt;p&gt;An open-weight vision-language model supported by your hardware&lt;/p&gt;

&lt;p&gt;Backend&lt;/p&gt;

&lt;p&gt;Python + FastAPI&lt;/p&gt;

&lt;p&gt;Frontend&lt;/p&gt;

&lt;p&gt;Lightweight mobile-first web app&lt;/p&gt;

&lt;p&gt;Offline data&lt;/p&gt;

&lt;p&gt;Curated nature facts stored locally&lt;/p&gt;

&lt;p&gt;The important architectural decision is to make the model generate missions and explain discoveries, while ordinary code handles safety checks, saved missions, and completion. Keep mission generation cached so the user does not need a network connection while outdoors.&lt;/p&gt;

&lt;p&gt;What makes this a strong hackathon submission?&lt;br&gt;
Open AI is essential: the local model generates missions and interprets discoveries rather than serving as a decorative chatbot.&lt;/p&gt;

&lt;p&gt;Offline-first: the core experience works without a cloud API, once the model and data are downloaded.&lt;/p&gt;

&lt;p&gt;Behavior change: success is measured by completed outdoor missions, not minutes spent in the app.&lt;/p&gt;

&lt;p&gt;Easy to test: take it to a garden, park, or nearby walking path and record a real five-minute session.&lt;/p&gt;

&lt;p&gt;One honest demo showing the app used outdoors would make this much stronger than a polished mockup.&lt;/p&gt;

&lt;p&gt;One question: What are you building with—so I can tailor the idea and implementation to your setup?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Preferred platform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mobile-friendly web app&lt;/p&gt;

&lt;p&gt;Android app&lt;/p&gt;

&lt;p&gt;Python prototype / CLI&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Available hardware&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Laptop, no dedicated GPU&lt;/p&gt;

&lt;p&gt;Laptop or desktop with a GPU&lt;/p&gt;

&lt;p&gt;Phone only&lt;/p&gt;

&lt;p&gt;Cloud GPU / hosted compute&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Time available&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One day&lt;/p&gt;

&lt;p&gt;A weekend&lt;/p&gt;

&lt;p&gt;One week&lt;/p&gt;

&lt;p&gt;You said:&lt;br&gt;
&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;ChatGPT said:&lt;br&gt;
Here’s a polished DEV.to submission draft based on LeafQuest, the outdoor AI scavenger-hunt idea. I’ve kept the demo, repository, and agent-session details as placeholders so you can fill in what you actually built and tested.&lt;/p&gt;

&lt;p&gt;Writing&lt;/p&gt;

&lt;p&gt;This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/p&gt;

&lt;p&gt;🍃 LeafQuest: An AI That Gets You Outside&lt;br&gt;
What I Built&lt;br&gt;
LeafQuest is an AI-powered nature scavenger hunt designed to turn ordinary outdoor walks into small adventures.&lt;/p&gt;

&lt;p&gt;Instead of encouraging people to spend more time scrolling, LeafQuest gives them a reason to put their phones away and explore the world around them.&lt;/p&gt;

&lt;p&gt;The idea is simple: open the app, receive a nature mission, head outside, and discover something new.&lt;/p&gt;

&lt;p&gt;Some example missions include:&lt;/p&gt;

&lt;p&gt;🍂 Find a leaf with three different colors.&lt;br&gt;
🐜 Observe a tiny creature and notice how it moves.&lt;br&gt;
🌳 Find two trees with different types of bark.&lt;br&gt;
☁️ Watch the clouds and describe the shapes you see.&lt;br&gt;
🌱 Discover a plant growing somewhere unexpected.&lt;br&gt;
LeafQuest is designed for curious people of all ages, including students, families, nature lovers, and anyone who wants to take a break from screens.&lt;/p&gt;

&lt;p&gt;The goal isn't to build another app people spend hours using. It's to make the screen the shortest part of an outdoor adventure.&lt;/p&gt;

&lt;p&gt;Demo&lt;br&gt;
Live demo: [Add your deployed application URL]&lt;/p&gt;

&lt;p&gt;Video demonstration: [Add your demo video URL]&lt;/p&gt;

&lt;p&gt;The ideal demonstration is simple: generate a mission, take the challenge outdoors, complete it, and show what you discovered.&lt;/p&gt;

&lt;p&gt;Code&lt;br&gt;
GitHub repository: [Add your repository URL]&lt;/p&gt;

&lt;p&gt;The project is intended to use an open-source-first architecture, with AI-assisted mission generation and a lightweight interface that makes it easy to get started.&lt;/p&gt;

&lt;p&gt;How I Built It&lt;br&gt;
I designed LeafQuest around a simple principle: AI should encourage real-world experiences, not replace them.&lt;/p&gt;

&lt;p&gt;The proposed architecture combines:&lt;/p&gt;

&lt;p&gt;Open-weight language model: A model from the Qwen or Gemma family generates creative, context-aware nature missions.&lt;br&gt;
Local inference: Ollama runs a compatible model locally, reducing dependence on hosted AI APIs.&lt;br&gt;
Backend: Python and FastAPI handle mission generation and application logic.&lt;br&gt;
Frontend: A lightweight, mobile-friendly interface keeps the experience quick and accessible.&lt;br&gt;
Offline-first design: Cached missions and a locally stored collection of nature facts help minimize connectivity requirements.&lt;br&gt;
A future extension could use an open-weight vision-language model to identify plants, leaves, and other natural discoveries from photos.&lt;/p&gt;

&lt;p&gt;The most important design choice is that the AI generates the inspiration, but the user completes the experience in the real world. The app can provide one mission and then step out of the way.&lt;/p&gt;

&lt;p&gt;Why Does Open Innovation Matter?&lt;br&gt;
Open innovation makes it possible to build AI experiences that are more private, accessible, and independent of a single provider.&lt;/p&gt;

&lt;p&gt;For a project like LeafQuest, these benefits are especially important.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AI that can run locally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With a compatible open-weight model, mission generation can happen on the user's own device or computer. This can reduce network requirements and avoid sending every interaction to a third-party API.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;More control over the experience&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Open models and frameworks give developers greater freedom to experiment with prompts, model behavior, inference settings, and specialized nature knowledge.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lower barriers to experimentation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Developers and contributors can explore different models and deployment strategies without making every experiment dependent on a paid proprietary API.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Technology that serves people, not engagement metrics&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Open-source development makes it easier to build and share tools around a different goal: helping people become more curious about the physical world.&lt;/p&gt;

&lt;p&gt;For LeafQuest, the technology is successful when someone closes the app, steps outside, and notices something they might otherwise have missed.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Touch grasss</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Sat, 10 Oct 2026 10:53:09 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/touch-grasss-4k0j</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/touch-grasss-4k0j</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Touch Grass</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Sat, 10 Oct 2026 10:51:14 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/touch-grass-1e3e</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/touch-grass-1e3e</guid>
      <description>&lt;p&gt;This Week's Theme: Touch Grass&lt;br&gt;
Build something with open-weight models or open-source AI that gets people off the screen and into the world.&lt;/p&gt;

&lt;p&gt;That can mean running an open-weight model, building on an open-source agent harness or framework, running inference locally, or all three. Whatever you pick, the open pieces should be what makes your project work.&lt;/p&gt;

&lt;p&gt;Hiking, gardening, birding, run clubs, fall foliage: if it gets someone outside, it counts. The best builds here should make the screen the shortest part of the experience. A few ideas to get you going:&lt;/p&gt;

&lt;p&gt;A bird call identifier that works on the trail with no signal&lt;/p&gt;

&lt;p&gt;A garden planner that tells you what to plant this week based on your local frost dates&lt;/p&gt;

&lt;p&gt;A run club route builder that finds the best fall foliage near you&lt;/p&gt;

&lt;p&gt;Bonus points if you take it outside, use it, and tell us how it went.&lt;/p&gt;

&lt;p&gt;This theme is perfect for building something where AI helps people experience the real world instead of spending more time on their phones. The strongest project will have one small AI-powered interaction, then send the user outside.&lt;/p&gt;

&lt;p&gt;Here are five build ideas, with an emphasis on open-weight models and a realistic hackathon MVP.&lt;/p&gt;

&lt;p&gt;Five project ideas for “Touch Grass”&lt;br&gt;
Essential Birdwatching Tips for Beginners | AvianScope&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;WildWhisper — Your offline nature companion
Best overall
Identify bird calls, recognize common nature sounds, and learn what to listen for on a walk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: Open audio classification model running locally.&lt;/p&gt;

&lt;p&gt;Open stack: Python, Hugging Face Transformers, an open audio model.&lt;/p&gt;

&lt;p&gt;MVP: Record a short sound, identify likely bird species, show confidence and one field observation tip.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: The app asks users to put away their phones and listen for the bird again.&lt;/p&gt;

&lt;p&gt;Yellow Wood Guiding - Private Tours&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;LeafQuest — AI-powered outdoor adventures
Turn an ordinary neighborhood walk into a scavenger hunt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: A small open-weight vision-language model generates challenges from photos of local surroundings.&lt;/p&gt;

&lt;p&gt;Open stack: Ollama, an open vision-language model such as Qwen2.5-VL, and a simple web app.&lt;/p&gt;

&lt;p&gt;MVP: Generate five quests: find a leaf with three colors, spot an insect, notice a cloud shape, and more.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: The user collects discoveries, not screen time.&lt;/p&gt;

&lt;p&gt;MaLu-Zahrady&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SowSimple — Your weekly garden coach
Tell people what they can plant now, using their local climate and garden conditions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: An open-weight language model converts planting rules into a simple weekly plan.&lt;/p&gt;

&lt;p&gt;Open stack: Ollama, Qwen or Gemma, and a locally stored gardening knowledge base.&lt;/p&gt;

&lt;p&gt;MVP: Enter your location, available sunlight, and garden type to get three planting tasks.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: Every recommendation ends with a real gardening task.&lt;/p&gt;

&lt;p&gt;Gemini AI Runner Photo Prompts | 25+ Copy-Paste Ideas&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RunRoot — Discover your city's green routes
Help runners discover peaceful routes through parks and tree-lined streets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: An open-weight model ranks route options based on preferences.&lt;/p&gt;

&lt;p&gt;Open stack: OpenStreetMap, OSRM or GraphHopper, and a local LLM.&lt;/p&gt;

&lt;p&gt;MVP: Suggest a 3 km, 5 km, or 10 km loop with green spaces and safe pedestrian paths.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: One tap starts the run; the phone stays in the pocket.&lt;/p&gt;

&lt;p&gt;How to Rewild Your Garden UK | Step-by-Step Guide&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;TinyNature — A real-world curiosity generator
Make a five-minute outdoor break feel like a tiny expedition.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AI: A small local language model generates location- and season-aware observation prompts.&lt;/p&gt;

&lt;p&gt;Open stack: llama.cpp, an open-weight model, and a curated offline nature guide.&lt;/p&gt;

&lt;p&gt;MVP: Pick a setting—balcony, garden, park, or street—and receive one observation mission.&lt;/p&gt;

&lt;p&gt;Touch Grass moment: No feed, no streaks, no endless scrolling. Just one prompt and a reason to explore.&lt;/p&gt;

&lt;p&gt;My recommendation: build LeafQuest&lt;br&gt;
Why? It is visual, fun to demo, easy for judges to understand, and naturally rewards spending time outside. You can also build a useful MVP without requiring a large dataset or a complicated backend.&lt;/p&gt;

&lt;p&gt;Осенний бизнес: 13 прибыльных идей&lt;br&gt;
Macro shot of a red ladybug on a green leaf&lt;br&gt;
Striped shadows from trees on a park alley in spring. Bright sunny spring day in the park.&lt;br&gt;
The 30-second user experience&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get one mission&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;“Find a leaf with two different shades of green.”&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go explore&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The screen locks away conceptually: users explore without checking their phones every few seconds.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Discover something&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Optionally snap a photo. A local vision model helps identify the discovery and shares one surprising fact.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Finish and put it away&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save the discovery and get a single optional next mission.&lt;/p&gt;

&lt;p&gt;Suggested technical stack&lt;br&gt;
Component&lt;/p&gt;

&lt;p&gt;Choice&lt;/p&gt;

&lt;p&gt;Language model&lt;/p&gt;

&lt;p&gt;Qwen2.5 or Gemma open-weight family&lt;/p&gt;

&lt;p&gt;Local inference&lt;/p&gt;

&lt;p&gt;Ollama&lt;/p&gt;

&lt;p&gt;Vision&lt;/p&gt;

&lt;p&gt;An open-weight vision-language model supported by your hardware&lt;/p&gt;

&lt;p&gt;Backend&lt;/p&gt;

&lt;p&gt;Python + FastAPI&lt;/p&gt;

&lt;p&gt;Frontend&lt;/p&gt;

&lt;p&gt;Lightweight mobile-first web app&lt;/p&gt;

&lt;p&gt;Offline data&lt;/p&gt;

&lt;p&gt;Curated nature facts stored locally&lt;/p&gt;

&lt;p&gt;The important architectural decision is to make the model generate missions and explain discoveries, while ordinary code handles safety checks, saved missions, and completion. Keep mission generation cached so the user does not need a network connection while outdoors.&lt;/p&gt;

&lt;p&gt;What makes this a strong hackathon submission?&lt;br&gt;
Open AI is essential: the local model generates missions and interprets discoveries rather than serving as a decorative chatbot.&lt;/p&gt;

&lt;p&gt;Offline-first: the core experience works without a cloud API, once the model and data are downloaded.&lt;/p&gt;

&lt;p&gt;Behavior change: success is measured by completed outdoor missions, not minutes spent in the app.&lt;/p&gt;

&lt;p&gt;Easy to test: take it to a garden, park, or nearby walking path and record a real five-minute session.&lt;/p&gt;

&lt;p&gt;One honest demo showing the app used outdoors would make this much stronger than a polished mockup.&lt;/p&gt;

&lt;p&gt;One question: What are you building with—so I can tailor the idea and implementation to your setup?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Preferred platform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mobile-friendly web app&lt;/p&gt;

&lt;p&gt;Android app&lt;/p&gt;

&lt;p&gt;Python prototype / CLI&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Available hardware&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Laptop, no dedicated GPU&lt;/p&gt;

&lt;p&gt;Laptop or desktop with a GPU&lt;/p&gt;

&lt;p&gt;Phone only&lt;/p&gt;

&lt;p&gt;Cloud GPU / hosted compute&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Time available&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One day&lt;/p&gt;

&lt;p&gt;A weekend&lt;/p&gt;

&lt;p&gt;One week&lt;/p&gt;

&lt;p&gt;You said:&lt;br&gt;
&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;ChatGPT said:&lt;br&gt;
Here’s a polished DEV.to submission draft based on LeafQuest, the outdoor AI scavenger-hunt idea. I’ve kept the demo, repository, and agent-session details as placeholders so you can fill in what you actually built and tested.&lt;/p&gt;

&lt;p&gt;Writing&lt;/p&gt;

&lt;p&gt;This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/p&gt;

&lt;p&gt;🍃 LeafQuest: An AI That Gets You Outside&lt;br&gt;
What I Built&lt;br&gt;
LeafQuest is an AI-powered nature scavenger hunt designed to turn ordinary outdoor walks into small adventures.&lt;/p&gt;

&lt;p&gt;Instead of encouraging people to spend more time scrolling, LeafQuest gives them a reason to put their phones away and explore the world around them.&lt;/p&gt;

&lt;p&gt;The idea is simple: open the app, receive a nature mission, head outside, and discover something new.&lt;/p&gt;

&lt;p&gt;Some example missions include:&lt;/p&gt;

&lt;p&gt;🍂 Find a leaf with three different colors.&lt;br&gt;
🐜 Observe a tiny creature and notice how it moves.&lt;br&gt;
🌳 Find two trees with different types of bark.&lt;br&gt;
☁️ Watch the clouds and describe the shapes you see.&lt;br&gt;
🌱 Discover a plant growing somewhere unexpected.&lt;br&gt;
LeafQuest is designed for curious people of all ages, including students, families, nature lovers, and anyone who wants to take a break from screens.&lt;/p&gt;

&lt;p&gt;The goal isn't to build another app people spend hours using. It's to make the screen the shortest part of an outdoor adventure.&lt;/p&gt;

&lt;p&gt;Demo&lt;br&gt;
Live demo: [Add your deployed application URL]&lt;/p&gt;

&lt;p&gt;Video demonstration: [Add your demo video URL]&lt;/p&gt;

&lt;p&gt;The ideal demonstration is simple: generate a mission, take the challenge outdoors, complete it, and show what you discovered.&lt;/p&gt;

&lt;p&gt;Code&lt;br&gt;
GitHub repository: [Add your repository URL]&lt;/p&gt;

&lt;p&gt;The project is intended to use an open-source-first architecture, with AI-assisted mission generation and a lightweight interface that makes it easy to get started.&lt;/p&gt;

&lt;p&gt;How I Built It&lt;br&gt;
I designed LeafQuest around a simple principle: AI should encourage real-world experiences, not replace them.&lt;/p&gt;

&lt;p&gt;The proposed architecture combines:&lt;/p&gt;

&lt;p&gt;Open-weight language model: A model from the Qwen or Gemma family generates creative, context-aware nature missions.&lt;br&gt;
Local inference: Ollama runs a compatible model locally, reducing dependence on hosted AI APIs.&lt;br&gt;
Backend: Python and FastAPI handle mission generation and application logic.&lt;br&gt;
Frontend: A lightweight, mobile-friendly interface keeps the experience quick and accessible.&lt;br&gt;
Offline-first design: Cached missions and a locally stored collection of nature facts help minimize connectivity requirements.&lt;br&gt;
A future extension could use an open-weight vision-language model to identify plants, leaves, and other natural discoveries from photos.&lt;/p&gt;

&lt;p&gt;The most important design choice is that the AI generates the inspiration, but the user completes the experience in the real world. The app can provide one mission and then step out of the way.&lt;/p&gt;

&lt;p&gt;Why Does Open Innovation Matter?&lt;br&gt;
Open innovation makes it possible to build AI experiences that are more private, accessible, and independent of a single provider.&lt;/p&gt;

&lt;p&gt;For a project like LeafQuest, these benefits are especially important.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AI that can run locally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With a compatible open-weight model, mission generation can happen on the user's own device or computer. This can reduce network requirements and avoid sending every interaction to a third-party API.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;More control over the experience&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Open models and frameworks give developers greater freedom to experiment with prompts, model behavior, inference settings, and specialized nature knowledge.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lower barriers to experimentation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Developers and contributors can explore different models and deployment strategies without making every experiment dependent on a paid proprietary API.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Technology that serves people, not engagement metrics&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Open-source development makes it easier to build and share tools around a different goal: helping people become more curious about the physical world.&lt;/p&gt;

&lt;p&gt;For LeafQuest, the technology is successful when someone closes the app, steps outside, and notices something they might otherwise have missed.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Dev Challenge</title>
      <dc:creator>Aditya Narayan</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:33:26 +0000</pubDate>
      <link>https://dev.to/aditya_narayan_7a94f8bd1c/dev-challenge-3077</link>
      <guid>https://dev.to/aditya_narayan_7a94f8bd1c/dev-challenge-3077</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challen</category>
    </item>
  </channel>
</rss>
