feat: Replace NHTSA VIN decode with Google Gemini via OCR service #223
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Replace the NHTSA vPIC API integration for VIN string decoding with Google Gemini, routed through the Python OCR microservice (mvp-ocr) to match the existing receipt/manual extraction architecture.
Current State
POST /api/vehicles/decode-vincalls NHTSA vPIC API directly from the TypeScript backend (vehicles/external/nhtsa/)vin_cachedatabase table (1-year TTL)vehicle.vinDecode)POST /api/ocr/extract/vin) is separate and unchanged by this issueExpected Behavior
POST /api/vehicles/decode-vinvin_cachetable for caching Gemini decode resultsScope
Remove
vehicles/external/nhtsa/directory (nhtsa.client.ts, nhtsa.types.ts, index.ts)Add/Modify
Acceptance Criteria
vin_cachetable still caches decode results/api/ocr/extract/vin) unaffectedPlan: Replace NHTSA VIN decode with Google Gemini via OCR service
Phase: Planning | Agent: Planner | Status: AWAITING_REVIEW
Architecture Decision
Decision: Add
decode_vin()method to existingGeminiEngineclass (not a new standalone module).Rationale: GeminiEngine already has lazy Vertex AI init, WIF credentials, and model setup. VIN decode is the same pattern (text-to-structured-data via Gemini). Duplicating init in a separate class adds complexity without SRP benefit at 2 methods. Decision Critic verdict: STAND (all claims verified, risks minor).
Current Flow
New Flow
Key Changes
nhtsaValuerenamed tosourceValueinMatchedField<T>(backend + frontend)mapNHTSAResponse()becomesmapVinDecodeResponse()- simpler since Gemini returns structured fields directly (no NHTSA array parsing)vin_cacheto clear NHTSA-format entriesMilestones
M1: OCR Python endpoint (#224)
Files:
ocr/app/engines/gemini_engine.py,ocr/app/routers/extract.py,ocr/app/models/*.pydecode_vin(vin: str)to GeminiEngine with VIN-specific prompt and JSON response schemaVinDecodeResponsePydantic modelPOST /decode/vinroute accepting{"vin": "..."}JSON body^[A-HJ-NPR-Z0-9]{17}$)M2: Backend types and OCR client (#225)
Files:
backend/src/features/ocr/domain/ocr.types.ts,backend/src/features/ocr/external/ocr-client.ts,backend/src/features/vehicles/domain/vehicles.types.ts(new, extracted from nhtsa.types.ts)VinDecodeResponsetype matching OCR service responsedecodeVin(vin: string)toOcrClient(JSON POST, not multipart)MatchedField<T>,DecodedVehicleData,DecodeVinRequest,VinDecodeErrorfrom nhtsa.types.ts into vehicles typesnhtsaValuetosourceValueinMatchedField<T>M3: Rewire vehicles controller (#226)
Files:
backend/src/features/vehicles/api/vehicles.controller.ts,backend/src/features/vehicles/domain/vehicles.service.ts,backend/src/features/vehicles/api/vehicles.routes.ts,backend/src/features/vehicles/migrations/007_truncate_vin_cache.sqlmapNHTSAResponse()tomapVinDecodeResponse()- takes flat Gemini fields, does dropdown matching007_truncate_vin_cache.sqlmigrationM4: Remove NHTSA code and update docs (#227)
Files: Delete
backend/src/features/vehicles/external/nhtsa/(3 files), modifybackend/src/features/platform/models/responses.ts,backend/src/features/platform/README.md, 5 CLAUDE.md filesvehicles/external/nhtsa/directoryVPICVariable,VPICResponsefrom platform modelsM5: Frontend and tests (#228)
Files:
frontend/src/features/vehicles/types/vehicles.types.ts,frontend/src/features/vehicles/components/VinOcrReviewModal.tsx, guide page sections, test filesnhtsaValuetosourceValuein frontend typesRisk Assessment
nhtsaValue->sourceValue)/api/ocr/extract/vinendpoint is completely separate and unaffectedFiles Summary
vehicles/external/nhtsa/(index.ts, nhtsa.client.ts, nhtsa.types.ts)Verdict: AWAITING_REVIEW | Next: Plan review cycle (QR plan-completeness)
Plan v2: Replace NHTSA VIN decode with Google Gemini via OCR service
Phase: Planning | Agent: Planner | Status: AWAITING_REVIEW
Revised based on QR plan-completeness FAIL and TW plan-scrub FAIL. Addresses: response schema, sequencing conflict, incomplete file lists, cache type disposition, prompt design, route registration.
Architecture Decision
Decision: Add
decode_vin()method to existingGeminiEngineclass.Rationale: Reuses lazy Vertex AI init and WIF credentials. Decision Critic verdict: STAND.
New Flow
OCR VIN Decode Response Schema (drives M1 and M2)
Gemini Prompt Design (M1)
Response schema enforced via
GenerationConfig(response_mime_type="application/json", response_schema=_VIN_DECODE_SCHEMA).Milestones
M1: OCR Python VIN decode endpoint (#224)
Files:
ocr/app/engines/gemini_engine.py- adddecode_vin(vin: str)method with_VIN_DECODE_PROMPTand_VIN_DECODE_SCHEMAocr/app/routers/decode.py(NEW) - new router with prefix/decode, endpointPOST /vinocr/app/routers/__init__.py- exportdecode_routerocr/app/main.py- registerdecode_routerocr/app/models/__init__.py(or models file) - addVinDecodeResponsePydantic modelocr/tests/test_vin_decode.py(NEW) - unit test for/decode/vinrouteImplementation details:
decode_vin()callsmodel.generate_content([prompt_text], generation_config=vin_config)- text-only input, noPart.from_data_generation_configper method (maintenance schema vs VIN schema), stored as instance vars on first use^[A-HJ-NPR-Z0-9]{17}$, return 400 on failureVinDecodeResponsePydantic model matching the schema aboveM2: Backend OCR client VIN decode method (#225)
Files:
backend/src/features/ocr/domain/ocr.types.ts- addVinDecodeResponsetypebackend/src/features/ocr/external/ocr-client.ts- adddecodeVin(vin: string)methodImplementation details:
OcrClient.decodeVin()uses JSON POST (not multipart):fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({vin}) })VinDecodeResponsematching the Python schema aboveNot in this milestone: Type extraction and nhtsaValue rename moved to M3 to avoid sequencing breakage.
M3: Rewire vehicles controller and service (#226)
Files:
backend/src/features/vehicles/api/vehicles.controller.ts- replace NHTSAClient with OcrClient, move VIN regex validation herebackend/src/features/vehicles/domain/vehicles.service.ts- addgetVinCached(),saveVinCache(), renamemapNHTSAResponse()tomapVinDecodeResponse(response: VinDecodeResponse), renamenhtsaValuetosourceValueinmatchField()and all MatchedField constructionbackend/src/features/vehicles/domain/vehicles.types.ts(existing file) - addMatchedField<T>,MatchConfidence,DecodedVehicleData,DecodeVinRequest,VinDecodeError,VinCacheEntry(all extracted from nhtsa.types.ts withnhtsaValue->sourceValuerename andrawDatatype changed toVinDecodeResponse)backend/src/features/vehicles/api/vehicles.routes.ts- update comment from "NHTSA vPIC API" to "OCR VIN decode"backend/src/features/vehicles/migrations/007_truncate_vin_cache.sql-TRUNCATE TABLE vin_cache;Implementation details:
decodeVin()method flow:^[A-HJ-NPR-Z0-9]{17}$), return 400 if invalidvehiclesService.getVinCached(vin)- if hit, return cachedDecodedVehicleDataocrClient.decodeVin(vin)to getVinDecodeResponsevehiclesService.saveVinCache(vin, response)to cachevehiclesService.mapVinDecodeResponse(response)for dropdown matchingDecodedVehicleDatamapVinDecodeResponse(response: VinDecodeResponse): Promise<DecodedVehicleData>:response.year,response.make, etc. (no NHTSAClient.extractValue calls)matchField()logic unchanged (fuzzy matching against dropdowns)MatchedFieldobjects usesourceValueinstead ofnhtsaValueVinCacheEntry.rawDatatype changes fromNHTSADecodeResponsetoVinDecodeResponsesaveVinCache()stores Gemini response asraw_dataJSONBgetVinCached()returns cachedVinDecodeResponsefromraw_data, passes throughmapVinDecodeResponse()if cache hit (so dropdown matching always runs fresh)M4: Remove NHTSA code and update docs (#227)
Files to DELETE:
backend/src/features/vehicles/external/nhtsa/index.tsbackend/src/features/vehicles/external/nhtsa/nhtsa.client.tsbackend/src/features/vehicles/external/nhtsa/nhtsa.types.tsFiles to MODIFY:
backend/src/features/platform/models/responses.ts- removeVPICVariableandVPICResponseinterfaces (lines 65-79). Verified: no other files import these types.backend/src/features/platform/README.md- remove NHTSA vPIC fallback strategy section, update VIN decode references to point to OCR servicebackend/src/features/vehicles/README.md- update VIN decode section from NHTSA to Gemini/OCRbackend/src/features/vehicles/CLAUDE.md- changeexternal/description from "External service integrations (NHTSA)" to "External service integrations"backend/src/features/vehicles/external/CLAUDE.md- removenhtsa/directory entryocr/app/engines/CLAUDE.md- add VIN decode to GeminiEngine descriptionocr/CLAUDE.md- add "Gemini VIN decode" to service descriptionocr/app/CLAUDE.md- add decode router to subdirectoriesbackend/src/features/ocr/CLAUDE.md- adddecodeVinto OcrClient method listM5: Frontend updates and tests (#228)
Frontend files:
frontend/src/features/vehicles/types/vehicles.types.ts- renamenhtsaValuetosourceValueinMatchedField<T>, updateDecodedVehicleDatacomment from "NHTSA vPIC API" to "VIN decode"frontend/src/features/vehicles/components/VinOcrReviewModal.tsx- renamenhtsaValuereferences (lines 114-118) tosourceValue, updatenhtsaRefsvariable name tosourceRefsfrontend/src/features/vehicles/hooks/useVinOcr.ts- update step 2 label from "Decode VIN using NHTSA" to "Decode VIN", update error messages removing NHTSAfrontend/src/features/vehicles/api/vehicles.api.ts- update JSDoc from "NHTSA vPIC API" to "VIN decode service"frontend/src/features/vehicles/components/VehicleForm.tsx- update NHTSA comment (line 510)frontend/src/pages/GuidePage/sections/VehiclesSection.tsx- change "NHTSA database" to "vehicle database"frontend/src/pages/GuidePage/sections/SubscriptionSection.tsx- change "NHTSA database" to "vehicle database"Test files:
backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts- update vin_cache references if neededVehiclesService.mapVinDecodeResponse()mocking OcrClientOcrClient.decodeVin()methodRegression verification:
POST /api/ocr/extract/vin(image-based VIN extraction) still works - run existing OCR tests, verify PaddleOCR path untouchedMilestone Dependencies
Files Summary
vehicles/external/nhtsa/directoryocr/app/routers/decode.py,ocr/tests/test_vin_decode.py,007_truncate_vin_cache.sqlVerdict: AWAITING_REVIEW | Next: QR plan-code, QR plan-docs
Plan v3: Revisions from QR plan-code and QR plan-docs
Phase: Plan-Review | Agent: Planner | Status: APPROVED
Addresses 4 RULE 0/1 findings from QR plan-code and 7 missing files from QR plan-docs.
Changes from v2
RULE 0 FIX: Remove TRUNCATE migration, use format-aware cache reads
Problem: TRUNCATE vin_cache is destructive with no rollback if Gemini is unavailable post-deploy.
Fix: Remove
007_truncate_vin_cache.sql. Instead,getVinCached()validates thatraw_datahas asuccessfield (present inVinDecodeResponse, absent in oldNHTSADecodeResponse). If format mismatch, treat as cache miss. Old NHTSA entries expire naturally via 1-year TTL. No destructive migration needed.RULE 0 FIX: OCR decode router error status codes
Problem: No specified HTTP status for Gemini unavailability vs processing error.
Fix: OCR
/decode/vinrouter returns:400for invalid VIN format422forGeminiProcessingError(Gemini returned bad data)503forGeminiUnavailableError(credentials missing, SDK not installed)Backend
OcrClient.decodeVin()propagates these status codes. Controller maps: 503 -> 502 "VIN decode service unavailable", 422 -> 502 "VIN decode failed", 400 -> 400 "Invalid VIN".RULE 0 FIX: Explicit VIN validation before cache/OCR
Problem: Gemini will hallucinate data for malformed VINs.
Fix: Controller
decodeVin()step 1 explicitly validates VIN format using regex^[A-HJ-NPR-Z0-9]{17}$BEFORE cache read and before OCR call. Returns 400 immediately on failure. This matches the existingNHTSAClient.validateVin()behavior.RULE 0 FIX: Cache write uses ON CONFLICT upsert
Fix:
saveVinCache()usesINSERT INTO vin_cache ... ON CONFLICT (vin) DO UPDATE- same idempotent pattern as existingNHTSAClient.saveToCache().RULE 1 FIX: Type transition safety
Problem:
VinCacheEntry.rawDatatype change could break at milestone boundaries.Fix: M3 is atomic - it extracts types from nhtsa.types.ts, changes
rawDatatype, AND rewires the controller/service in a single milestone. The old NHTSAClient code continues to exist (deleted in M4) but is no longer imported by any file after M3 completes. No intermediate broken state.RULE 2 NOTE: GenerationConfig per method
Fix:
decode_vin()createsGenerationConfiglocally within the method body (not cached as instance state). The existingself._generation_configfor maintenance extraction is untouched.RULE 2 NOTE: matchField() reuse confirmed
mapVinDecodeResponse()delegates to the sameprivate matchField()method. No new fuzzy matching logic.Additional files added to M3 (config/code)
These files have active vpic/NHTSA references that are functional, not just documentation:
backend/src/core/config/config-loader.ts- removevpicfrom Zod schema inexternalconfig object (lines 46-48). RULE 0: failing to remove this causes config validation error at startup when vpic config is removed from YAML filesbackend/src/core/config/feature-tiers.ts- updateupgradePromptfrom "NHTSA database" to "vehicle database" (line 32)config/app/ci.yml- removevpic:block (lines 26-28)config/app/production.yml.example- removevpic_api_url(line 25)config/shared/production.yml- removevpic:block (lines 110-111)Additional files added to M4 (docs)
docs/PLATFORM-SERVICES.md- update "NHTSA vPIC API fallback with circuit breaker" to "Gemini VIN decode via OCR service" (line 38)docs/USER-GUIDE.md- update "NHTSA database" references to "vehicle database" (lines 647, 658)docs/TESTING.md- update vPIC mock references to OCR service mocks (lines 77, 197, 322-323)backend/src/features/vehicles/external/README.md- remove NHTSAClient pattern reference (line 18)Updated milestone file counts
Removed from plan
007_truncate_vin_cache.sql- no longer needed (format-aware cache reads instead)Verdict: APPROVED | Next: Create branch, begin execution
Milestone: M1-M3 Complete
Phase: Execution | Agent: Developer | Status: IN_PROGRESS
Completed
POST /decode/vinwith GeminiEngine.decode_vin(), VinDecodeResponse model, VIN validation (400/422/503 error codes), 13 tests passingRemaining
Commits
a75f7b5feat: add VIN decode endpoint to OCR Python service (refs #224)3cd6125feat: add backend OCR client method for VIN decode (refs #225)5cbf9c7feat: rewire vehicles controller to OCR VIN decode (refs #226)Verdict: IN_PROGRESS | Next: M4 and M5 (parallel)
Milestone: All Milestones Complete - PR Open
Phase: Review | Agent: Developer | Status: AWAITING_REVIEW
All 5 Milestones Complete
Quality Checks
PR
PR #229 opened targeting main. Fixes #223, #224, #225, #226, #227, #228.
Verdict: AWAITING_REVIEW | Next: Quality Agent RULE 0/1/2 review