Skip to content

Repository files navigation

MoneyGuard

CI Node TypeScript License: MIT

A privacy-conscious, vision→reasoning pipeline that turns a photo of a timecard into a warm, numerically-grounded financial audit, with explicit data boundaries for local and hosted use.

Snap a picture of a timecard. MoneyGuard reads the hours with a vision model, computes your real weekly position locally, reduces the ledger to selected aggregate metrics, and streams back a mentor-style audit from a text model — token by token, rate-limit-safe.

# Create the required private ledger, then run with zero API keys:
cp finance.example.json finance.json
npx moneyguard --mock fixtures/timecard.png
📊 Wage Audit (2026-W26)
---
🕒 Labor:   38 hrs
💰 Gross:   $950.00 AUD
📉 Burn:    $552.38 AUD
💎 Surplus: $397.62 AUD | STABLE
---
🧠 Audit:
先停下来给自己一个肯定——一周四十多个小时的体力活扛下来,
还能自费把学习和 AI 工具一个个续上,这份狠劲本身就值钱…

Privacy posture by form factor

CLI/library. finance.json is read into memory on the machine running MoneyGuard and is never transmitted. Its line items, labels, and per-item tags never leave that machine. In live mode, the configured vision provider receives the timecard image, while the audit provider receives selected OCR values, aggregate financial metrics, and fixed directives.

Hosted /extract server. This has a different privacy posture. The uploaded image and the extracted hourly rate travel over the network by design: the server sends the image to the configured vision provider and returns hourlyRate to the authenticated web client as part of the documented response contract. Do not treat the hosted endpoint as a local-only workflow.

Hosted /v1/explain server. This additive endpoint accepts only the frozen masked-metrics/topic/profile contract and never reads finance.json. Pipeline keeps request, prompt, response, and stream content in memory only for the request lifecycle. DeepSeek is not approved for real-user production data: the only built-in DeepSeek mode is explicitly restricted to synthetic evaluation, and the endpoint otherwise fails closed until a named provider completes the Provider Privacy Gate.


Why this exists (the problem)

Someone self-funding a career change into tech needs honest financial feedback, but the input is a photo and the data is deeply personal — exact hourly rate, rent, every subscription. For the CLI/library workflow, two hard requirements fall out immediately:

  1. The ledger file and its line-item details must remain on the machine running the pipeline. Not in a prompt, not in a log.
  2. The model must still reason about the real numbers.

MoneyGuard resolves that tension with a local data-minimization boundary: ledger math happens in the local process, and only selected OCR values, aggregated metrics, and fixed tone directives are sent to the reasoning model. This is not a claim that the audit prompt is anonymous: it intentionally retains both hours worked and gross income while that product decision remains open.


Architecture at a glance

flowchart LR
    IMG[📷 Timecard image] --> VIS[Vision Provider<br/>Gemini — OCR only]
    LED[(finance.json<br/>local ledger)] -.in-memory.-> MET

    VIS -->|untrusted JSON| ZOD{Zod validate}
    ZOD -->|OcrResult| MET[computeMetrics<br/>pure math]
    MET --> PAY[buildAuditPayload<br/>🔒 minimize]

    subgraph LOCAL [" 🖥️  Local ledger processing — line-item details stay local "]
        LED
        MET
        PAY
    end

    PAY -->|selected OCR values + aggregate financial metrics + fixed directives| AUD[Audit Provider<br/>DeepSeek — streamed]
    AUD -->|tokens| RPT[buildReport<br/>deterministic skeleton]
    RPT -->|onReportUpdate| TX[(Transport<br/>CLI · Telegram)]
Loading

Split-brain compute. Two models, two jobs: a vision model receives the timecard image and does structured OCR, while a text model receives the selected OCR values and aggregate ledger metrics needed for empathetic copywriting. Neither is trusted to do the other's job.

The pipeline is the product; transports are disposable. runMoneyGuardPipeline(imageBuffer, { onReportUpdate }) is completely channel-agnostic. The CLI and the Telegram adapter are each ~40 lines that own only transport concerns.


Engineering challenges worth reading the code for

1. A local data-minimization boundary (src/payload.ts, src/metrics.ts)

