This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine

What I Built

Ledger turns a folder of invoices and receipts into an accountant-ready CSV — on the user's own hardware — and predicts which unpaid invoices will be paid late.

A local invoice sorter that keeps your clients' names off the internet.

The problem. A freelancer ends each month with 30–60 invoices and receipts in mixed formats — emailed PDFs, phone photos of paper receipts, screenshots of payment confirmations — and needs them as structured rows for an accountant or a tax filing. There are two ways to get there, and one thing that looks like a third:

  1. Pay someone. $50–150/month for data entry.
  2. Do it yourself. 2–4 hours a month, forever, re-reading documents whose content you already have.
  3. Paste it into a hosted chatbot. Which is not free in the only currency that matters. Invoice text contains client names, project names, rates and account numbers, and that text goes to a service you don't control.

Who pays for it. Freelancers, independent designers and developers, and small studios who reconcile monthly and hate it. My first user is exactly this person — my sister, who runs her own freelance work.

Why they'd pay. Not for the extraction. Local extraction is a utility, and a local one is free forever. What nobody else offers is the chase list: knowing which invoice to chase this week, because the model predicts days-to-payment and you don't have to remember that one client always pays on day 60. That's the $12/month hypothesis, framed around the part that isn't commoditised.

How it works. pdf.js reads the PDF text layer; tesseract.js handles scans with no text layer; gemma3:4b extracts eleven typed fields via Ollama on the user's CPU; each field gets a review-priority score; export CSV/JSON. Then TabPFN-3.5 forecasts days-to-payment on your invoice history.

No login. No API key. No upload. The document is read in memory and never written to disk — there is no document store to breach because there is no document store.

Demo

Live: https://ledger-room-indigo.vercel.app — no login, no API key

Ledger - Landing Page

Ledger - Upload Page

Ledger - Review Page

Try it in 30 seconds: click "Use the 20-document demo set". That loads real recorded model output, including its mistakes, so you can exercise review, correction, CSV export and the chase list immediately.

Try it for real:

ollama pull gemma3:4b 

Then open /app. The runtime banner turns green when it finds your local model.

If the banner says "Demo data" instead, your Ollama is refusing your browser's origin. The banner prints the exact OLLAMA_ORIGINS command with your origin filled in — paste it, restart Ollama, press Re-check.

Code

Ledger

A local-first invoice and receipt sorter for freelancers, with a measured accuracy study of what the privacy costs.

License: MIT Next.js 16 Local inference

Live demo · Report an issue

The problem

A freelancer ends each month with 30–60 invoices and receipts in mixed formats — emailed PDFs, phone photos of paper receipts, screenshots of payment confirmations — and needs them as structured rows for an accountant or a tax filing.

There are two ways to get there, and one thing that looks like a third:

Option Cost The catch
Pay a bookkeeper $50–150/month A stranger handles every client name, rate and account number you own
Do it yourself 2–4 hours/month, forever The same hours every month, re-reading documents whose content you already have
Paste it into a hosted chatbot Free Invoice text contains client names, project names, rates and bank details — and it goes to a service you don't control

Ledger removes the…

MIT licensed. The embed above pulls in the repository README, which is where the measured results table, the reproduction commands and the verification checklist live.

Every number in this post is reproducible from a committed script:

npm run fixtures # deterministic corpus: PDFs, PNGs, ground truth npm run ingest:verify # asserts text-layer vs OCR routing, one assertion per fixture npm run eval -- --model gemma3:4b --out results/eval-4b.json npm run history && npm run chase:eval npm run results:export # regenerates EVAL.md's tables and the demo dataset npm run chase:check # fails loudly if the baselines silently degrade into each other 

The model response cache is committed, so npm run eval -- --model gemma3:4b reproduces the headline table in 8 seconds on a machine with no GPU, no Ollama, and no hosted quota. It tells you it did so:

All 20 responses came from the committed cache — no model was run. Delete results/cache/ or pass --no-cache to force a real run. 

That is the reproducibility claim I care about: you shouldn't have to trust my numbers, you should be able to re-derive them.

How I Built It

The whole pipeline

