Removes the old planner/decision-critic/role-agents/domain-agents system (step-injector scripts, stale scopes, sprint-era workflow) and replaces it with 16 ground-truth-verified skills under .claude/skills/: change control, debugging playbook, failure archaeology, architecture contract, domain reference, OCR/Gemini pipeline, config and secrets, build and env, run and operate, diagnostics (with tested scripts), validation and QA, docs and writing, launch readiness, deploy-safety campaign, proof and analysis toolkit, and research frontier. RULE 0/1/2, the temporal-contamination rule, and the decision stress-test protocol are carried forward into the new skills; the retired content remains in git history. Co-Authored-By: Claude Fable 5 <[email protected]>
21 KiB
name, description
| name | description |
|---|---|
| mvp-ocr-gemini-pipeline | The AI extraction subsystem - OCR engines, Gemini semantic extraction, VIN decode, and the WIF auth chain. Load this when working on anything under backend/src/features/ocr/, ocr/app/, or backend/src/features/email-ingestion/, or when you see symptoms like VIN decode failing/timing out, "Gemini authentication failed", "GeminiUnavailableError", pydantic validation error from google-genai, receipt scan returning empty fields, "No maintenance receipt fields could be extracted", manual extraction job stuck or expired, OCR container unhealthy, Google Vision quota, or "OCR service error 502/503/504". Also load before changing GEMINI_MODEL, GenerateContentConfig fields, or ocr/requirements.txt. |
mvp-ocr-gemini-pipeline
The AI extraction subsystem: React frontend -> Fastify backend proxy -> Python FastAPI OCR service -> PaddleOCR / Google Vision / Gemini (Vertex AI via google-genai SDK). Authored 2026-07-07; all paths and values verified against code on that date.
When to use / When NOT to use
Use this skill when you are:
- Modifying
backend/src/features/ocr/,ocr/app/, orbackend/src/features/email-ingestion/. - Debugging VIN decode, receipt scan, manual extraction, or email-ingested receipts.
- Touching Gemini config (
GEMINI_MODEL,GenerateContentConfig, response schemas) or the WIF auth chain.
Do NOT use this skill for:
- General failure triage across the app ->
mvp-debugging-playbook. - The full incident history (the AFC revert appears there in chronicle form) ->
mvp-failure-archaeology. - Adding config knobs or secrets outside OCR ->
mvp-config-and-secrets. - VIN structure theory, check digits, year-code tables as domain knowledge ->
mvp-vehicle-domain-reference(this skill covers only how the code uses them). - Deploy/rollback mechanics for the OCR container ->
mvp-run-and-operate. - What counts as test evidence, definition of done ->
mvp-validation-and-qa.
WARNING: docs/ocr-pipeline-tech-stack.md is substantially stale (describes Celery, spaCy, S3/MinIO - none exist). Code wins. Do not use that doc as a source.
1. End-to-end flows (real paths)
Three hops. Every OCR feature goes: frontend hook -> backend proxy (auth + tier gate + size/type validation) -> Python service.
Backend proxy: backend/src/features/ocr/
api/ocr.routes.ts- route registration, auth + tier preHandlers.domain/ocr.service.ts- size caps (MAX_SYNC_SIZE10MB,MAX_ASYNC_SIZE200MB) and MIME allowlist (jpeg/png/heic/heif/pdf); manual jobs must beapplication/pdf.external/ocr-client.ts- HTTP client toOCR_SERVICE_URL(defaulthttp://mvp-ocr:8000). Every call goes throughfetchWithTimeoutwith hardcodedOCR_TIMEOUT_MS = 120000(line 9; AbortController, NOT env-configurable).decodeVin()posts JSON; everything else is multipart.
Endpoint map (verified 2026-07-07)
Backend route (/api prefix) |
Tier gate | Python endpoint | Engine path |
|---|---|---|---|
POST /ocr/extract |
auth only | POST /extract |
services/ocr_service.py -> engine from engines/engine_factory.py |
POST /ocr/extract/vin |
auth only | POST /extract/vin |
extractors/vin_extractor.py: preprocess -> OCR -> 17-char pattern + check digit (validators/vin_validator.py), I/O/Q correction |
POST /ocr/extract/receipt |
pro: fuelLog.receiptScan |
POST /extract/receipt |
extractors/receipt_extractor.py + fuel_receipt.py (OCR + regex in app/patterns/) |
POST /ocr/extract/maintenance-receipt |
pro: maintenance.receiptScan |
POST /extract/maintenance-receipt |
extractors/maintenance_receipt_extractor.py: OCR -> Gemini structured JSON -> regex cross-validation |
POST /ocr/extract/manual |
pro: document.scanMaintenanceSchedule |
POST /extract/manual (async) |
Redis DB 1 job via services/job_queue.py, BackgroundTask -> extractors/manual_extractor.py -> GeminiEngine.extract_maintenance() (whole PDF inline) -> patterns/service_mapping.py (27 subtypes) |
POST /ocr/jobs, GET /ocr/jobs/:jobId |
auth only | POST /jobs, GET /jobs/{job_id} |
generic async OCR + job polling (routers/jobs.py) |
POST /vehicles/decode-vin (vehicles feature, not ocr) |
pro: vehicle.vinDecode |
POST /decode/vin |
routers/decode.py -> shared lazy GeminiEngine.decode_vin() |
Python routers: ocr/app/routers/extract.py, decode.py, jobs.py, registered in ocr/app/main.py. /health is in main.py.
VIN decode flow (the most fragile path)
- Frontend:
frontend/src/features/vehicles/api/vehicles.api.ts:90- axios timeout 120000ms. - Backend:
backend/src/features/vehicles/api/vehicles.controller.ts:decodeVin()(line 384) - regex-validates^[A-HJ-NPR-Z0-9]{17}$, callsocrClient.decodeVin(). Error mapping: OCR 503/422 -> 502VIN_DECODE_FAILED; message contains "timed out"/"aborted" -> 504VIN_DECODE_TIMEOUT. - Python:
ocr/app/routers/decode.py->GeminiEngine.decode_vin()with Google Search grounding (tools=[types.Tool(google_search=types.GoogleSearch())],gemini_engine.py:384). - Model year is NEVER taken from the LLM:
resolve_vin_year()(gemini_engine.py:58) computes it deterministically from VIN positions 7 and 10 (alphabetic pos 7 -> 2010-2039 cycle, per NHTSA FMVSS No. 115; this logic was inverted before commit936753f- do not "fix" it back). The result overrides whatever Gemini returns (gemini_engine.py:409).
Email ingestion path: backend/src/features/email-ingestion/
POST /api/webhooks/resend/inbound (public, Svix-signature-verified; api/email-ingestion.routes.ts) -> domain/email-ingestion.service.ts:processEmail():
- Validate sender against user profiles; fetch and filter attachments.
- Classify subject+body via
domain/receipt-classifier.ts(keyword matching; confident requires >= 2 keyword matches for one type, ties are unclassified). - Confident -> call the matching OCR endpoint, with the other as fallback on failure. Unconfident -> call BOTH
/extract/receiptand/extract/maintenance-receiptper attachment, re-classify from returnedrawText, then a field-count heuristic (email-ingestion.service.tsaround lines 280-325). - Vehicle association: exact match creates the
fuel_logormaintenance_recorddirectly; ambiguous match inserts a pending association (insertPendingAssociation) for the user to resolve later.
2. Engine architecture
Two engine categories - do not conflate them:
- OcrEngine subclasses (image -> text + word boxes):
ocr/app/engines/paddle_engine.py(PaddleOCR, local),cloud_engine.py(Google Vision TEXT_DETECTION),hybrid_engine.py(primary + fallback wrapper). Built viaengine_factory.create_engine(); registry accepts onlypaddleocrandgoogle_vision. IfOCR_FALLBACK_ENGINE != "none", factory returns aHybridEngine. - GeminiEngine (
engines/gemini_engine.py): standalone, NOT an OcrEngine subclass. Semantic extraction (PDF -> maintenance schedule JSON) and VIN decode. Instantiated directly bymanual_extractor.pyandrouters/decode.py, never through the factory.maintenance_receipt_extractor.pyhas its OWN copy-pasted Gemini client (see section 3).
Which engine actually runs - code defaults vs compose (compose wins in every deployed environment):
| Setting | Code default (ocr/app/config.py) |
docker-compose.yml (all envs inherit) |
|---|---|---|
OCR_PRIMARY_ENGINE |
paddleocr |
google_vision |
OCR_FALLBACK_ENGINE |
none |
paddleocr |
Code defaults only apply where env vars are absent - i.e., bare pytest runs and ad-hoc local Python. Deployed containers run Vision-primary with PaddleOCR fallback. When reasoning about production behavior, always use the compose values.
HybridEngine semantics (hybrid_engine.py): with a CLOUD primary, the Vision monthly cap (VISION_MONTHLY_LIMIT, 1000/calendar month, Redis counter ocr:vision_requests:YYYY-MM in DB 1) is checked BEFORE calling it; once exhausted, PaddleOCR is the sole engine until month end. With a LOCAL primary, fallback triggers on confidence < OCR_FALLBACK_THRESHOLD. _CLOUD_TIMEOUT_SECONDS = 10.0 (raised from 5s because first-call WIF token exchange takes 6-8s, refs #182) is a discard-after-completion threshold, NOT an abort: _run_cloud_with_cap runs cloud.recognize() to completion with no timeout or cancellation, only then compares elapsed time, and throws away results slower than 10s (without incrementing the Vision counter). A hung or slow Vision call can therefore block far past 10s -- directly relevant to weak point 1 (sync calls block the event loop); do not rule out the Vision path in a hung-container investigation because of this constant.
3. Auth chain: WIF via Auth0 (memorize this before touching credentials)
WIF = Workload Identity Federation: Google Cloud accepts a third-party JWT (here, an Auth0 machine-to-machine token) instead of a service-account key file. No Google key ever exists on disk.
Chain, in order:
secrets/app/google-wif-config.json- deliberately committed to git; it is configuration, not a secret (it contains only the pool/provider audience and an executable pointer). Mounted at/run/secrets/google-wif-config.json;GOOGLE_VISION_KEY_PATHpoints at it. Do not "fix" its presence in git.- Its
credential_source.executable.commandis/app/scripts/fetch-auth0-token.sh(repo:ocr/scripts/fetch-auth0-token.sh, chmod +x inocr/Dockerfile:48, 30s timeout). The script reads/run/secrets/auth0-ocr-client-idand/run/secrets/auth0-ocr-client-secret(real secrets, NOT in git; compose mounts them fromsecrets/app/*.txt), requests an Auth0 M2M token, and emits Google's executable-credential JSON format. - Google STS exchanges that JWT, then impersonates
[email protected]. - Client bootstrap sets, as process-wide env vars INSIDE
_get_client()immediately beforegenai.Client(vertexai=True, ...)construction:GOOGLE_APPLICATION_CREDENTIALS=<key_path>andGOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1(executables are refused by google-auth without this flag).
The bootstrap is DUPLICATED. Identical _get_client() implementations exist in ocr/app/engines/gemini_engine.py:218 and ocr/app/extractors/maintenance_receipt_extractor.py:173 (and cloud_engine.py:67 sets the same env vars for Vision). Any auth or client-construction fix must land in BOTH _get_client() copies or you fix VIN decode/manual extraction while leaving maintenance receipts broken (or vice versa).
On a dev machine the auth0-ocr-client-* secrets typically exist only as Docker-created empty directories - the live Gemini path cannot run locally; end-to-end verification happens on staging.
4. google-genai SDK sharp edges
The dependency is unpinned: ocr/requirements.txt:25 has google-genai>=1.0.0 (floor only). Each image build resolves whatever is newest. This already caused a staging breakage:
936753faddedautomatic_function_calling=types.AutomaticFunctionCallingConfig(max_remote_calls=3)to the VIN decodeGenerateContentConfig.- The installed SDK version rejected it with a pydantic validation error at request time - VIN decode broke on staging while the build stayed green (CI runs zero tests; the only gate is images build + containers report healthy).
1add6c8removed the parameter;56df5d4confirmed the revert and added diagnostic logging (full raw Gemini JSON atgemini_engine.py:401-406;hasTrim/hasEngine/hasTransmissioninvehicles.controller.ts). Do not re-add AFC config without pinning and verifying the SDK version.
Rule: before shipping any new GenerateContentConfig field (or types.* construct), validate it against the INSTALLED SDK version, not the docs:
# What version is actually in the running container
docker compose exec mvp-ocr pip show google-genai | head -2
# Does the field exist / validate on that version (pydantic validates at construction)
docker compose exec mvp-ocr python3 -c "
from google.genai import types
print(types.GenerateContentConfig(response_mime_type='application/json', YOUR_NEW_FIELD=...))"
Model config drift (known-weak, deliberate as of 2026-07-07): code default is gemini-2.5-flash (ocr/app/config.py:37) but docker-compose.yml overrides GEMINI_MODEL: gemini-3-flash-preview - a PREVIEW model in deployed config. Preview models can be withdrawn or change behavior without notice. If Gemini responses suddenly degrade or 404, check the model name first. CLAUDE.md files under ocr/ still say "Gemini 2.5 Flash" - compose wins.
Response schemas use Vertex-style UPPERCASE types ("OBJECT", "STRING", "NUMBER", nullable flags) - keep that convention when editing _VIN_DECODE_SCHEMA / _RESPONSE_SCHEMA / _RECEIPT_RESPONSE_SCHEMA.
5. Config knob table (verified 2026-07-07)
| Knob | Where | Default / deployed value |
|---|---|---|
GEMINI_MODEL |
ocr/app/config.py:37 / docker-compose.yml |
gemini-2.5-flash / gemini-3-flash-preview |
VERTEX_AI_PROJECT / VERTEX_AI_LOCATION |
config.py:33-36 / compose |
"" / global -> compose: motovaultpro / global |
OCR_PRIMARY_ENGINE / OCR_FALLBACK_ENGINE |
config.py:13,19 / compose |
paddleocr/none -> compose: google_vision/paddleocr |
OCR_CONFIDENCE_THRESHOLD / OCR_FALLBACK_THRESHOLD |
config.py |
0.6 / 0.6 |
VISION_MONTHLY_LIMIT |
config.py:28 |
1000 requests/calendar month |
GOOGLE_VISION_KEY_PATH |
config.py:23 |
/run/secrets/google-wif-config.json (shared by Vision and Gemini) |
| Sync size cap | ocr.service.ts:20, extract.py:30 |
10MB (413 above) |
| Manual/async size cap | ocr.service.ts:23, extract.py:33 |
200MB |
| Gemini inline PDF cap | gemini_engine.py:20 _MAX_PDF_BYTES |
20MB hard limit |
| Backend->OCR timeout | ocr-client.ts:9 |
120000ms, hardcoded, all endpoints |
| Frontend timeouts | vehicles.api.ts:90 (VIN decode), useVinOcr.ts:52, useManualExtraction.ts:54 |
120000ms |
useReceiptOcr.ts:153, useMaintenanceReceiptOcr.ts:147 |
30000ms | |
| Hybrid cloud result-discard threshold (the call itself is unbounded) | hybrid_engine.py:25 |
10.0s |
| Job TTLs | job_queue.py:19-22 |
3600s regular / 7200s manual; expired jobs -> backend 410 |
| OCR Redis | config.py:40-42 |
mvp-redis DB 1 (backend uses DB 0) |
| Gemini base field confidence | maintenance_receipt_extractor.py:31, manual_extractor.py:58 |
0.85 before regex cross-validation |
| Service-side Gemini timeout | (nowhere) | NONE - generate_content calls are unbounded inside the Python service |
6. Known-weak points (owner-acknowledged, 2026-07-07; do not silently "improve" without an issue)
- Sync Gemini calls block the uvicorn event loop.
routers/decode.py:42androuters/extract.py:239,327run synchronous OCR/Gemini work directly inasync defhandlers. A slow Search-grounded VIN decode (observed >60s) blocks ALL requests including/health(compose healthcheck: interval 5s, timeout 5s, retries 3 -> container flagged unhealthy within ~15s). Verified: plain compose does not auto-restart unhealthy containers, butscripts/ci/health-check.shand the deploy/rollback gates treat unhealthy as failure, so a deploy landing during a long decode can fail its gate or trigger rollback. Only manual extraction usesrun_in_executor(extract.py:472). - 120s == 120s timeout race. Frontend VIN decode timeout equals the backend->OCR timeout exactly; no headroom, so at the boundary the user sees a generic axios timeout instead of the backend's mapped 504. Receipt hooks are worse in a different way: frontend gives up at 30s while backend + OCR + Gemini keep burning quota for up to 120s.
- No retries anywhere for transient Gemini/Vision failures. The single exception is maintenance-receipt's silent fallback (next item).
- Silent empty-fields fallback. In
maintenance_receipt_extractor.py:129-133, any Gemini exception is caught, logged at WARNING, andgemini_fields = {}- which usually surfaces as 422 "No maintenance receipt fields could be extracted". A dead Gemini auth chain therefore looks like "bad receipt photo". Check OCR container logs for "Gemini extraction failed, falling back to OCR-only" before blaming image quality. - 20MB vs 200MB manual gap.
/extract/manualaccepts up to 200MB, queues the job, thenGeminiEngine.extract_maintenance()rejects anything over 20MB with an error suggesting GCS URIs - which are not implemented. Every 21-200MB manual is a guaranteed deferred failure. - Email-ingestion cost amplification. Unconfident classification runs BOTH OCR endpoints per attachment (2x Vision against the 1000/month cap, plus a Gemini call for the maintenance path). A burst of ambiguous forwarded emails can drain the Vision quota.
- Manual job progress race (
manual_extractor.py:122-126): fire-and-forget progress updates from worker threads could overwrite COMPLETED status; mitigated only by the convention "never send 100% from the extractor". Preserve that convention. - VIN decode trusts LLM+Search for specs. Only the year is ground-truthed. Full raw Gemini JSON is logged per decode (
gemini_engine.py:401-406) - diagnostic aid, but log volume/PII to keep in mind.
7. How to test
CI gates nothing beyond build + boot (canonical statement: mvp-validation-and-qa section 1). All tests below are local-only and are your responsibility to run.
Python unit tests (all Gemini/network calls mocked - safe without secrets):
cd ocr && pytest
Key files: tests/test_gemini_engine.py (client init, 20MB cap, extraction, error paths), test_resolve_vin_year.py (year-cycle disambiguation - regenerated in 936753f, do not weaken), test_vin_decode.py, test_receipt_extraction.py, test_maintenance_patterns.py, test_engine_abstraction.py, test_service_mapping.py. Remember bare pytest sees code defaults (paddleocr primary), not compose values.
Backend unit tests (OcrClient mocked):
cd backend && npm test -- --testPathPattern="features/ocr"
Files: backend/src/features/ocr/tests/unit/ocr-receipt.test.ts, ocr-manual.test.ts. Do NOT run the backend integration tests casually - they DROP TABLE CASCADE on the shared dev database (owner non-negotiable: fresh backup first).
Live pipeline: staging only. The WIF chain needs real auth0-ocr-client-id/secret files, which exist only as empty mount directories on dev machines. Push a PR, let the staging deploy run, then exercise the flow through the staging UI, or from the staging host:
docker compose exec mvp-backend curl -s -X POST http://mvp-ocr:8000/decode/vin \
-H 'Content-Type: application/json' -d '{"vin":"<17-char VIN>"}'
Watch logs: docker logs mvp-ocr-staging --tail 100 on the staging host (dev/prod container name is mvp-ocr); look for "Gemini engine initialized", "VIN year resolved", "Gemini decoded VIN". Never hand-edit files on staging/prod servers - rsync --delete reverts them on the next deploy.
Provenance and maintenance
Authored 2026-07-07 by direct inspection of the repo (all line numbers and values verified against code, not docs). Volatile facts and how to re-verify each:
| Fact | Re-verify with |
|---|---|
| Backend->OCR timeout 120000ms | grep -n OCR_TIMEOUT_MS backend/src/features/ocr/external/ocr-client.ts |
| Frontend timeouts (120s VIN decode, 30s receipts) | grep -rn "timeout:" frontend/src/features/vehicles/api/vehicles.api.ts frontend/src/features/*/hooks/use*Ocr*.ts frontend/src/features/documents/hooks/useManualExtraction.ts |
Deployed model gemini-3-flash-preview, Vision-primary engines |
grep -n "GEMINI_MODEL|OCR_PRIMARY_ENGINE|OCR_FALLBACK_ENGINE" docker-compose.yml |
Code defaults (gemini-2.5-flash, paddleocr/none) |
grep -n "getenv" ocr/app/config.py |
| google-genai unpinned | grep -n google-genai ocr/requirements.txt |
| Installed SDK version in a running container | docker compose exec mvp-ocr pip show google-genai |
| Size caps 10MB/200MB/20MB | grep -n "MAX_SYNC_SIZE|MAX_ASYNC_SIZE|MAX_MANUAL_SIZE|_MAX_PDF_BYTES" backend/src/features/ocr/domain/ocr.service.ts ocr/app/routers/extract.py ocr/app/engines/gemini_engine.py |
| Job TTLs 3600/7200, Redis DB 1 | grep -n "JOB_TTL|MANUAL_JOB_TTL" ocr/app/services/job_queue.py; grep -n REDIS_DB docker-compose.yml |
Duplicated _get_client() bootstrap |
grep -n "_get_client|ALLOW_EXECUTABLES" ocr/app/engines/gemini_engine.py ocr/app/extractors/maintenance_receipt_extractor.py ocr/app/engines/cloud_engine.py |
| WIF config committed, executable helper path | git ls-files secrets/app/ | grep wif; grep -n executable secrets/app/google-wif-config.json |
| AFC incident commits | git log --oneline 936753f 1add6c8 56df5d4 -1 --no-walk 2>/dev/null || git show --stat 56df5d4 |
| Tier gates on OCR routes | grep -n requireTier backend/src/features/ocr/api/ocr.routes.ts backend/src/features/vehicles/api/vehicles.routes.ts |
| CI still runs no tests | grep -rn "pytest|npm test|jest" .gitea/workflows/ (expect no test-execution hits) |
| Event-loop-blocking sync calls | grep -n "_gemini_engine.decode_vin|receipt_extractor.extract|maintenance_receipt_extractor.extract" ocr/app/routers/decode.py ocr/app/routers/extract.py |