For CLI/library use, the ledger is read in-memory and reduced to tag-aggregated weekly sums and a health tier. The finance.json file, line-item records, labels, and per-item tags are not transmitted. The audit payload intentionally includes selected values such as hours worked and weekly gross income, so it should not be described as anonymous or as hiding an hourly rate that can be inferred from those two values. currentRole is mapped to a fixed coaching directive instead of being interpolated into the prompt, and the reversible hoursFor150 metric is omitted. Tests seed private markers and assert that the raw ledger fields and role string do not appear in the outbound prompt (src/pipeline.test.ts). The hosted /extract posture is separate: its image upload and hourlyRate response cross the network by contract.

2. Stream-safe retry — the subtle one (src/resilience.ts)

You cannot wrap a live token stream in a naive "retry on failure": re-running it replays tokens the user already saw. MoneyGuard uses streamWithConnectRetry, which retries only while the stream fails before its first chunk (connection establishment). Once a single token is emitted, it never retries. Non-streaming calls (OCR) use ordinary withRetry with exponential backoff + equal jitter. Two retry strategies, deliberately not interchangeable — enforced by tests.

3. Streaming under a 1000ms throttle (transports)

Telegram returns HTTP 429 if you edit a message too fast. Every transport gates streamed re-renders to one per 1000ms while always applying the final frame, and keeps a trailing cursor until the last token lands. Crucially this lives in the transport, not the pipeline — the same THROTTLE_MS = 1000 contract appears in both src/cli/main.ts and examples/telegram/adapter.ts, proving it's a transport property.

4. Untrusted model output is validated, never cast (src/schemas.ts)

OCR output is hostile input. It's parsed with Zod: hours are coerced from possible strings, constrained to (0, 168], and a missing confidence defaults to low while an unexpected confidence is rejected. A blurry photo yields a clean "Vision Error", never a crash.

5. Tag-driven config with explicit subtotals (src/schemas.ts, src/metrics.ts)

finance.json is a list of tagged line items with a cadence (monthly/weekly). Aggregation filters by tag and normalizes each item to a weekly amount, without a hardcoded if/else chain. Three of the six accepted tags — essential, strategic_weapon, and discretionary — currently have their own weekly subtotals. The other three — liability, subscription, and variable — are accepted and validated but are not separately aggregated. Adding a category with its own subtotal requires adding the enum value, computing the subtotal in src/metrics.ts, and adding its output line in src/payload.ts: no new branching is required, but it is not a one-line change.

6. Dependency-injected providers (src/providers/)

The pipeline depends on two interfaces — VisionProvider and AuditProvider — not on any SDK. That yields three implementations behind one seam: Gemini, DeepSeek, and a deterministic Mock. The mock is what lets the whole pipeline run (and be tested) with zero keys and zero network. Tests inject stubs directly — no module mocking required.

7. A thin transport + a discriminated result (src/pipeline.ts)

The pipeline returns { ok: true } | { ok: false, kind: "config" | "vision" | "model", message }. Transports never inspect internals — they render message on failure and stream on success. Local config errors (bad JSON / failed schema) are deliberately distinguished from network/vision errors.


Quickstart

Requirements: Node ≥ 22.

git clone https://github.com/liuyuelintop/moneyguard-pipeline.git && cd moneyguard-pipeline
pnpm install            # or npm install

# 1) Create your private ledger, then run with zero keys:
cp finance.example.json finance.json
pnpm moneyguard --mock fixtures/timecard.png

# 2) Run the test suite (no keys needed):
pnpm test

# 3) Build the distributable library + CLI:
pnpm build

Live mode (real models)

cp .env.example .env       # then fill in your keys
cp finance.example.json finance.json   # your private ledger (gitignored)