PDF/JPG/PNG → pdf.js text layer (or tesseract.js OCR if there isn't one) → gemma3:4b via Ollama, schema-constrained, temperature 0 → Zod validation, one repair round-trip → per-field review-priority score → review table → CSV/JSON → TabPFN-3.5 chase list 

Schema-constrained output is what makes the numbers trustworthy

The Zod schema is converted with z.toJSONSchema() and passed to Ollama as a structured-output grammar constraint, at temperature: 0, with one repair round-trip available.

Measured: zero schema violations across 20 fixtures, and zero repairs needed.

That is a guarantee from the grammar, not a capability of the model — which matters, because small models produce plausible JSON with a string where a number belongs, and in a finance tool a plausible wrong number is worse than an obvious failure.

Confidence is computed, never asked for

A 4B model asked to rate its own confidence produces a number with no calibration behind it, and it is confidently wrong on exactly the fields that cost money. So the score is built from three things that can be checked:

  • verbatim — does this value actually appear in the source text?
  • structure — is it well formed for its type (ISO date, ISO currency, finite number)?
  • agreement — does subtotal + tax == total? Do the line items sum to the subtotal?

It's shown as a review-priority score out of 100, never as a probability, because that's what it is.

The runtime banner never claims a path that will fail

On load the app probes for a local model and reports one of four states: local → hosted → demo → blocked. A judge who sees "local runtime detected" on a machine with no Ollama is worse off than one told plainly that they need to pull a model.

Payment prediction, measured against baselines

TabPFN-3.5 forecasts days-to-payment with a q10–q90 interval, scored against two naive baselines on a held-out slice. Only numbers and a hashed client identifier are sent to Prior Labs — the raw client name never leaves the device.

Why Does Open Innovation Matter?

It changed what was buildable, not just what was affordable.

Cost. Both the local arm and the free hosted arm cost $0.00. The interesting result isn't that local is cheap — it's that a 7.75× larger hosted model is also free. Open weights moved the ceiling without a budget.

Swap-ability. One Zod schema and one response_format call drive a 1B model on my laptop and a 31B model on someone else's GPU. The eval harness ran both arms without a code change — only possible because the interface is a schema, not a vendor's API shape.

Offline. Local extraction works with the network disconnected after the model download. For a freelancer reconciling receipts on a train, that's not a performance feature, it's the feature.

Privacy you can check rather than believe. Open DevTools, drop one invoice, watch every request go to 127.0.0.1. Then disconnect the network and do it again. That's a 60-second verification anyone can run, and it's the reason this product exists instead of a wrapper around someone else's endpoint.

A reproducible study. Because the cache is committed, anyone re-derives every number in 8 seconds without a GPU. With a closed API this is impossible — a third party would have to reproduce your closed-model results on hardware they don't have.

The study

Twenty synthetic invoices and receipts — text-layer PDFs, image-only scans, decimal-comma locales, ambiguous dates like 03/04/26, thousands separators, PKR / EUR / GBP / USD. Ground truth hand-authored before the first run and never edited to match model output.

Hardware: i7-1165G7, 4 cores / 8 threads, 16 GB RAM, no GPU, Windows 11.

Headline — local gemma3:4b, CPU only

Metric Value
Field accuracy 66.2% (n=19)
Exact-record accuracy 5.3%
Line-item accuracy 58.3%
Populated-field recall 80.9%
p50 / p95 latency 57.0s / 103.5s
Throughput 60.7 documents/hour
Schema violations reaching the UI 0
Cost per 20 invoices $0.00

Per-field, including where it fails

I wrote down the expected failure modes before running anything. They were mostly right:

Field Accuracy Expected failure mode
tax 100.0% almost always printed explicitly
vendor 94.7% multi-column layouts
issueDate 89.5% ambiguous dd/mm vs mm/dd
dueDate 89.5% genuinely absent — correct null behaviour matters
total 84.2% decimal commas; odd labels like GRAND T0TAL
currency 78.9% symbol vs ISO code
subtotal 73.7% thousands separators and decimal commas
invoiceNo 68.4% model normalises separators
billable 47.4% no signal in most receipts
category 15.8% open vocabulary — weakest field, as predicted

category in the teens is the honest result. I could have raised it by switching to a closed category list; I didn't, because a number that improves by making the field useless isn't worth publishing. The scorer accepts only what the document genuinely permits — never what the model happened to emit.

Size curve, same hardware

Model Params Field accuracy p50
gemma3:1b 1B 59.4% 37.7s
gemma3:4b 4B 66.2% 57.0s

Four times the parameters bought 6.8 points of accuracy for 1.5× the latency. The more interesting result points the other way: the 1B model had the higher populated-field recall — 96.2% vs 80.9%. It filled in more fields and got more of what it filled in wrong. For an unattended pipeline that's the dangerous combination, and it's an argument for constraining a small model harder, not trusting it more.

The hosted 31B arm was not run. I planned it against google/gemma-4-31b-it:free via OpenRouter and I didn't run it. The free tier allows 50 requests/day and a full run consumes most of that; I ran the local arms first and ran out of time. The table says "not run" rather than being quietly omitted. Two things stay true anyway — the local arm makes zero external requests, and the hosted arm is also $0.00. And even if I'd run it, it wouldn't be a clean ablation: a hosted Gemma 4 against a local Gemma 3 confounds parameter count with model generation. So the central local-vs-hosted accuracy claim here is unmeasured, and I'd rather say that than dress a gap up as a finding.

Payment prediction, against baselines it has to beat

Predictor MAE (days) n
TabPFN-3.5 7.91 17
Baseline: that client's historical mean 8.12 17
Baseline: global mean 13.48 17

Fit on 67 rows, scored on the 17 most recent, which were never in the training target. 84 paid invoices, 8 clients, 12 features.

TabPFN wins by 2.6%. A real margin, and a narrow one, and the reason is worth stating plainly: who pays late is mostly a property of who the client is, so a well-built per-client mean is a genuinely strong baseline rather than a straw man. The model's contribution is in the residual — invoice size, seasonality, the client's own variance.

Its q10–q90 interval contained the true value in 12 of 17 held-out rows — 71%, against an 80% nominal target. The intervals run a little narrow, and I'd rather report that than round it to "roughly 80%".

Where open lost, plainly: accuracy (a frontier model beats 66.2% comfortably, and category at 15.8% is where size would most obviously pay), speed (57s/document — my own ADR-002 estimated 15–25s and the estimate is simply wrong; I left the miss in the decision log), and availability (free hosted tiers are rate-limited with upstream saturation on top, a cost that never appears in an accuracy table).

Two bugs worth reading about

I published the study, then verified the deployed app instead of trusting my local run. Both of these were found by checking, not by reading.

1. A baseline bug that flattered my own model. The deployed /api/predict reported the client-mean baseline as 13.48 days — identical to the global mean. A per-client baseline scoring exactly the global mean is self-evidently broken. The handler keyed per-client history off a lookup that could never match, so the map stayed empty and the baseline silently degraded to the global mean.

Real client-mean MAE is 8.12. So the deployed app was claiming a 35% margin where the true margin is 2.6%. The bug made my model look better than it is — the one failure mode this project can't afford, given its whole argument is "we published our own numbers, including where we lost."

Fixed, plus npm run chase:check, which fails if the two baselines ever converge again.

2. I instrumented my own app into a CORS failure. After adding Sentry tracing, the runtime probe stopped finding a local Ollama on machines that had one. My first diagnosis was Chrome's Private Network Access — I verified the relevant header was absent, built a development proxy around that theory, and wrote it into ARCHITECTURE.md.

The real cause was my own Sentry config: a tracePropagationTargets rule pointed at localhost, injecting a sentry-trace header into every request to 127.0.0.1. That turned a CORS simple request into a preflighted one, and Ollama's preflight allowlist is fixed, so it rejected the header. The fix was removing the rule — and I deleted the proxy rather than leave a confidently wrong technical claim in the codebase.

My Agent Session

Open Code Session
You

Go through all the docs and get to building

Agent

I'll start by exploring the project structure and documentation.

▸ bash {"command":"ls -Force","workdir":"C:\\All Projs\\ledger-room"}
$ {"command":"ls -Force","workdir":"C:\\All Projs\\ledger-room"}
 Directory: C:\All Projs\ledger-room Mode LastWriteTime Length Name ---- ------------- ------ ---- d--h-- 10/18/2026 10:42 PM .git d----- 10/18/2026 10:31 PM .next d----- 10/18/2026 9:44 PM app d----- 10/18/2026 9:47 PM components d----- 10/18/2026 9:44 PM hooks d----- 10/18/2026 9:47 PM lib d----- 10/18/2026 9:47 PM node_modules d----- 10/18/2026 9:44 PM public -a---- 10/18/2026 10:20 PM 2186 .env -a---- 10/18/2026 10:22 PM 2452 .env.example -a---- 10/18/2026 9:44 PM 431 .gitignore -a---- 10/18/2026 9:44 PM 78 .prettierignore -a---- 10/18/2026 9:44 PM 266 .prettierrc -a---- 10/18/2026 9:44 PM 687 AGENTS.md -a---- 10/18/2026 10:33 PM 11352 ARCHITECTURE.md -a---- 10/18/2026 9:44 PM 12 CLAUDE.md -a---- 10/18/2026 9:46 PM 527 components.json -a---- 10/18/2026 10:40 PM 17514 DECISIONS.md -a---- 10/18/2026 10:39 PM 14788 DESIGN.md -a---- 10/18/2026 9:44 PM 483 eslint.config.mjs -a---- 10/18/2026 10:33 PM 8104 EVAL.md -a---- 10/18/2026 9:57 PM 1076 LICENSE -a---- 10/18/2026 10:31 PM 303 next-env.d.ts -a---- 10/18/2026 9:44 PM 106 next.config.ts -a---- 10/18/2026 9:47 PM 349059 package-lock.json -a---- 10/18/2026 9:47 PM 928 package.json -a---- 10/18/2026 10:31 PM 13688 PLAN.md -a---- 10/18/2026 9:44 PM 99 postcss.config.mjs -a---- 10/18/2026 10:32 PM 6733 POST_OUTLINE.md -a---- 10/18/2026 10:40 PM 11195 PRD.md -a---- 10/18/2026 10:24 PM 6403 PRIVACY.md -a---- 10/18/2026 9:44 PM 415 README.md -a---- 10/18/2026 9:44 PM 700 tsconfig.json 
▸ glob {"pattern":"**/*.md"}
{ "pattern": "**/*.md" }
C:\All Projs\ledger-room\README.md C:\All Projs\ledger-room\DESIGN.md C:\All Projs\ledger-room\PLAN.md C:\All Projs\ledger-room\EVAL.md C:\All Projs\ledger-room\DECISIONS.md C:\All Projs\ledger-room\PRD.md C:\All Projs\ledger-room\POST_OUTLINE.md C:\All Projs\ledger-room\AGENTS.md C:\All Projs\ledger-room\PRIVACY.md C:\All Projs\ledger-room\ARCHITECTURE.md C:\All Projs\ledger-room\CLAUDE.md
Agent

I'll read the core docs in parallel.

▸ read {"filePath":"C:\\All Projs\\ledger-room\\PRD.md"}
{ "filePath": "C:\\All Projs\\ledger-room\\PRD.md" }
<path>C:\All Projs\ledger-room\PRD.md</path> <type>file</type> <content> 1: # Ledger — Product Requirements 2: 3: **Status:** Draft for Hacktoberfest Week 2 4: **Version:** 1.1 5: **Date:** 2026-10-18 6: 7: --- 8: 9: ## 1. Problem 10: 11: A freelancer ends each month with 30–60 invoices and receipts in mixed 12: formats: emailed PDFs, phone photos of paper receipts, screenshots of 13: payment confirmations. They need those as structured rows for an accountant 14: or a tax filing. 15: 16: There are exactly two options today, and both are bad: 17: 18: 1. **Pay someone.** Bookkeepers charge $50–150/month for data entry. 19: 2. **Do it manually.** Two to four hours a month, every month. 20: 21: The generic alternative — paste an invoice into a hosted LLM chat — is not 22: viable, because invoice text contains client names, project names, rates and 23: bank details, and freelancers are precisely the population least willing to 24: paste that into a service they do not control. 25: 26: ## 2. Persona 27: 28: **Sam**, freelance product designer. Three to eight client invoices a month 29: plus a wall of software and utility receipts. Keeps receipts in a Downloads 30: folder and an email folder and reconciles monthly, dreading it. Would pay 31: $10–20/month to stop doing it. Will not upload client data to a hosted API. 32: 33: ## 3. Job to be done 34: 35: > "When my month closes and I have a folder of receipts, turn them into a 36: > spreadsheet my accountant accepts, without me reading each one — and 37: > without my clients' names leaving my laptop." 38: 39: ## 4. Positioning 40: 41: Two-part thesis, mirroring the challenge's own framing: 42: 43: - **Product:** local-first and privacy-first. The default path runs entirely 44: on the user's hardware, in the browser, offline after the model download. 45: - **Evidence:** a measured accuracy study showing exactly what that costs, 46: what it buys, and where a hosted approach wins. No hand-waving. 47: 48: ## 5. Scope 49: 50: ### In scope (v1) 51: 52: 1. Multi-file upload of PDF, JPG, PNG 53: 2. PDF text-layer extraction, with OCR fallback 54: 3. Structured field extraction by an open-weight model, schema-constrained 55: 4. Runtime ladder: local → hosted → demo → blocked 56: 5. Best-effort redaction of client identity before any external call 57: 6. Review-and-correct table with confidence flags 58: 7. CSV and JSON export 59: 8. Payment prediction (days-to-payment) from invoice history 60: 9. Opt-in cloud sync of extracted fields 61: 10. Per-document tracing 62: 63: ### Out of scope 64: 65: - Accounting logic, double-entry, tax computation 66: - Bank feeds, Plaid, QuickBooks two-way sync 67: - Multi-user accounts, auth, workspaces 68: - Payment collection — **explicitly excluded, because Vercel Hobby's terms 69: restrict it to personal, non-commercial use** 70: - Receipt image storage. The app does not retain the image after extraction. 71: 72: ## 6. Functional requirements 73: 74: | ID | Requirement | Acceptance | 75: |---|---|---| 76: | FR-1 | Accept PDF/JPG/PNG, up to 100 files per batch | 20-fixture batch completes unattended | 77: | FR-2 | Use the PDF text layer when present | Verified on text-layer fixtures | 78: | FR-3 | OCR fallback when the text layer is absent or sparse | Verified on image-only fixtures | 79: | FR-4 | Extract vendor, invoice no., issue date, due date, line items, subtotal, tax, total, currency, category, billable | ≥90% of fields populated on ≥90% of fixtures | 80: | FR-5 | Output must satisfy the Zod schema or be repaired | Zero schema violations reach the UI | 81: | FR-6 | Per-field confidence shown as flags | Low-confidence fields visually distinguished | 82: | FR-7 | Inline edit of any field | Edits persist and update the row | 83: | FR-8 | Capture every correction as a training example | Corrections appended to a durable log | 84: | FR-9 | Export CSV and JSON | Export round-trips to identical values | 85: | FR-10 | Predict days-to-payment per open invoice with interval | Interval present on every predicted row | 86: | FR-11 | Sort the chase list by predicted lateness | Ordered worst-first | 87: | FR-12 | Emit one trace per document with OCR/LLM/validation spans | Trace visible in Sentry with tags | 88: | FR-13 | Detect the available runtime on load and display it | Banner names the active provider and its privacy properties | 89: | FR-14 | Offer a hosted path when local is unavailable | One explicit session confirmation before the first document | 90: | FR-15 | Offer a pre-computed demo dataset when no runtime is available | Judge can exercise the full flow with zero setup | 91: | FR-16 | Surface remaining hosted quota before a batch starts | Quota shown in the banner; exhaustion returns a clear, non-retryable error | 92: | FR-17 | Redact client names and account numbers before any external call | Text leaving the device contains no vendor names or long digit runs | 93: | FR-18 | Optional cloud sync of extracted fields, default off | Off by default; enabling is an explicit action | 94: | FR-19 | Landing page at `/` presenting the product as a real product | Twelve sections per `DESIGN.md` §8, complete before deploy | 95: | FR-20 | Light/dark theme, respecting system preference | Persists explicit choice; no flash of wrong theme on first paint | 96: | FR-21 | Site header with nav, theme toggle and primary CTA; full footer | Sticky header, multi-column footer with attribution and licence | 97: 98: ## 6a. Design requirements 99: 100: Specified in full in `DESIGN.md`. Non-negotiable summary: 101: 102: | Requirement | Detail | 103: |---|---| 104: | Accent | Existing lime tokens, used **unmodified**. Surface in both themes; accent text as `text-primary-foreground dark:text-primary` | 105: | Focus | `ring-2 ring-foreground` with background offset. `--ring` measures 2.32:1 and fails the 3:1 requirement | 106: | Secondary text | `text-secondary-foreground dark:text-muted-foreground`. `--muted-foreground` alone is 4.26:1 in light | 107: | Numerals | All money, dates and counts use `tabular-nums` | 108: | Components | shadcn/ui Base UI variant throughout. No hand-rolled primitives | 109: | State | Confidence and provenance never conveyed by colour alone — always paired with text or an icon | 110: | Motion | `tw-animate-css` (already imported) or CSS transitions only, 150–200ms. No animation library. `prefers-reduced-motion` honoured | 111: | Pending data | `EVAL.md` `_TBD_` cells render as visibly pending, never as zeros | 112: | Contrast | WCAG AA on every pair actually used. Rules in `DESIGN.md` §2 | 113: 114: ## 7. Non-functional requirements 115: 116: | ID | Requirement | 117: |---|---| 118: | NFR-1 | Extraction runs on CPU. No GPU, ever | 119: | NFR-2 | In local mode, document content makes zero external network requests | 120: | NFR-3 | A judge can exercise the whole flow with no login and no API key | 121: | NFR-4 | Batch processing is client-orchestrated; no single invocation exceeds 300s | 122: | NFR-5 | Disk footprint under 6 GB including the model | 123: | NFR-6 | No secret appears in any build artifact | 124: | NFR-7 | Every published number is reproducible from a committed script | 125: | NFR-8 | Hosted requests carry `data_collection: "deny"`; the request fails rather than route to a non-compliant endpoint | 126: | NFR-9 | Every interactive element keyboard reachable with a visible focus ring | 127: | NFR-10 | No horizontal scroll at 320px width | 128: | NFR-11 | Theme applied before first paint — no flash of the wrong theme | 129: | NFR-12 | Landing page content above the fold does not depend on JavaScript | 130: 131: ## 8. Data model 132: 133: ```ts 134: type Invoice = { 135: id: string 136: sourceFile: string 137: vendor: string | null 138: invoiceNo: string | null 139: issueDate: string | null // ISO 8601 140: dueDate: string | null // ISO 8601 141: currency: string | null // ISO 4217 142: subtotal: number | null 143: tax: number | null 144: total: number | null 145: category: string | null 146: billable: boolean | null 147: lineItems: Array<{ 148: description: string 149: quantity: number | null 150: unitPrice: number | null 151: amount: number | null 152: }> 153: confidences: Record<string, number> // field -> 0..1 review-priority score 154: provenance: { 155: textLayer: boolean 156: ocrUsed: boolean 157: model: string 158: runtime: "local" | "hosted" | "demo" 159: } 160: corrected: boolean 161: } 162: 163: type PaymentPrediction = { 164: invoiceId: string 165: clientHash: string // never the raw client name 166: amount: number 167: predictedDaysToPayment: number 168: interval: [number, number] // q10, q90 169: willBeLate: boolean 170: features: Record<string, number | string> 171: model: string // "tabpfn-3.5" 172: baselines: { 173: clientMeanDays: number 174: globalMeanDays: number 175: } 176: } 177: 178: type Correction = { 179: id: string 180: invoiceId: string 181: field: keyof Invoice 182: predicted: unknown 183: actual: unknown 184: at: string // ISO 8601 185: } 186: ``` 187: 188: ## 9. Persistence (optional, default off) 189: 190: MongoDB Atlas M0, 512 MB, free forever, no credit card. 191: 192: | Collection | Contents | Never stored | 193: |---|---|---| 194: | `invoices` | Extracted structured fields | Document image, raw OCR text | 195: | `corrections` | Prediction vs actual per field | Document image, raw OCR text | 196: | `predictions` | TabPFN outputs and feature rows | Raw client names | 197: 198: Why this is a genuine fit rather than box-ticking: the `corrections` collection 199: is a **self-growing, correctly-labelled training set**. Every review the user 200: performs is a labelled example of the exact failure mode they care about. That 201: is the dataset a fine-tune would need, and it is produced as a side effect of 202: normal use. 203: 204: Note: Atlas Vector Search requires a paid search node and is **not** available 205: on M0. Similar-invoice retrieval is instead implemented as a local cosine 206: similarity scan over embeddings produced by the local model, which is trivial 207: at this data size and keeps the "no paid tier" property intact. 208: 209: ## 10. Pricing hypothesis 210: 211: Free local tier up to 50 documents/month. $12/month for sync and chase-list 212: forecasting. This is a hypothesis to test with the first user, stated in the 213: writeup — the app must not collect payment, per Vercel Hobby's terms. 214: 215: ## 11. Success metrics 216: 217: | Metric | Target | 218: |---|---| 219: | Field-level accuracy, local 4B | Measured and published, no target | 220: | Documents per hour, CPU only | Measured and published | 221: | Cost per 20 invoices | $0.00 on the local path | 222: | First user would pay $12/mo | Yes or no, recorded honestly | 223: | Judge completes the flow unaided | Yes | 224: 225: ## 12. Known limitations, stated up front 226: 227: 1. Local 4B extraction is less accurate than a frontier hosted model, and this 228: project measures exactly how much less. 229: 2. Ambiguous numeric dates (`03/04/26`) are a known failure mode. 230: 3. Payment prediction needs history. Below ~20 historical invoices it will be 231: weak, and the UI should say so rather than showing false precision. 232: 4. Handwriting is out of scope. 233: 5. Vercel Hobby functions cannot exceed 300s, which is why batches are 234: client-orchestrated. 235: 6. The hosted path is limited to 50 requests/day and is subject to upstream 236: rate limiting. It is a demo path, not production infrastructure. 237: 7. Redaction before external calls is best-effort obfuscation, not 238: anonymisation, and the UI says so. 239: 8. The hosted arm of the accuracy study compares a different model generation 240: (Gemma 4 hosted) against local Gemma 3, so it is not a controlled 241: parameter ablation. 242: 243: ## 13. Attribution 244: 245: Must be credited in the submission post: shadcn/ui (Base UI variant), Next.js, 246: Tailwind CSS, Ollama, Google's Gemma 3 weights, Tesseract, TabPFN (Prior 247: Labs), MongoDB Atlas, Sentry. (End of file - total 247 lines) </content>
▸ read {"filePath":"C:\\All Projs\\ledger-room\\PLAN.md"}
{ "filePath": "C:\\All Projs\\ledger-room\\PLAN.md" }
<path>C:\All Projs\ledger-room\PLAN.md</path> <type>file</type> <content> 1: # Ledger — Execution Plan 2: 3: **Project:** Ledger — local-first invoice & receipt sorter for freelancers 4: **Challenge:** Hacktoberfest Open-Source AI Challenge, Week 2 (`#hf26challenge`) 5: **Deadline:** 2026-10-19 06:59 UTC (11:59 UTC+5) 6: **Prepared:** 2026-10-18 UTC+5 7: **Working budget:** ~30h of the ~35h available 8: 9: --- 10: 11: ## 1. Objective 12: 13: Ship a deployed, judge-testable web app that converts a folder of mixed 14: invoices and receipts into an accountant-ready CSV, and predicts which open 15: invoices will be paid late. Extraction runs on an open-weight model on the 16: user's own hardware. Everything is documented with a reproducible accuracy 17: study. 18: 19: ## 2. Hard constraints 20: 21: | Constraint | Value | Consequence | 22: |---|---|---| 23: | No GPU | i7-1165G7, 4C/8T | Only models ≤4B are usable locally | 24: | Disk | ~16 GB free | Single local model; no 12B/27B pull | 25: | RAM | 16 GB | 4B Q4 leaves ample headroom | 26: | No credit card | — | No DigitalOcean, no paid inference | 27: | Vercel Hobby | Free, no card | **Personal, non-commercial only.** No payment button, no ads, no affiliate links. | 28: | Vercel Hobby functions | 300 s max duration | Batch extraction must be client-orchestrated, one document per request | 29: | Vercel Hobby limits | 100 GB transfer, 1M invocations, 4 CPU-h/mo | Fine for a demo; document processing is mostly client-side | 30: | OpenRouter free tier | 50 req/day, 20 req/min | Local arms first; hosted arm is a declared subset or cached | 31: | OpenRouter upstream | Google AI Studio, frequently 429 | Backoff, `unavailable` outcomes, demo fallback | 32: | Time | **13.6h** | Four categories max, no stretch. Cut at hour 22 from an original 30h plan | 33: | New work | Must start Oct 12+ | Git history must show this | 34: ## 3. Target prize categories (4, $600) 35: 36: Scope was cut from seven to four when the window shrank to 13.6 hours. Writing 37: is weighted most heavily by the rubric, so the budget belongs there — three 38: categories done well beats six rushed. 39: 40: | # | Category | Amount | Dependency | Effort | Card | 41: |---|---|---|---|---|---| 42: | 1 | Gemma | $200 | Ollama + `gemma3:4b`; hosted arm via OpenRouter free Gemma 4 | Core | No | 43: | 2 | Prior Labs (TabPFN) | $200 | platform.priorlabs.ai free key | ~1.5h | No | 44: | 3 | MongoDB Atlas | $100 | Free M0 cluster, 512 MB | ~45m | No | 45: | 4 | Sentry Agent Tracing | $100 | Free developer org | ~30m | No | 46: 47: **Cut: Render, .tech Domains, Entire.** Render was dropped on the assumption 48: that Vercel replaces it. Vercel is not a sponsor, but a Render *static* site 49: from the same branch is ~20 minutes for $200 — the best ratio on the board — 50: so it is gated rather than abandoned (below). `.tech` dropped: `*.vercel.app` 51: is an acceptable public URL. Entire dropped: worth $100, but the time is 52: better spent on the post. 53: 54: Deliberately never pursued: DigitalOcean (card required), Thinking Machines / 55: Tinker (needs credits), Backboard, ElevenLabs, GitHub Copilot, SerpApi, 56: Temporal, Tiger Data, Mastra, Qualcomm Arduino. 57: 58: ### The hour-8 gate 59: 60: Two items are gated, not committed. Both are additive and neither may block 61: the core app. Both must not start before the core extraction path works. 62: 63: | Item | Gate | If not done | 64: |---|---|---| 65: | MongoDB Atlas | Hour 8 | Ship without. Category lost, app unaffected. | 66: | Render static mirror | Hour 8 | Ship without. $200 forfeited. | 67: 68: ## 4. Deployment topology 69: 70: | Target | Role | What it serves | 71: |---|---|---| 72: | `localhost` | Development | Full app, live Ollama, real fixtures | 73: | Vercel | **Primary and public** | Full build at `*.vercel.app`. Hosts the hosted-path route handlers. | 74: | Render | Optional mirror, gated | Static export of the same branch, if hour-8 capacity allows | 75: | MongoDB Atlas | Optional, gated | Opt-in sync of extracted fields only, fire-and-forget | 76: 77: 78: ## 5. Workstreams 79: 80: ### WS1 — Risk retirement (0:00–3:00) `GATE` 81: 82: The whole project depends on Gemma 3 4B returning schema-valid JSON through 83: Ollama's structured-outputs `format` field, from the browser, on messy 84: invoice text. Nothing else is built until this is proven. 85: 86: - [ ] `ollama run gemma3:4b` responds 87: - [ ] Define the extraction Zod schema 88: - [ ] `z.toJSONSchema()` → Ollama `format` field 89: - [ ] Schema-valid output on 5 hand-written invoices 90: - [ ] **Browser → Ollama direct call works** (set `OLLAMA_ORIGINS`, confirm no mixed-content block) 91: - [ ] Measure tokens/sec on this CPU 92: 93: **Gate:** if browser→Ollama cannot be made to work inside 3 hours, fall back 94: to routing local extraction through a Next.js route handler during dev only, 95: and lead the judge demo with the pre-computed dataset. Do not proceed on hope. 96: 97: ### WS2 — Evaluation harness + fixtures (1:00–2:00) 98: 99: Built before the UI, because it is what makes the project credible and what 100: the writeup is built on. 101: 102: - [ ] Generate **20** synthetic invoices/receipts (trimmed from 35 for time) 103: - [ ] Author ground-truth JSON for each 104: - [ ] Scorer: per-field accuracy, exact-record rate, latency, token cost 105: - [ ] Run against local `gemma3:4b` — headline numbers, no quota consumed 106: - [ ] Optional: OpenRouter free `gemma-4-31b-it` as the hosted comparison. 107: **Consumes the 50/day quota — run last, over a declared subset, with 108: response caching keyed by `sha256(fixtureText + modelId + schemaHash)`.** 109: - [ ] Drop the 1B arm if time is short. It is the weakest of the three rows. 110: 111: ### WS3 — Ingest + OCR (2:00–3:15) 112: 113: - [ ] PDF text-layer extraction via `pdf.js` in the browser 114: - [ ] Fallback rasterization → `tesseract.js` (WASM, `eng`) 115: - [ ] Heuristic: skip OCR when the text layer is dense enough 116: 117: ### WS4 — Core app (3:15–6:15) 118: 119: - [ ] Next.js 16 + TypeScript + Tailwind + shadcn (Base UI variant), per `DESIGN.md` 120: - [ ] Upload: drag-drop, multi-file, per-document progress queue 121: - [ ] Runtime ladder in `/api/health`: local → hosted → demo → blocked, with 122: remaining hosted quota surfaced 123: - [ ] Best-effort redaction pass before any external call (ADR-016) 124: - [ ] Extraction: schema-constrained, `temperature: 0`, one repair round-trip 125: - [ ] Review table: inline edit, per-field confidence flags 126: - [ ] Correction capture → local `corrections.jsonl` 127: - [ ] CSV + JSON export 128: 129: ### WS5 — TabPFN chase list (6:15–7:30) 130: 131: Highest value per hour after Gemma. Protect this one. 132: 133: - [ ] CSV upload of ≥12 months of invoice history 134: - [ ] `/tabpfn/fit` → `/tabpfn/predict`, `v3.5_default`, `TabPFNRegressor` 135: - [ ] `output_type: "quantiles"` → predicted days-to-payment + q10/q90 interval 136: - [ ] Feature engineering: client (hashed), amount, issue lag, client history 137: count, that client's historical mean days-to-pay, seasonality 138: - [ ] Chase list sorted worst-first 139: - [ ] Naive baselines (client-mean, global-mean) so the result is credible 140: 141: ### WS6 — Persistence (gated, 30–45 min) 142: 143: Fire-and-forget, silently degradable. See ADR-011 and ADR-017. 144: 145: - [ ] Atlas M0 cluster, 512 MB, no card 146: - [ ] Collections: `invoices`, `corrections`, `predictions` 147: - [ ] Opt-in sync toggle, **default off**, non-blocking writes 148: - [ ] Store extracted fields only — never the document, never raw OCR text 149: - [ ] If Atlas is not connected and working within 45 minutes, abandon it 150: 151: ### WS7 — Observability (30 min) 152: 153: - [ ] Sentry: one transaction per document, spans for OCR / LLM / validation 154: - [ ] Tags: `llm.model`, `llm.tokens_in`, `llm.tokens_out`, `runtime` 155: - [ ] Capture 2–3 trace screenshots, including one real bug found and fixed 156: 157: ### WS8 — Landing page (1.75h) 158: 159: Per `DESIGN.md` §8. Twelve sections, shadcn primitives only, CSS-first motion. 160: The accuracy-study section renders `EVAL.md` for real, `_TBD_` cells visibly 161: marked as pending. 162: 163: - [ ] Header, hero, problem, how-it-works 164: - [ ] Product preview, accuracy study, privacy, pricing, footer 165: - [ ] Light/dark across every section, respect system, no flash 166: 167: ### WS9 — Deploy (30 min) 168: 169: - [ ] Vercel: connect repo, set env vars, verify production build 170: - [ ] Optional Render static mirror if the gate allows 171: - [ ] Grep the build output for every secret 172: - [ ] Confirm the judge path works unaided 173: 174: ### WS10 — Write & publish (2h, starts on time) 175: 176: **Non-negotiable. Starts when it starts, regardless of build state.** 177: 178: - [ ] Draft per `POST_OUTLINE.md` 179: - [ ] Tags: `devchallenge`, `hf26challenge` 180: - [ ] Publish with buffer 181: 182: ## 6. Schedule (local, UTC+5) 183: 184: | Start | End | Hours | Block | 185: |---|---|---|---| 186: | 22:25 | 00:00 | 1.6 | WS1 risk retirement gate | 187: | 00:00 | 01:00 | 1.0 | WS2 harness + 20 fixtures | 188: | 01:00 | 02:15 | 1.25 | WS3 ingest + OCR | 189: | 02:15 | 05:15 | 3.0 | WS4 core app | 190: | 05:15 | 06:30 | 1.25 | WS5 TabPFN | 191: | 06:30 | 07:15 | 0.75 | WS6 Mongo (gated) | 192: | 07:15 | 07:45 | 0.5 | WS7 Sentry | 193: | 07:45 | 09:30 | 1.75 | WS8 landing page | 194: | 09:30 | 10:00 | 0.5 | WS9 deploy | 195: | **10:00** | **12:00** | **2.0** | **WS10 write and publish** | 196: | 12:00 | 11:55 | — | Buffer | 197: 198: **Total planned work: 13.0h against a 13.5h window. There is effectively no 199: slack, which is why the gates below exist.** 200: 201: ### Hard checkpoints 202: 203: | Time | Check | If failing | 204: |---|---|---| 205: | 00:00 | ...
Source:https://dev.to/talha666tahir/necrovault-the-ghosts-will-keep-your-clients-names-off-the-internet-2115

Related posts