# .env: GEMINI_API_KEY=...  DEEPSEEK_API_KEY=...
pnpm moneyguard path/to/real-timecard.png
Variable Purpose Default
GEMINI_API_KEY Vision / OCR auth — (required, live)
DEEPSEEK_API_KEY Audit / text auth — (required, live)
MONEY_GUARD_VISION_MODEL OCR model gemini-2.5-flash
MONEY_GUARD_TEXT_MODEL Audit model deepseek-v4-flash
MONEYGUARD_EXPLAIN_PROVIDER Hosted explanation mode: mock or synthetic-only deepseek-synthetic-evaluation; unset fails closed
MONEYGUARD_MOCK Force offline providers (--mock) off
MONEYGUARD_PROVIDER_MAX_ATTEMPTS Bound vision-provider calls, including the initial attempt (1-3) 3
MONEY_GUARD_DEBUG Enable safe diagnostics without payloads, headers, secrets, or env values off

Private hosted HTTP endpoints

The private OCR endpoint is started with:

pnpm build
node dist/http/server.js

Runtime contract:

Setting Contract
PORT HTTP server listens on this value when provided; otherwise falls back to 10000.
Bind host The HTTP server binds to 0.0.0.0 so Render can route traffic to it.
HOST Ignored by the executable HTTP entrypoint. Use code-level startExtractServer({ host }) only for embedded tests/tools.
MONEYGUARD_PIPELINE_CREDENTIAL Required bearer credential for POST /extract and POST /v1/explain; keep present and masked in hosting UI.
MONEYGUARD_EXPLAIN_PROVIDER Explanation mode. mock is offline; deepseek-synthetic-evaluation is synthetic-only. No real-user production value exists until a provider passes the privacy gate.
MONEYGUARD_PROVIDER_MAX_ATTEMPTS Server-only vision-provider attempt cap from 1 to 3; use 1 for protected single-attempt rehearsal. Missing or invalid values fall back to 3.
MONEYGUARD_REQUIRE_SINGLE_PROVIDER_ATTEMPT Server-only protected rehearsal invariant. When true, MONEYGUARD_PROVIDER_MAX_ATTEMPTS must be explicitly and exactly 1; otherwise extraction fails before provider work.
GEMINI_API_KEY Required only for live OCR provider calls; keep present and masked in hosting UI.
MONEYGUARD_MOCK Must be false or unset for live OCR; set only for deterministic offline tests.
MONEY_GUARD_DEBUG Must be false or unset in hosted rehearsal/production; debug output is not needed for private OCR smoke checks.
MONEY_GUARD_VISION_MODEL Optional OCR model override; defaults to gemini-2.5-flash.
NODE_VERSION Use Node 22 on hosts that require an explicit runtime version.
finance.json Required at process root for hosted totals math; provide it as a host secret file, not a committed file. Unknown string context.marketCondition values are normalized to neutral with the fixed diagnostic market_condition_normalized.

Every real CLI, library, and hosted extraction run requires an explicit, readable, valid finance.json ledger (or a MoneyGuardConfig.financePath override for library embedding). Ledger validation finishes before any vision-provider call. The bundled finance.example.json is only a template: MoneyGuard never selects it automatically.

POST /extract accepts multipart/form-data with mode=real-ocr and an image file. Authentication is checked before the request body is read. The image cap is 5 MiB, and the total HTTP request cap is 5 MiB + 256 KiB to allow multipart overhead; requests over the total cap return 413 before multipart parsing.

The web adapter may send a UUID v4 in X-MoneyGuard-Correlation-Id. The endpoint validates the fixed 36-character format before logging it, treats missing or invalid values as safe categories without reproducing raw input, and never uses correlation metadata for authentication, provider selection, retry policy, or extraction logic.

Accepted image MIME types are image/png and image/jpeg; image/jpg is normalized to canonical image/jpeg. image/webp is not accepted for the rehearsal contract. The endpoint verifies that the declared MIME type matches a bounded container-structure check before it calls the vision provider, and it passes the validated canonical MIME type to Gemini. The checks require PNG IDAT before IEND and JPEG segment structure with EOI; they are not complete image decoding. Mismatched, malformed, or unsupported image types return 415.

Successful responses are totals-only:

{
  "source": "real-ocr",
  "extraction": {
    "totalHours": 38,
    "hourlyRate": 25,
    "grossWage": 950,
    "currency": "AUD",
    "confidence": 0.9,
    "warnings": []
  }
}

The endpoint must never return raw image bytes, raw OCR text, filenames, MIME metadata, worker/employer metadata, or shift rows.

Privacy-gated /v1/explain

POST /v1/explain is additive and runs in the same hosted process as /extract. Its canonical Phase 1 wire contract is contracts/explain-v1.openapi.json; the accepted decision and companion controls are versioned in ADR-001, the contract freeze, the Provider Privacy Gate, and the synthetic evaluation matrix.

The endpoint authenticates before reading the body, accepts strict application/json up to 8,192 UTF-8 bytes, and accepts only profile moneyguard-mentor-en-AU-v1 with topics risk-signal, surplus, weekly-expenses, or labour-value. It emits normalized text/event-stream events:

event: delta
data: {"text":"..."}

event: done
data: {"finishReason":"stop"}

Failures before the first delta use generic JSON HTTP errors. Failures after streaming begins use one terminal SSE error event. Pipeline performs at most two connection attempts, retries only before the first delta, aborts provider work on client disconnect, and never retries, replays, or switches provider after visible output. DeepSeek requests explicitly disable thinking and set max_tokens: 800; decoded output is capped at 16 KiB.

The deterministic mock path and the executable 48-case fixture at fixtures/explain/synthetic-cases.ts require no provider credential. To exercise that path in a private local server:

MONEYGUARD_PIPELINE_CREDENTIAL=local-test-only \
MONEYGUARD_EXPLAIN_PROVIDER=mock \
node dist/http/server.js

Live DeepSeek evaluation, if deliberately enabled with MONEYGUARD_EXPLAIN_PROVIDER=deepseek-synthetic-evaluation, accepts only exact requests from those 48 versioned fixtures. Any unlisted or altered schema-valid request is rejected as a generic 400 invalid_payload before a provider is constructed or called. This repository does not provide a production real-user mode while the privacy gate remains not passed.


Using it as a library

import { runMoneyGuardPipeline, mockProviders } from "moneyguard";

const result = await runMoneyGuardPipeline(imageBuffer, {
  providers: mockProviders(),            // or omit for env-selected live providers
  onReportUpdate: async (text, final) => {
    // YOU own throttling/transport here. final === true is the last frame.
    process.stdout.write(text + "\n");
  },
});

if (!result.ok) console.error(result.kind, result.message);

Wiring it to a real Telegram bot is a few lines — see examples/telegram/.


Project layout

src/
  pipeline.ts        Orchestrator (channel-agnostic, DI providers, discriminated result)
  finance.ts         Fail-closed ledger loading and schema validation
  metrics.ts         Pure finance math — cadence normalization, tag subtotals, tier   🔒
  payload.ts         Local data minimization → selected upstream audit payload        🔒
  resilience.ts      Backoff/jitter retry + stream-safe connect-retry + error mapping 🔒
  schemas.ts         Zod contracts for the ledger and untrusted OCR output
  prompts.ts         Static persona + OCR prompts (dynamic data is built separately)
  report.ts          Deterministic Markdown skeleton (model can't hijack structure)
  config.ts          Single source of truth for env-driven config
  providers/         VisionProvider/AuditProvider interfaces + Gemini, DeepSeek, Mock
  http/explain.ts    Privacy-gated normalized-SSE explanation endpoint
  cli/main.ts        CLI transport (owns the 1000ms throttle)
examples/telegram/   The original bot transport this was extracted from

🔒 = privacy/resilience redline modules.

Testing

pnpm test          # full offline suite: math, retry semantics, privacy boundaries, and pipeline paths
pnpm typecheck     # strict TS, no implicit any, noUncheckedIndexedAccess

The default suite runs entirely offline via the mock providers. It asserts that ledger records and raw context strings stay out of audit prompts, /v1/explain never reads the ledger, a live stream is never replayed on retry, and all 48 fixed synthetic explanation cases satisfy deterministic contract and privacy checks.

Contributing

Issues and PRs welcome — see CONTRIBUTING.md. The three architectural redlines (privacy boundary, stream-safe retry, transport throttle) are intentional; please don't regress them.

License

MIT © 2026 Yuelin Liu

About

Privacy-first vision→reasoning pipeline: turns a timecard photo into a streamed financial audit while keeping the raw ledger on-device. Gemini OCR + DeepSeek, DI providers, stream-safe retry, strict TS.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages