37 Commits

Author SHA1 Message Date
7e2bb9ef36 Merge pull request 'feat: Migrate Gemini SDK to google-genai (#231)' (#236) from issue-231-migrate-gemini-sdk-google-genai into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 37s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #236
2026-03-01 04:08:09 +00:00
Eric Gullickson
56df5d48f3 fix: revert unsupported AFC config and add diagnostic logging for VIN decode (refs #231)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 12m33s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- Remove AutomaticFunctionCallingConfig(max_remote_calls=3) which caused
  pydantic validation error on the installed google-genai version
- Log full Gemini raw JSON response in OCR engine for debugging
- Add engine/transmission to backend raw values log
- Add hasTrim/hasEngine/hasTransmission to decode success log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:16:56 -06:00
Eric Gullickson
1add6c8240 fix: remove unsupported AutomaticFunctionCallingConfig parameter (refs #231)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 39s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 53s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The installed google-genai version does not support max_remote_calls on
AutomaticFunctionCallingConfig, causing a pydantic validation error that
broke VIN decode on staging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:59:04 -06:00
Eric Gullickson
936753fac2 fix: VIN Decoding timeouts and logic errors
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m33s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-28 12:02:26 -06:00
Eric Gullickson
96e1dde7b2 docs: update CLAUDE.md references from Vertex AI to google-genai (refs #231)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 8m4s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 24s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:21:58 -06:00
Eric Gullickson
1464a0e1af feat: update test mocks for google-genai SDK (refs #235)
Replace engine._model/engine._generation_config mocks with
engine._client/engine._model_name. Update sys.modules patches
from vertexai to google.genai. Remove dead if-False branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:21:10 -06:00
Eric Gullickson
9f51e62b94 feat: migrate MaintenanceReceiptExtractor to google-genai SDK (refs #234)
Replace vertexai.generative_models with google.genai client pattern.
Fix pre-existing bug: raise GeminiUnavailableError instead of bare
RuntimeError for missing credentials. Add proper try/except blocks
matching GeminiEngine error handling pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:17:14 -06:00
Eric Gullickson
b7f472b3e8 feat: migrate GeminiEngine to google-genai SDK with Google Search grounding (refs #233)
Replace vertexai.generative_models with google.genai client pattern.
Add Google Search grounding tool to VIN decode for improved accuracy.
Convert response schema types to uppercase per Vertex AI Schema spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:16:18 -06:00
Eric Gullickson
398d67304f feat: replace google-cloud-aiplatform with google-genai dependency (refs #232)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:13:54 -06:00
Eric Gullickson
0055d9f0f3 fix: VIN decoding year fixes
All checks were successful
Deploy to Staging / Build Images (push) Successful in 35s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 1m2s
2026-02-28 11:09:46 -06:00
Eric Gullickson
9dc56a3773 fix: distribute plan storage to sub-issues for context efficiency
Some checks failed
Deploy to Staging / Build Images (push) Successful in 38s
Deploy to Staging / Verify Staging (push) Has been cancelled
Deploy to Staging / Notify Staging Ready (push) Has been cancelled
Deploy to Staging / Notify Staging Failure (push) Has been cancelled
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Split monolithic parent-issue plan into per-sub-issue comments.
  Updated workflow contract to enforce self-contained sub-issue plans.
2026-02-28 11:08:49 -06:00
Eric Gullickson
283ba6b108 fix: Remove VIN Cache
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m36s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 42s
2026-02-20 08:26:39 -06:00
Eric Gullickson
7d90f4b25a fix: add VIN year code table to Gemini decode prompt (refs #229)
All checks were successful
Deploy to Staging / Build Images (push) Successful in 37s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
gemini-3-flash-preview was hallucinating year (e.g., returning 1993
instead of 2023 for position-10 code P). Prompt now includes the full
1980-2039 year code table and position-7 disambiguation rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:55:21 -06:00
e2e6471c5e Merge pull request 'fix: increase VIN decode timeout for Gemini cold start' (#230) from issue-223-replace-nhtsa-vin-decode-gemini into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 37s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #230
2026-02-20 03:37:49 +00:00
Eric Gullickson
3b5b84729f fix: increase VIN decode timeout to 60s for Gemini cold start (refs #229)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Default 10s API client timeout caused frontend "Failed to decode" errors
when Gemini engine cold-starts (34s+ on first call).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:30:31 -06:00
d9df9193dc Merge pull request 'feat: Replace NHTSA VIN decode with Google Gemini via OCR service (#223)' (#229) from issue-223-replace-nhtsa-vin-decode-gemini into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 37s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #229
2026-02-20 03:10:46 +00:00
Eric Gullickson
781241966c chore: change google region
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-19 20:59:40 -06:00
Eric Gullickson
bf6742f6ea chore: Gemini 3.0 Flash Preview model
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 33s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-19 20:36:34 -06:00
Eric Gullickson
5bb44be8bc chore: Change to Gemini 3.0 Flash
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 21s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-19 20:35:06 -06:00
Eric Gullickson
361f58d7c6 fix: resolve VIN decode cache race, fuzzy matching, and silent failure (refs #229)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Prevent lower-confidence Gemini results from overwriting higher-confidence
cache entries, add reverse-contains matching so values like "X5 xDrive35i"
match DB option "X5", and show amber hint when dropdown matching fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:14:54 -06:00
Eric Gullickson
d96736789e feat: update frontend for Gemini VIN decode (refs #228)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 23s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Rename nhtsaValue to sourceValue in frontend MatchedField type and
VinOcrReviewModal component. Update NHTSA references to vehicle
database across guide pages, hooks, and API documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:51:45 -06:00
Eric Gullickson
f590421058 chore: remove NHTSA code and update documentation (refs #227)
Delete vehicles/external/nhtsa/ directory (3 files), remove VPICVariable
and VPICResponse from platform models. Update all documentation to
reflect Gemini VIN decode via OCR service architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:51:38 -06:00
Eric Gullickson
5cbf9c764d feat: rewire vehicles controller to OCR VIN decode (refs #226)
Replace NHTSAClient with OcrClient in vehicles controller. Move cache
logic into VehiclesService with format-aware reads (Gemini vs legacy
NHTSA entries). Rename nhtsaValue to sourceValue in MatchedField.
Remove vpic config from Zod schema and YAML config files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:47:47 -06:00
Eric Gullickson
3cd61256ba feat: add backend OCR client method for VIN decode (refs #225)
Add VinDecodeResponse type and OcrClient.decodeVin() method that sends
JSON POST to the new /decode/vin OCR endpoint. Unlike other OCR methods,
this uses JSON body instead of multipart since there is no file upload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:40:47 -06:00
Eric Gullickson
a75f7b5583 feat: add VIN decode endpoint to OCR Python service (refs #224)
Add POST /decode/vin endpoint using Gemini 2.5 Flash for VIN string
decoding. Returns structured vehicle data (year, make, model, trim,
body/drive/fuel type, engine, transmission) with confidence score.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:40:10 -06:00
00aa2a5411 Merge pull request 'chore: Update email FROM address and fix unsubscribe link' (#222) from issue-221-update-email-from-and-unsubscribe into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 35s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #222
2026-02-19 02:54:27 +00:00
Eric Gullickson
1dac6d342b fix: evaluate copyright year in email footer template (refs #221)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Add missing $ prefix to template literal expression so the year
renders as "2026" instead of literal "{new Date().getFullYear()}".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:43:00 -06:00
Eric Gullickson
3b62f5a621 fix: Email Logo URL
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 23s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
2026-02-18 20:32:28 -06:00
Eric Gullickson
4f4fb8a886 chore: update email FROM address and fix unsubscribe link (refs #221)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Change default FROM to hello@notify.motovaultpro.com across app and CI
senders. Replace broken {{unsubscribeUrl}} placeholder with real Settings
page URL. Add RFC 8058 List-Unsubscribe headers for email client support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:19:19 -06:00
Eric Gullickson
d57c5d6cf8 chore: Update from email addresses
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m44s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-16 21:07:56 -06:00
Eric Gullickson
8a73352ddc fix: charge immediately on subscription and read item-level period dates
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m39s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Three fixes to the Stripe subscription flow:

1. Change payment_behavior from 'default_incomplete' to
   'error_if_incomplete' so Stripe charges the card immediately instead
   of leaving the subscription in incomplete status waiting for frontend
   payment confirmation that never happens.

2. Read currentPeriodStart/End from subscription items instead of the
   top-level subscription object. Stripe moved these fields to
   items.data[0] in API version 2025-03-31.basil, causing epoch-zero
   dates (Dec 31, 1969).

3. Map Stripe 'incomplete' status to 'active' in mapStripeStatus() so
   it doesn't fall through to the default 'canceled' mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:40:58 -06:00
Eric Gullickson
72e557346c fix: attach payment method to customer before creating subscription
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m39s
Deploy to Staging / Deploy to Staging (push) Successful in 23s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Stripe requires payment methods to be attached to a customer before they
can be set as default_payment_method on a subscription. The
createSubscription() method was skipping this step, causing 500 errors
on checkout with: "The customer does not have a payment method with the
ID pm_xxx".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:21:31 -06:00
Eric Gullickson
853a075e8b chore: centralize docker-compose variables into .env
All checks were successful
Deploy to Staging / Build Images (push) Successful in 39s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Stripe Price IDs were hardcoded and duplicated across 4 compose files.
Log levels were hardcoded per-overlay instead of using generate-log-config.sh.
This refactors all environment-specific variables into a single .env file
that CI/CD generates from Gitea repo variables + generate-log-config.sh.

- Add .env.example template with documented variables
- Replace hardcoded values with ${VAR:-default} substitution in base compose
- Simplify prod overlay from 90 to 32 lines (remove redundant env blocks)
- Add YAML anchors to blue-green overlay (eliminate blue/green duplication)
- Remove redundant OCR env block from staging overlay
- Change generate-log-config.sh to output to stdout (pipe into .env)
- Update staging/production CI/CD to generate .env with Stripe + log vars
- Remove dangerous pk_live_ default from VITE_STRIPE_PUBLISHABLE_KEY

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:57:36 -06:00
Eric Gullickson
07c3d8511d fix: Stripe ID's take 3
All checks were successful
Deploy to Staging / Build Images (push) Successful in 38s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 9s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-16 16:38:17 -06:00
Eric Gullickson
15956a8711 fix: Stripe ID's take 2
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m28s
Deploy to Staging / Deploy to Staging (push) Successful in 53s
Deploy to Staging / Verify Staging (push) Successful in 10s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-02-16 15:29:00 -06:00
Eric Gullickson
714ed92438 Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
Some checks failed
Deploy to Staging / Build Images (push) Has been cancelled
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Deploy to Staging / Verify Staging (push) Has been cancelled
Deploy to Staging / Notify Staging Ready (push) Has been cancelled
Deploy to Staging / Notify Staging Failure (push) Has been cancelled
2026-02-16 15:28:08 -06:00
Eric Gullickson
bc0be75957 fix: Update Stripe ID's 2026-02-16 15:28:05 -06:00
63 changed files with 1310 additions and 930 deletions

View File

@@ -52,7 +52,8 @@
"ONE PR for the parent issue. The PR closes the parent and all sub-issues.", "ONE PR for the parent issue. The PR closes the parent and all sub-issues.",
"Commits reference the specific sub-issue index they implement.", "Commits reference the specific sub-issue index they implement.",
"Sub-issues should be small enough to fit in a single AI context window.", "Sub-issues should be small enough to fit in a single AI context window.",
"Plan milestones map 1:1 to sub-issues." "Plan milestones map 1:1 to sub-issues.",
"Each sub-issue receives its own plan comment with duplicated shared context. An agent must be able to execute from the sub-issue alone."
], ],
"examples": { "examples": {
"parent": "#105 'feat: Add Grafana dashboards and alerting'", "parent": "#105 'feat: Add Grafana dashboards and alerting'",
@@ -103,8 +104,9 @@
"[SKILL] Problem Analysis if complex problem.", "[SKILL] Problem Analysis if complex problem.",
"[SKILL] Decision Critic if uncertain approach.", "[SKILL] Decision Critic if uncertain approach.",
"If multi-file feature (3+ files): decompose into sub-issues per sub_issues rules. Each sub-issue = one plan milestone.", "If multi-file feature (3+ files): decompose into sub-issues per sub_issues rules. Each sub-issue = one plan milestone.",
"[SKILL] Planner writes plan as parent issue comment. Plan milestones map 1:1 to sub-issues.", "[SKILL] Planner writes plan summary as parent issue comment: shared context + milestone index linking each milestone to its sub-issue. M5 (doc-sync) stays on parent if no sub-issue exists.",
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs.", "[SKILL] Planner posts each milestone's self-contained implementation plan as a comment on the corresponding sub-issue. Each sub-issue plan duplicates relevant shared context (API maps, state changes, auth, error handling, risk) so an agent can execute from the sub-issue alone without reading the parent.",
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs. Distribute milestone-specific review findings to sub-issue plan comments.",
"Create ONE branch issue-{parent_index}-{slug} from main.", "Create ONE branch issue-{parent_index}-{slug} from main.",
"[SKILL] Planner executes plan, delegates to Developer per milestone/sub-issue.", "[SKILL] Planner executes plan, delegates to Developer per milestone/sub-issue.",
"[SKILL] QR post-implementation per milestone (results in parent issue comment).", "[SKILL] QR post-implementation per milestone (results in parent issue comment).",
@@ -123,7 +125,7 @@
"execution_review": ["QR post-implementation per milestone"], "execution_review": ["QR post-implementation per milestone"],
"final_review": ["Quality Agent RULE 0/1/2"] "final_review": ["Quality Agent RULE 0/1/2"]
}, },
"plan_storage": "gitea_issue_comments", "plan_storage": "gitea_issue_comments: summary on parent issue, milestone detail on sub-issues",
"tracking_storage": "gitea_issue_comments", "tracking_storage": "gitea_issue_comments",
"issue_comment_operations": { "issue_comment_operations": {
"create_comment": "mcp__gitea-mcp__create_issue_comment", "create_comment": "mcp__gitea-mcp__create_issue_comment",

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# MotoVaultPro Environment Configuration
# Copy to .env and fill in environment-specific values
# Generated .env files should NOT be committed to version control
#
# Local dev: No .env needed -- base docker-compose.yml defaults are sandbox values
# Staging/Production: CI/CD generates .env from Gitea variables + generate-log-config.sh
# ===========================================
# Stripe Price IDs (environment-specific)
# ===========================================
# Sandbox defaults used for local development
STRIPE_PRO_MONTHLY_PRICE_ID=price_1T1ZHMJXoKkh5RcKwKSSGIlR
STRIPE_PRO_YEARLY_PRICE_ID=price_1T1ZHnJXoKkh5RcKWlG2MPpX
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_1T1ZIBJXoKkh5RcKu2jyhqBN
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_1T1ZIQJXoKkh5RcK34YXiJQm
# ===========================================
# Stripe Publishable Key (baked into frontend at build time)
# ===========================================
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...
# ===========================================
# Log Levels (generated by scripts/ci/generate-log-config.sh)
# ===========================================
# Run: ./scripts/ci/generate-log-config.sh DEBUG >> .env
#
# BACKEND_LOG_LEVEL=debug
# TRAEFIK_LOG_LEVEL=DEBUG
# POSTGRES_LOG_STATEMENT=all
# POSTGRES_LOG_MIN_DURATION=0
# REDIS_LOGLEVEL=debug
# ===========================================
# Grafana
# ===========================================
# GRAFANA_ADMIN_PASSWORD=admin

View File

@@ -99,6 +99,7 @@ jobs:
docker-compose.yml docker-compose.yml
docker-compose.blue-green.yml docker-compose.blue-green.yml
docker-compose.prod.yml docker-compose.prod.yml
.env.example
sparse-checkout-cone-mode: false sparse-checkout-cone-mode: false
fetch-depth: 1 fetch-depth: 1
@@ -115,11 +116,20 @@ jobs:
mkdir -p "$DEPLOY_PATH/secrets/app" mkdir -p "$DEPLOY_PATH/secrets/app"
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/" cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/"
- name: Generate logging configuration - name: Generate environment configuration
run: | run: |
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
{
echo "# Generated by CI/CD - DO NOT EDIT"
echo "STRIPE_PRO_MONTHLY_PRICE_ID=${{ vars.STRIPE_PRO_MONTHLY_PRICE_ID }}"
echo "STRIPE_PRO_YEARLY_PRICE_ID=${{ vars.STRIPE_PRO_YEARLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_YEARLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_YEARLY_PRICE_ID }}"
echo "VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }}"
echo "GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}"
} > .env
chmod +x scripts/ci/generate-log-config.sh chmod +x scripts/ci/generate-log-config.sh
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" ./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
- name: Login to registry - name: Login to registry
run: | run: |

View File

@@ -124,11 +124,20 @@ jobs:
mkdir -p "$DEPLOY_PATH/secrets/app" mkdir -p "$DEPLOY_PATH/secrets/app"
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/" cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/"
- name: Generate logging configuration - name: Generate environment configuration
run: | run: |
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
{
echo "# Generated by CI/CD - DO NOT EDIT"
echo "STRIPE_PRO_MONTHLY_PRICE_ID=${{ vars.STRIPE_PRO_MONTHLY_PRICE_ID }}"
echo "STRIPE_PRO_YEARLY_PRICE_ID=${{ vars.STRIPE_PRO_YEARLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID }}"
echo "STRIPE_ENTERPRISE_YEARLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_YEARLY_PRICE_ID }}"
echo "VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }}"
echo "GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}"
} > .env
chmod +x scripts/ci/generate-log-config.sh chmod +x scripts/ci/generate-log-config.sh
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" ./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
- name: Login to registry - name: Login to registry
run: | run: |

View File

@@ -41,14 +41,6 @@ const configSchema = z.object({
audience: z.string(), audience: z.string(),
}), }),
// External APIs configuration (optional)
external: z.object({
vpic: z.object({
url: z.string(),
timeout: z.string(),
}).optional(),
}).optional(),
// Service configuration // Service configuration
service: z.object({ service: z.object({
name: z.string(), name: z.string(),

View File

@@ -29,7 +29,7 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
'vehicle.vinDecode': { 'vehicle.vinDecode': {
minTier: 'pro', minTier: 'pro',
name: 'VIN Decode', name: 'VIN Decode',
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the NHTSA database.', upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the vehicle database.',
}, },
'fuelLog.receiptScan': { 'fuelLog.receiptScan': {
minTier: 'pro', minTier: 'pro',

View File

@@ -97,7 +97,7 @@ Templates use `{{variableName}}` syntax for variable substitution.
### Environment Variables ### Environment Variables
- `RESEND_API_KEY` - Resend API key (required, stored in secrets) - `RESEND_API_KEY` - Resend API key (required, stored in secrets)
- `FROM_EMAIL` - Sender email address (default: noreply@motovaultpro.com) - `FROM_EMAIL` - Sender email address (default: hello@notify.motovaultpro.com)
### Email Delivery ### Email Delivery
- Uses Resend API for transactional emails - Uses Resend API for transactional emails

View File

@@ -7,7 +7,7 @@
import { EMAIL_STYLES } from './email-styles'; import { EMAIL_STYLES } from './email-styles';
// External logo URL - hosted on GitHub for reliability // External logo URL - hosted on GitHub for reliability
const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png'; const LOGO_URL = 'https://motovaultpro.com/images/logos/motovaultpro-auth0-small.png';
/** /**
* Renders the complete HTML email layout with branding * Renders the complete HTML email layout with branding
@@ -65,10 +65,10 @@ export function renderEmailLayout(content: string): string {
<a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a> <a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
</p> </p>
<p style="${EMAIL_STYLES.footerText}"> <p style="${EMAIL_STYLES.footerText}">
<a href="{{unsubscribeUrl}}" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a> <a href="https://motovaultpro.com/settings" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
</p> </p>
<p style="${EMAIL_STYLES.copyright}"> <p style="${EMAIL_STYLES.copyright}">
&copy; {new Date().getFullYear()} MotoVaultPro. All rights reserved. &copy; ${new Date().getFullYear()} MotoVaultPro. All rights reserved.
</p> </p>
</td> </td>
</tr> </tr>

View File

@@ -16,7 +16,7 @@ export class EmailService {
} }
this.resend = new Resend(apiKey); this.resend = new Resend(apiKey);
this.fromEmail = process.env['FROM_EMAIL'] || 'noreply@motovaultpro.com'; this.fromEmail = process.env['FROM_EMAIL'] || 'hello@notify.motovaultpro.com';
} }
/** /**
@@ -33,6 +33,10 @@ export class EmailService {
to, to,
subject, subject,
html, html,
headers: {
'List-Unsubscribe': '<https://motovaultpro.com/settings>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
}); });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';

View File

@@ -37,7 +37,7 @@ Backend proxy for the Python OCR microservice. Handles authentication, tier gati
| File | What | When to read | | File | What | When to read |
| ---- | ---- | ------------ | | ---- | ---- | ------------ |
| `ocr-client.ts` | HTTP client to mvp-ocr Python service (extract, extractVin, extractReceipt, submitJob, submitManualJob, getJobStatus, isHealthy) | OCR service communication, error handling | | `ocr-client.ts` | HTTP client to mvp-ocr Python service (extract, extractVin, extractReceipt, decodeVin, submitJob, submitManualJob, getJobStatus, isHealthy) | OCR service communication, error handling |
## tests/ ## tests/

View File

@@ -131,3 +131,21 @@ export interface ManualJobResponse {
result?: ManualExtractionResult; result?: ManualExtractionResult;
error?: string; error?: string;
} }
/** Response from VIN decode via Gemini (OCR service) */
export interface VinDecodeResponse {
success: boolean;
vin: string;
year: number | null;
make: string | null;
model: string | null;
trimLevel: string | null;
bodyType: string | null;
driveType: string | null;
fuelType: string | null;
engine: string | null;
transmission: string | null;
confidence: number;
processingTimeMs: number;
error: string | null;
}

View File

@@ -2,7 +2,7 @@
* @ai-summary HTTP client for OCR service communication * @ai-summary HTTP client for OCR service communication
*/ */
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinExtractionResponse } from '../domain/ocr.types'; import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinDecodeResponse, VinExtractionResponse } from '../domain/ocr.types';
/** OCR service configuration */ /** OCR service configuration */
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000'; const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
@@ -373,6 +373,55 @@ export class OcrClient {
return result; return result;
} }
/**
* Decode a VIN string into structured vehicle data via Gemini.
*
* Unlike other OCR methods, this sends JSON (not multipart) because
* VIN decode has no file upload.
*
* @param vin - 17-character Vehicle Identification Number
* @returns Structured vehicle data from Gemini decode
*/
async decodeVin(vin: string): Promise<VinDecodeResponse> {
const url = `${this.baseUrl}/decode/vin`;
logger.info('OCR VIN decode request', {
operation: 'ocr.client.decodeVin',
url,
vin,
});
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vin }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('OCR VIN decode failed', {
operation: 'ocr.client.decodeVin.error',
status: response.status,
error: errorText,
});
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
err.statusCode = response.status;
throw err;
}
const result = (await response.json()) as VinDecodeResponse;
logger.info('OCR VIN decode completed', {
operation: 'ocr.client.decodeVin.success',
success: result.success,
vin: result.vin,
confidence: result.confidence,
processingTimeMs: result.processingTimeMs,
});
return result;
}
/** /**
* Check if the OCR service is healthy. * Check if the OCR service is healthy.
* *

View File

@@ -117,7 +117,7 @@ platform/
When implemented, VIN decoding will use: When implemented, VIN decoding will use:
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures) 1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
2. **PostgreSQL**: Database function for high-confidence decode 2. **PostgreSQL**: Database function for high-confidence decode
3. **vPIC Fallback**: NHTSA vPIC API with circuit breaker protection 3. **OCR Service Fallback**: Gemini VIN decode via OCR service
4. **Graceful Degradation**: Return meaningful errors when all sources fail 4. **Graceful Degradation**: Return meaningful errors when all sources fail
### Database Schema ### Database Schema
@@ -164,7 +164,7 @@ When VIN decoding is implemented:
### External APIs (Planned/Future) ### External APIs (Planned/Future)
When VIN decoding is implemented: When VIN decoding is implemented:
- **NHTSA vPIC**: https://vpic.nhtsa.dot.gov/api (VIN decoding fallback) - **OCR Service**: Gemini VIN decode via mvp-ocr (VIN decoding fallback)
### Database Tables ### Database Tables
- **vehicle_options** - Hierarchical vehicle data (years, makes, models, trims, engines, transmissions) - **vehicle_options** - Hierarchical vehicle data (years, makes, models, trims, engines, transmissions)
@@ -269,7 +269,7 @@ npm run lint
## Future Considerations ## Future Considerations
### Planned Features ### Planned Features
- VIN decoding endpoint with PostgreSQL + vPIC fallback - VIN decoding endpoint with PostgreSQL + Gemini/OCR service fallback
- Circuit breaker pattern for external API resilience - Circuit breaker pattern for external API resilience
### Potential Enhancements ### Potential Enhancements

View File

@@ -61,19 +61,3 @@ export interface VINDecodeResponse {
error?: string; error?: string;
} }
/**
* vPIC API response structure (NHTSA)
*/
export interface VPICVariable {
Variable: string;
Value: string | null;
ValueId: string | null;
VariableId: number;
}
export interface VPICResponse {
Count: number;
Message: string;
SearchCriteria: string;
Results: VPICVariable[];
}

View File

@@ -570,11 +570,13 @@ export class SubscriptionsService {
} }
// Update subscription with Stripe subscription ID // Update subscription with Stripe subscription ID
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = stripeSubscription.items?.data?.[0];
await this.repository.update(subscription.id, { await this.repository.update(subscription.id, {
stripeSubscriptionId: stripeSubscription.id, stripeSubscriptionId: stripeSubscription.id,
status: this.mapStripeStatus(stripeSubscription.status), status: this.mapStripeStatus(stripeSubscription.status),
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
}); });
// Log event // Log event
@@ -608,11 +610,13 @@ export class SubscriptionsService {
const tier = this.determineTierFromStripeSubscription(stripeSubscription); const tier = this.determineTierFromStripeSubscription(stripeSubscription);
// Update subscription // Update subscription
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = stripeSubscription.items?.data?.[0];
const updateData: UpdateSubscriptionData = { const updateData: UpdateSubscriptionData = {
status: this.mapStripeStatus(stripeSubscription.status), status: this.mapStripeStatus(stripeSubscription.status),
tier, tier,
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
}; };
@@ -849,6 +853,7 @@ export class SubscriptionsService {
switch (stripeStatus) { switch (stripeStatus) {
case 'active': case 'active':
case 'trialing': case 'trialing':
case 'incomplete':
return 'active'; return 'active';
case 'past_due': case 'past_due':
return 'past_due'; return 'past_due';

View File

@@ -75,10 +75,18 @@ export class StripeClient {
try { try {
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId }); logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
// Attach payment method to customer before creating subscription
if (paymentMethodId) {
await this.stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
logger.info('Payment method attached to customer', { customerId, paymentMethodId });
}
const subscriptionParams: Stripe.SubscriptionCreateParams = { const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: customerId, customer: customerId,
items: [{ price: priceId }], items: [{ price: priceId }],
payment_behavior: 'default_incomplete', payment_behavior: 'error_if_incomplete',
payment_settings: { payment_settings: {
save_default_payment_method: 'on_subscription', save_default_payment_method: 'on_subscription',
}, },
@@ -93,13 +101,16 @@ export class StripeClient {
logger.info('Stripe subscription created', { subscriptionId: subscription.id }); logger.info('Stripe subscription created', { subscriptionId: subscription.id });
// Period dates moved from subscription to items in API 2025-03-31.basil
const item = subscription.items?.data?.[0];
return { return {
id: subscription.id, id: subscription.id,
customer: subscription.customer as string, customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'], status: subscription.status as StripeSubscription['status'],
items: subscription.items, items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0, currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: (subscription as any).current_period_end || 0, currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined, canceledAt: subscription.canceled_at || undefined,
created: subscription.created, created: subscription.created,
@@ -140,13 +151,15 @@ export class StripeClient {
logger.info('Stripe subscription canceled immediately', { subscriptionId }); logger.info('Stripe subscription canceled immediately', { subscriptionId });
} }
const item = subscription.items?.data?.[0];
return { return {
id: subscription.id, id: subscription.id,
customer: subscription.customer as string, customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'], status: subscription.status as StripeSubscription['status'],
items: subscription.items, items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0, currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: (subscription as any).current_period_end || 0, currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined, canceledAt: subscription.canceled_at || undefined,
created: subscription.created, created: subscription.created,
@@ -286,14 +299,15 @@ export class StripeClient {
logger.info('Retrieving Stripe subscription', { subscriptionId }); logger.info('Retrieving Stripe subscription', { subscriptionId });
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items?.data?.[0];
return { return {
id: subscription.id, id: subscription.id,
customer: subscription.customer as string, customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'], status: subscription.status as StripeSubscription['status'],
items: subscription.items, items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0, currentPeriodStart: item?.current_period_start ?? 0,
currentPeriodEnd: (subscription as any).current_period_end || 0, currentPeriodEnd: item?.current_period_end ?? 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined, canceledAt: subscription.canceled_at || undefined,
created: subscription.created, created: subscription.created,

View File

@@ -16,6 +16,6 @@
| `data/` | Repository, database queries | Database operations | | `data/` | Repository, database queries | Database operations |
| `docs/` | Feature-specific documentation | Vehicle design details | | `docs/` | Feature-specific documentation | Vehicle design details |
| `events/` | Event handlers and emitters | Cross-feature event integration | | `events/` | Event handlers and emitters | Cross-feature event integration |
| `external/` | External service integrations (NHTSA) | VIN decoding, third-party APIs | | `external/` | External service integrations | VIN decoding, third-party APIs |
| `migrations/` | Database schema | Schema changes | | `migrations/` | Database schema | Schema changes |
| `tests/` | Unit and integration tests | Adding or modifying tests | | `tests/` | Unit and integration tests | Adding or modifying tests |

View File

@@ -13,7 +13,7 @@ Primary entity for vehicle management consuming MVP Platform Vehicles Service. H
- `DELETE /api/vehicles/:id` - Soft delete vehicle - `DELETE /api/vehicles/:id` - Soft delete vehicle
### VIN Decoding (Pro/Enterprise Only) ### VIN Decoding (Pro/Enterprise Only)
- `POST /api/vehicles/decode-vin` - Decode VIN using NHTSA vPIC API - `POST /api/vehicles/decode-vin` - Decode VIN using Gemini via OCR service
### Hierarchical Vehicle Dropdowns ### Hierarchical Vehicle Dropdowns
**Status**: Vehicles service now proxies the platform vehicle catalog to provide fully dynamic dropdowns. Each selection step filters the next list, ensuring only valid combinations are shown. **Status**: Vehicles service now proxies the platform vehicle catalog to provide fully dynamic dropdowns. Each selection step filters the next list, ensuring only valid combinations are shown.
@@ -104,11 +104,7 @@ vehicles/
├── data/ # Database layer ├── data/ # Database layer
│ └── vehicles.repository.ts │ └── vehicles.repository.ts
├── external/ # External service integrations ├── external/ # External service integrations
── CLAUDE.md # Integration pattern docs ── CLAUDE.md # Integration pattern docs
│ └── nhtsa/ # NHTSA vPIC API client
│ ├── nhtsa.client.ts
│ ├── nhtsa.types.ts
│ └── index.ts
├── migrations/ # Feature schema ├── migrations/ # Feature schema
│ └── 001_create_vehicles_tables.sql │ └── 001_create_vehicles_tables.sql
├── tests/ # All tests ├── tests/ # All tests
@@ -121,14 +117,14 @@ vehicles/
## Key Features ## Key Features
### 🔍 VIN Decoding (NHTSA vPIC API) ### VIN Decoding (Gemini via OCR Service)
- **Tier Gating**: Pro and Enterprise users only (`vehicle.vinDecode` feature key) - **Tier Gating**: Pro and Enterprise users only (`vehicle.vinDecode` feature key)
- **NHTSA API**: Calls official NHTSA vPIC API for authoritative vehicle data - **Gemini**: Calls OCR service Gemini VIN decode for authoritative vehicle data
- **Caching**: Results cached in `vin_cache` table (1-year TTL, VIN data is static) - **Caching**: Results cached in `vin_cache` table (1-year TTL, VIN data is static)
- **Validation**: 17-character VIN format, excludes I/O/Q characters - **Validation**: 17-character VIN format, excludes I/O/Q characters
- **Matching**: Case-insensitive exact match against dropdown options - **Matching**: Case-insensitive exact match against dropdown options
- **Confidence Levels**: High (exact match), Medium (normalized match), None (hint only) - **Confidence Levels**: High (exact match), Medium (normalized match), None (hint only)
- **Timeout**: 5-second timeout for NHTSA API calls - **Timeout**: 5-second timeout for OCR service calls
#### Decode VIN Request #### Decode VIN Request
```json ```json
@@ -140,15 +136,15 @@ Authorization: Bearer <jwt>
Response (200): Response (200):
{ {
"year": { "value": 2021, "nhtsaValue": "2021", "confidence": "high" }, "year": { "value": 2021, "decodedValue": "2021", "confidence": "high" },
"make": { "value": "Honda", "nhtsaValue": "HONDA", "confidence": "high" }, "make": { "value": "Honda", "decodedValue": "HONDA", "confidence": "high" },
"model": { "value": "Civic", "nhtsaValue": "Civic", "confidence": "high" }, "model": { "value": "Civic", "decodedValue": "Civic", "confidence": "high" },
"trimLevel": { "value": "EX", "nhtsaValue": "EX", "confidence": "high" }, "trimLevel": { "value": "EX", "decodedValue": "EX", "confidence": "high" },
"engine": { "value": null, "nhtsaValue": "2.0L L4 DOHC 16V", "confidence": "none" }, "engine": { "value": null, "decodedValue": "2.0L L4 DOHC 16V", "confidence": "none" },
"transmission": { "value": null, "nhtsaValue": "CVT", "confidence": "none" }, "transmission": { "value": null, "decodedValue": "CVT", "confidence": "none" },
"bodyType": { "value": null, "nhtsaValue": "Sedan", "confidence": "none" }, "bodyType": { "value": null, "decodedValue": "Sedan", "confidence": "none" },
"driveType": { "value": null, "nhtsaValue": "FWD", "confidence": "none" }, "driveType": { "value": null, "decodedValue": "FWD", "confidence": "none" },
"fuelType": { "value": null, "nhtsaValue": "Gasoline", "confidence": "none" } "fuelType": { "value": null, "decodedValue": "Gasoline", "confidence": "none" }
} }
Error (400 - Invalid VIN): Error (400 - Invalid VIN):
@@ -157,7 +153,7 @@ Error (400 - Invalid VIN):
Error (403 - Tier Required): Error (403 - Tier Required):
{ "error": "TIER_REQUIRED", "requiredTier": "pro", "currentTier": "free", ... } { "error": "TIER_REQUIRED", "requiredTier": "pro", "currentTier": "free", ... }
Error (502 - NHTSA Failure): Error (502 - OCR Service Failure):
{ "error": "VIN_DECODE_FAILED", "message": "Unable to decode VIN from external service" } { "error": "VIN_DECODE_FAILED", "message": "Unable to decode VIN from external service" }
``` ```
@@ -230,7 +226,7 @@ Error (502 - NHTSA Failure):
## Testing ## Testing
### Unit Tests ### Unit Tests
- `vehicles.service.test.ts` - Business logic with mocked dependencies (VIN decode, caching, CRUD operations) - `vehicles.service.test.ts` - Business logic with mocked dependencies (VIN decode via OCR service mock, caching, CRUD operations)
### Integration Tests ### Integration Tests
- `vehicles.integration.test.ts` - Complete API workflow with test database (create, read, update, delete vehicles) - `vehicles.integration.test.ts` - Complete API workflow with test database (create, read, update, delete vehicles)

View File

@@ -10,19 +10,18 @@ import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types'; import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
import { getStorageService } from '../../../core/storage/storage.service'; import { getStorageService } from '../../../core/storage/storage.service';
import { NHTSAClient, DecodeVinRequest } from '../external/nhtsa'; import { ocrClient } from '../../ocr/external/ocr-client';
import type { DecodeVinRequest } from '../domain/vehicles.types';
import crypto from 'crypto'; import crypto from 'crypto';
import FileType from 'file-type'; import FileType from 'file-type';
import path from 'path'; import path from 'path';
export class VehiclesController { export class VehiclesController {
private vehiclesService: VehiclesService; private vehiclesService: VehiclesService;
private nhtsaClient: NHTSAClient;
constructor() { constructor() {
const repository = new VehiclesRepository(pool); const repository = new VehiclesRepository(pool);
this.vehiclesService = new VehiclesService(repository, pool); this.vehiclesService = new VehiclesService(repository, pool);
this.nhtsaClient = new NHTSAClient(pool);
} }
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) { async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
@@ -378,7 +377,7 @@ export class VehiclesController {
} }
/** /**
* Decode VIN using NHTSA vPIC API * Decode VIN using OCR service (Gemini)
* POST /api/vehicles/decode-vin * POST /api/vehicles/decode-vin
* Requires Pro or Enterprise tier * Requires Pro or Enterprise tier
*/ */
@@ -395,26 +394,39 @@ export class VehiclesController {
}); });
} }
logger.info('VIN decode requested', { userId, vin: vin.substring(0, 6) + '...' }); // Validate VIN format
const sanitizedVin = vin.trim().toUpperCase();
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
if (!VIN_REGEX.test(sanitizedVin)) {
return reply.code(400).send({
error: 'INVALID_VIN',
message: 'Invalid VIN format. VIN must be exactly 17 characters and contain only letters (except I, O, Q) and numbers.'
});
}
// Validate and decode VIN logger.info('VIN decode requested', { userId, vin: sanitizedVin.substring(0, 6) + '...' });
const response = await this.nhtsaClient.decodeVin(vin);
// Extract and map fields from NHTSA response // Call OCR service for VIN decode
const decodedData = await this.vehiclesService.mapNHTSAResponse(response); const response = await ocrClient.decodeVin(sanitizedVin);
// Map response to decoded vehicle data with dropdown matching
const decodedData = await this.vehiclesService.mapVinDecodeResponse(response);
logger.info('VIN decode successful', { logger.info('VIN decode successful', {
userId, userId,
hasYear: !!decodedData.year.value, hasYear: !!decodedData.year.value,
hasMake: !!decodedData.make.value, hasMake: !!decodedData.make.value,
hasModel: !!decodedData.model.value hasModel: !!decodedData.model.value,
hasTrim: !!decodedData.trimLevel.value,
hasEngine: !!decodedData.engine.value,
hasTransmission: !!decodedData.transmission.value,
}); });
return reply.code(200).send(decodedData); return reply.code(200).send(decodedData);
} catch (error: any) { } catch (error: any) {
logger.error('VIN decode failed', { error, userId }); logger.error('VIN decode failed', { error, userId });
// Handle validation errors // Handle VIN validation errors
if (error.message?.includes('Invalid VIN')) { if (error.message?.includes('Invalid VIN')) {
return reply.code(400).send({ return reply.code(400).send({
error: 'INVALID_VIN', error: 'INVALID_VIN',
@@ -422,16 +434,25 @@ export class VehiclesController {
}); });
} }
// Handle timeout // Handle OCR service errors by status code
if (error.message?.includes('timed out')) { if (error.statusCode === 503 || error.statusCode === 422) {
return reply.code(504).send({ return reply.code(502).send({
error: 'VIN_DECODE_TIMEOUT', error: 'VIN_DECODE_FAILED',
message: 'NHTSA API request timed out. Please try again.' message: 'VIN decode service unavailable',
details: error.message
}); });
} }
// Handle NHTSA API errors // Handle timeout
if (error.message?.includes('NHTSA')) { if (error.message?.includes('timed out') || error.message?.includes('aborted')) {
return reply.code(504).send({
error: 'VIN_DECODE_TIMEOUT',
message: 'VIN decode service timed out. Please try again.'
});
}
// Handle OCR service errors
if (error.message?.includes('OCR service error')) {
return reply.code(502).send({ return reply.code(502).send({
error: 'VIN_DECODE_FAILED', error: 'VIN_DECODE_FAILED',
message: 'Unable to decode VIN from external service', message: 'Unable to decode VIN from external service',

View File

@@ -75,7 +75,7 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
handler: vehiclesController.getDropdownOptions.bind(vehiclesController) handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
}); });
// POST /api/vehicles/decode-vin - Decode VIN using NHTSA vPIC API (Pro/Enterprise only) // POST /api/vehicles/decode-vin - Decode VIN via OCR service (Pro/Enterprise only)
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', { fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
preHandler: [fastify.authenticate, fastify.requireTier({ featureKey: 'vehicle.vinDecode' })], preHandler: [fastify.authenticate, fastify.requireTier({ featureKey: 'vehicle.vinDecode' })],
handler: vehiclesController.decodeVin.bind(vehiclesController) handler: vehiclesController.decodeVin.bind(vehiclesController)

View File

@@ -1,6 +1,6 @@
/** /**
* @ai-summary Business logic for vehicles feature * @ai-summary Business logic for vehicles feature
* @ai-context Handles VIN decoding, caching, and business rules * @ai-context Handles VIN decoding and business rules
*/ */
import { Pool } from 'pg'; import { Pool } from 'pg';
@@ -24,7 +24,8 @@ import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/v
import { normalizeMakeName, normalizeModelName } from './name-normalizer'; import { normalizeMakeName, normalizeModelName } from './name-normalizer';
import { getVehicleDataService, getPool } from '../../platform'; import { getVehicleDataService, getPool } from '../../platform';
import { auditLogService } from '../../audit-log'; import { auditLogService } from '../../audit-log';
import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa'; import type { VinDecodeResponse } from '../../ocr/domain/ocr.types';
import type { DecodedVehicleData, MatchedField } from './vehicles.types';
import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers'; import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
@@ -657,82 +658,89 @@ export class VehiclesService {
} }
/** /**
* Map NHTSA decode response to internal decoded vehicle data format * Map VIN decode response to internal decoded vehicle data format
* with dropdown matching and confidence levels * with dropdown matching and confidence levels
*/ */
async mapNHTSAResponse(response: NHTSADecodeResponse): Promise<DecodedVehicleData> { async mapVinDecodeResponse(response: VinDecodeResponse): Promise<DecodedVehicleData> {
const vehicleDataService = getVehicleDataService(); const vehicleDataService = getVehicleDataService();
const pool = getPool(); const pool = getPool();
// Extract raw values from NHTSA response // Read flat fields directly from Gemini response
const nhtsaYear = NHTSAClient.extractYear(response); const sourceYear = response.year;
const nhtsaMake = NHTSAClient.extractValue(response, 'Make'); const sourceMake = response.make;
const nhtsaModel = NHTSAClient.extractValue(response, 'Model'); const sourceModel = response.model;
const nhtsaTrim = NHTSAClient.extractValue(response, 'Trim'); const sourceTrim = response.trimLevel;
const nhtsaBodyType = NHTSAClient.extractValue(response, 'Body Class'); const sourceBodyType = response.bodyType;
const nhtsaDriveType = NHTSAClient.extractValue(response, 'Drive Type'); const sourceDriveType = response.driveType;
const nhtsaFuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary'); const sourceFuelType = response.fuelType;
const nhtsaEngine = NHTSAClient.extractEngine(response); const sourceEngine = response.engine;
const nhtsaTransmission = NHTSAClient.extractValue(response, 'Transmission Style'); const sourceTransmission = response.transmission;
logger.debug('VIN decode raw values', {
vin: response.vin,
year: sourceYear, make: sourceMake, model: sourceModel,
trim: sourceTrim, engine: sourceEngine, transmission: sourceTransmission,
confidence: response.confidence
});
// Year is always high confidence if present (exact numeric match) // Year is always high confidence if present (exact numeric match)
const year: MatchedField<number> = { const year: MatchedField<number> = {
value: nhtsaYear, value: sourceYear,
nhtsaValue: nhtsaYear?.toString() || null, sourceValue: sourceYear?.toString() || null,
confidence: nhtsaYear ? 'high' : 'none' confidence: sourceYear ? 'high' : 'none'
}; };
// Match make against dropdown options // Match make against dropdown options
let make: MatchedField<string> = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' }; let make: MatchedField<string> = { value: null, sourceValue: sourceMake, confidence: 'none' };
if (nhtsaYear && nhtsaMake) { if (sourceYear && sourceMake) {
const makes = await vehicleDataService.getMakes(pool, nhtsaYear); const makes = await vehicleDataService.getMakes(pool, sourceYear);
make = this.matchField(nhtsaMake, makes); make = this.matchField(sourceMake, makes);
} }
// Match model against dropdown options // Match model against dropdown options
let model: MatchedField<string> = { value: null, nhtsaValue: nhtsaModel, confidence: 'none' }; let model: MatchedField<string> = { value: null, sourceValue: sourceModel, confidence: 'none' };
if (nhtsaYear && make.value && nhtsaModel) { if (sourceYear && make.value && sourceModel) {
const models = await vehicleDataService.getModels(pool, nhtsaYear, make.value); const models = await vehicleDataService.getModels(pool, sourceYear, make.value);
model = this.matchField(nhtsaModel, models); model = this.matchField(sourceModel, models);
} }
// Match trim against dropdown options // Match trim against dropdown options
let trimLevel: MatchedField<string> = { value: null, nhtsaValue: nhtsaTrim, confidence: 'none' }; let trimLevel: MatchedField<string> = { value: null, sourceValue: sourceTrim, confidence: 'none' };
if (nhtsaYear && make.value && model.value && nhtsaTrim) { if (sourceYear && make.value && model.value && sourceTrim) {
const trims = await vehicleDataService.getTrims(pool, nhtsaYear, make.value, model.value); const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value);
trimLevel = this.matchField(nhtsaTrim, trims); trimLevel = this.matchField(sourceTrim, trims);
} }
// Match engine against dropdown options // Match engine against dropdown options
let engine: MatchedField<string> = { value: null, nhtsaValue: nhtsaEngine, confidence: 'none' }; let engine: MatchedField<string> = { value: null, sourceValue: sourceEngine, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaEngine) { if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) {
const engines = await vehicleDataService.getEngines(pool, nhtsaYear, make.value, model.value, trimLevel.value); const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value);
engine = this.matchField(nhtsaEngine, engines); engine = this.matchField(sourceEngine, engines);
} }
// Match transmission against dropdown options // Match transmission against dropdown options
let transmission: MatchedField<string> = { value: null, nhtsaValue: nhtsaTransmission, confidence: 'none' }; let transmission: MatchedField<string> = { value: null, sourceValue: sourceTransmission, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaTransmission) { if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) {
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, nhtsaYear, make.value, model.value, trimLevel.value); const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value);
transmission = this.matchField(nhtsaTransmission, transmissions); transmission = this.matchField(sourceTransmission, transmissions);
} }
// Body type, drive type, and fuel type are display-only (no dropdown matching) // Body type, drive type, and fuel type are display-only (no dropdown matching)
const bodyType: MatchedField<string> = { const bodyType: MatchedField<string> = {
value: null, value: null,
nhtsaValue: nhtsaBodyType, sourceValue: sourceBodyType,
confidence: 'none' confidence: 'none'
}; };
const driveType: MatchedField<string> = { const driveType: MatchedField<string> = {
value: null, value: null,
nhtsaValue: nhtsaDriveType, sourceValue: sourceDriveType,
confidence: 'none' confidence: 'none'
}; };
const fuelType: MatchedField<string> = { const fuelType: MatchedField<string> = {
value: null, value: null,
nhtsaValue: nhtsaFuelType, sourceValue: sourceFuelType,
confidence: 'none' confidence: 'none'
}; };
@@ -754,42 +762,62 @@ export class VehiclesService {
* Returns the matched dropdown value with confidence level * Returns the matched dropdown value with confidence level
* Matching order: exact -> normalized -> prefix -> contains * Matching order: exact -> normalized -> prefix -> contains
*/ */
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> { private matchField(sourceValue: string, options: string[]): MatchedField<string> {
if (!nhtsaValue || options.length === 0) { if (!sourceValue || options.length === 0) {
return { value: null, nhtsaValue, confidence: 'none' }; return { value: null, sourceValue, confidence: 'none' };
} }
const normalizedNhtsa = nhtsaValue.toLowerCase().trim(); const normalizedSource = sourceValue.toLowerCase().trim();
// Try exact case-insensitive match // Try exact case-insensitive match
const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedNhtsa); const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedSource);
if (exactMatch) { if (exactMatch) {
return { value: exactMatch, nhtsaValue, confidence: 'high' }; return { value: exactMatch, sourceValue, confidence: 'high' };
} }
// Try normalized comparison (remove special chars) // Try normalized comparison (remove special chars)
const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const normalizedNhtsaClean = normalizeForCompare(nhtsaValue); const normalizedSourceClean = normalizeForCompare(sourceValue);
const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedNhtsaClean); const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedSourceClean);
if (normalizedMatch) { if (normalizedMatch) {
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' }; return { value: normalizedMatch, sourceValue, confidence: 'medium' };
} }
// Try prefix match - option starts with NHTSA value // Try prefix match - option starts with source value
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa)); const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource));
if (prefixMatch) { if (prefixMatch) {
return { value: prefixMatch, nhtsaValue, confidence: 'medium' }; return { value: prefixMatch, sourceValue, confidence: 'medium' };
} }
// Try contains match - option contains NHTSA value // Try contains match - option contains source value
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa)); const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource));
if (containsMatch) { if (containsMatch) {
return { value: containsMatch, nhtsaValue, confidence: 'medium' }; return { value: containsMatch, sourceValue, confidence: 'medium' };
} }
// No match found - return NHTSA value as hint with no match // Try reverse contains - source value contains option (e.g., source "X5 xDrive35i" contains option "X5")
return { value: null, nhtsaValue, confidence: 'none' }; // Prefer the longest matching option to avoid false positives (e.g., "X5 M" over "X5")
const reverseMatches = options.filter(opt => {
const normalizedOpt = opt.toLowerCase().trim();
return normalizedSource.includes(normalizedOpt) && normalizedOpt.length > 0;
});
if (reverseMatches.length > 0) {
const bestMatch = reverseMatches.reduce((a, b) => a.length >= b.length ? a : b);
return { value: bestMatch, sourceValue, confidence: 'medium' };
}
// Try word-start match - source starts with option + separator (e.g., "X5 xDrive" starts with "X5 ")
const wordStartMatch = options.find(opt => {
const normalizedOpt = opt.toLowerCase().trim();
return normalizedSource.startsWith(normalizedOpt + ' ') || normalizedSource.startsWith(normalizedOpt + '-');
});
if (wordStartMatch) {
return { value: wordStartMatch, sourceValue, confidence: 'medium' };
}
// No match found - return source value as hint with no match
return { value: null, sourceValue, confidence: 'none' };
} }
private toResponse(vehicle: Vehicle): VehicleResponse { private toResponse(vehicle: Vehicle): VehicleResponse {

View File

@@ -215,3 +215,41 @@ export interface TCOResponse {
distanceUnit: string; distanceUnit: string;
currencyCode: string; currencyCode: string;
} }
/** Confidence level for matched dropdown values */
export type MatchConfidence = 'high' | 'medium' | 'none';
/** Matched field with confidence indicator */
export interface MatchedField<T> {
value: T | null;
sourceValue: string | null;
confidence: MatchConfidence;
}
/**
* Decoded vehicle data with match confidence per field.
* Maps VIN decode response fields to internal field names.
*/
export interface DecodedVehicleData {
year: MatchedField<number>;
make: MatchedField<string>;
model: MatchedField<string>;
trimLevel: MatchedField<string>;
bodyType: MatchedField<string>;
driveType: MatchedField<string>;
fuelType: MatchedField<string>;
engine: MatchedField<string>;
transmission: MatchedField<string>;
}
/** VIN decode request body */
export interface DecodeVinRequest {
vin: string;
}
/** VIN decode error response */
export interface VinDecodeError {
error: 'INVALID_VIN' | 'VIN_DECODE_FAILED' | 'TIER_REQUIRED';
message: string;
details?: string;
}

View File

@@ -5,9 +5,3 @@
| File | What | When to read | | File | What | When to read |
| ---- | ---- | ------------ | | ---- | ---- | ------------ |
| `README.md` | Integration patterns, adding new services | Understanding external service conventions | | `README.md` | Integration patterns, adding new services | Understanding external service conventions |
## Subdirectories
| Directory | What | When to read |
| --------- | ---- | ------------ |
| `nhtsa/` | NHTSA vPIC API client for VIN decoding | VIN decode feature work |

View File

@@ -15,7 +15,7 @@ Each integration follows this structure:
## Adding New Integrations ## Adding New Integrations
1. Create subdirectory: `external/{service}/` 1. Create subdirectory: `external/{service}/`
2. Add client: `{service}.client.ts` following NHTSAClient pattern 2. Add client: `{service}.client.ts` following the axios-based client pattern
3. Add types: `{service}.types.ts` 3. Add types: `{service}.types.ts`
4. Update `CLAUDE.md` with new directory 4. Update `CLAUDE.md` with new directory
5. Add tests in `tests/unit/{service}.client.test.ts` 5. Add tests in `tests/unit/{service}.client.test.ts`

View File

@@ -1,16 +0,0 @@
/**
* @ai-summary NHTSA vPIC integration exports
* @ai-context Public API for VIN decoding functionality
*/
export { NHTSAClient } from './nhtsa.client';
export type {
NHTSADecodeResponse,
NHTSAResult,
DecodedVehicleData,
MatchedField,
MatchConfidence,
VinCacheEntry,
DecodeVinRequest,
VinDecodeError,
} from './nhtsa.types';

View File

@@ -1,235 +0,0 @@
/**
* @ai-summary NHTSA vPIC API client for VIN decoding
* @ai-context Fetches vehicle data from NHTSA and caches results
*/
import axios, { AxiosError } from 'axios';
import { logger } from '../../../../core/logging/logger';
import { NHTSADecodeResponse, VinCacheEntry } from './nhtsa.types';
import { Pool } from 'pg';
/**
* VIN validation regex
* - 17 characters
* - Excludes I, O, Q (not used in VINs)
* - Alphanumeric only
*/
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
/**
* Cache TTL: 1 year (VIN data is static - vehicle specs don't change)
*/
const CACHE_TTL_SECONDS = 365 * 24 * 60 * 60;
export class NHTSAClient {
private readonly baseURL = 'https://vpic.nhtsa.dot.gov/api';
private readonly timeout = 5000; // 5 seconds
constructor(private readonly pool: Pool) {}
/**
* Validate VIN format
* @throws Error if VIN format is invalid
*/
validateVin(vin: string): string {
const sanitized = vin.trim().toUpperCase();
if (!sanitized) {
throw new Error('VIN is required');
}
if (!VIN_REGEX.test(sanitized)) {
throw new Error('Invalid VIN format. VIN must be exactly 17 characters and contain only letters (except I, O, Q) and numbers.');
}
return sanitized;
}
/**
* Check cache for existing VIN data
*/
async getCached(vin: string): Promise<VinCacheEntry | null> {
try {
const result = await this.pool.query<{
vin: string;
make: string | null;
model: string | null;
year: number | null;
engine_type: string | null;
body_type: string | null;
raw_data: NHTSADecodeResponse;
cached_at: Date;
}>(
`SELECT vin, make, model, year, engine_type, body_type, raw_data, cached_at
FROM vin_cache
WHERE vin = $1
AND cached_at > NOW() - INTERVAL '${CACHE_TTL_SECONDS} seconds'`,
[vin]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
vin: row.vin,
make: row.make,
model: row.model,
year: row.year,
engineType: row.engine_type,
bodyType: row.body_type,
rawData: row.raw_data,
cachedAt: row.cached_at,
};
} catch (error) {
logger.error('Failed to check VIN cache', { vin, error });
return null;
}
}
/**
* Save VIN data to cache
*/
async saveToCache(vin: string, response: NHTSADecodeResponse): Promise<void> {
try {
const findValue = (variable: string): string | null => {
const result = response.Results.find(r => r.Variable === variable);
return result?.Value || null;
};
const year = findValue('Model Year');
const make = findValue('Make');
const model = findValue('Model');
const engineType = findValue('Engine Model');
const bodyType = findValue('Body Class');
await this.pool.query(
`INSERT INTO vin_cache (vin, make, model, year, engine_type, body_type, raw_data, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (vin) DO UPDATE SET
make = EXCLUDED.make,
model = EXCLUDED.model,
year = EXCLUDED.year,
engine_type = EXCLUDED.engine_type,
body_type = EXCLUDED.body_type,
raw_data = EXCLUDED.raw_data,
cached_at = NOW()`,
[vin, make, model, year ? parseInt(year) : null, engineType, bodyType, JSON.stringify(response)]
);
logger.debug('VIN cached', { vin });
} catch (error) {
logger.error('Failed to cache VIN data', { vin, error });
// Don't throw - caching failure shouldn't break the decode flow
}
}
/**
* Decode VIN using NHTSA vPIC API
* @param vin - 17-character VIN
* @returns Raw NHTSA decode response
* @throws Error if VIN is invalid or API call fails
*/
async decodeVin(vin: string): Promise<NHTSADecodeResponse> {
// Validate and sanitize VIN
const sanitizedVin = this.validateVin(vin);
// Check cache first
const cached = await this.getCached(sanitizedVin);
if (cached) {
logger.debug('VIN cache hit', { vin: sanitizedVin });
return cached.rawData;
}
// Call NHTSA API
logger.info('Calling NHTSA vPIC API', { vin: sanitizedVin });
try {
const response = await axios.get<NHTSADecodeResponse>(
`${this.baseURL}/vehicles/decodevin/${sanitizedVin}`,
{
params: { format: 'json' },
timeout: this.timeout,
}
);
// Check for NHTSA-level errors
if (response.data.Count === 0) {
throw new Error('NHTSA returned no results for this VIN');
}
// Check for error messages in results
const errorResult = response.data.Results.find(
r => r.Variable === 'Error Code' && r.Value && r.Value !== '0'
);
if (errorResult) {
const errorText = response.data.Results.find(r => r.Variable === 'Error Text');
throw new Error(`NHTSA error: ${errorText?.Value || 'Unknown error'}`);
}
// Cache the successful response
await this.saveToCache(sanitizedVin, response.data);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.code === 'ECONNABORTED') {
logger.error('NHTSA API timeout', { vin: sanitizedVin });
throw new Error('NHTSA API request timed out. Please try again.');
}
if (axiosError.response) {
logger.error('NHTSA API error response', {
vin: sanitizedVin,
status: axiosError.response.status,
data: axiosError.response.data,
});
throw new Error(`NHTSA API error: ${axiosError.response.status}`);
}
logger.error('NHTSA API network error', { vin: sanitizedVin, message: axiosError.message });
throw new Error('Unable to connect to NHTSA API. Please try again later.');
}
throw error;
}
}
/**
* Extract a specific value from NHTSA response
*/
static extractValue(response: NHTSADecodeResponse, variable: string): string | null {
const result = response.Results.find(r => r.Variable === variable);
return result?.Value?.trim() || null;
}
/**
* Extract year from NHTSA response
*/
static extractYear(response: NHTSADecodeResponse): number | null {
const value = NHTSAClient.extractValue(response, 'Model Year');
if (!value) return null;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? null : parsed;
}
/**
* Extract engine description from NHTSA response
* Combines multiple engine-related fields
*/
static extractEngine(response: NHTSADecodeResponse): string | null {
const engineModel = NHTSAClient.extractValue(response, 'Engine Model');
if (engineModel) return engineModel;
// Build engine description from components
const cylinders = NHTSAClient.extractValue(response, 'Engine Number of Cylinders');
const displacement = NHTSAClient.extractValue(response, 'Displacement (L)');
const fuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary');
const parts: string[] = [];
if (cylinders) parts.push(`${cylinders}-Cylinder`);
if (displacement) parts.push(`${displacement}L`);
if (fuelType && fuelType !== 'Gasoline') parts.push(fuelType);
return parts.length > 0 ? parts.join(' ') : null;
}
}

View File

@@ -1,96 +0,0 @@
/**
* @ai-summary Type definitions for NHTSA vPIC API
* @ai-context Defines request/response types for VIN decoding
*/
/**
* Individual result from NHTSA DecodeVin API
*/
export interface NHTSAResult {
Value: string | null;
ValueId: string | null;
Variable: string;
VariableId: number;
}
/**
* Raw response from NHTSA DecodeVin API
* GET https://vpic.nhtsa.dot.gov/api/vehicles/decodevin/{VIN}?format=json
*/
export interface NHTSADecodeResponse {
Count: number;
Message: string;
SearchCriteria: string;
Results: NHTSAResult[];
}
/**
* Confidence level for matched dropdown values
*/
export type MatchConfidence = 'high' | 'medium' | 'none';
/**
* Matched field with confidence indicator
*/
export interface MatchedField<T> {
value: T | null;
nhtsaValue: string | null;
confidence: MatchConfidence;
}
/**
* Decoded vehicle data with match confidence per field
* Maps NHTSA response fields to internal field names (camelCase)
*
* NHTSA Field Mappings:
* - ModelYear -> year
* - Make -> make
* - Model -> model
* - Trim -> trimLevel
* - BodyClass -> bodyType
* - DriveType -> driveType
* - FuelTypePrimary -> fuelType
* - EngineModel / EngineCylinders + EngineDisplacementL -> engine
* - TransmissionStyle -> transmission
*/
export interface DecodedVehicleData {
year: MatchedField<number>;
make: MatchedField<string>;
model: MatchedField<string>;
trimLevel: MatchedField<string>;
bodyType: MatchedField<string>;
driveType: MatchedField<string>;
fuelType: MatchedField<string>;
engine: MatchedField<string>;
transmission: MatchedField<string>;
}
/**
* Cached VIN data from vin_cache table
*/
export interface VinCacheEntry {
vin: string;
make: string | null;
model: string | null;
year: number | null;
engineType: string | null;
bodyType: string | null;
rawData: NHTSADecodeResponse;
cachedAt: Date;
}
/**
* VIN decode request body
*/
export interface DecodeVinRequest {
vin: string;
}
/**
* VIN decode error response
*/
export interface VinDecodeError {
error: 'INVALID_VIN' | 'VIN_DECODE_FAILED' | 'TIER_REQUIRED';
message: string;
details?: string;
}

View File

@@ -36,15 +36,13 @@ describe('Vehicles Integration Tests', () => {
afterAll(async () => { afterAll(async () => {
// Clean up test database // Clean up test database
await pool.query('DROP TABLE IF EXISTS vehicles CASCADE'); await pool.query('DROP TABLE IF EXISTS vehicles CASCADE');
await pool.query('DROP TABLE IF EXISTS vin_cache CASCADE');
await pool.query('DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE'); await pool.query('DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE');
await pool.end(); await pool.end();
}); });
beforeEach(async () => { beforeEach(async () => {
// Clean up test data before each test - more thorough cleanup // Clean up test data before each test
await pool.query('DELETE FROM vehicles WHERE user_id = $1', ['test-user-123']); await pool.query('DELETE FROM vehicles WHERE user_id = $1', ['test-user-123']);
await pool.query('DELETE FROM vin_cache WHERE vin LIKE $1', ['1HGBH41JXMN%']);
// Clear Redis cache for the test user // Clear Redis cache for the test user
try { try {

View File

@@ -22,11 +22,6 @@ platform:
url: http://mvp-platform-vehicles-api:8000 url: http://mvp-platform-vehicles-api:8000
timeout: 5s timeout: 5s
external:
vpic:
url: https://vpic.nhtsa.dot.gov/api/vehicles
timeout: 10s
service: service:
name: mvp-backend name: mvp-backend

View File

@@ -21,5 +21,3 @@ auth0:
domain: motovaultpro.us.auth0.com domain: motovaultpro.us.auth0.com
audience: https://api.motovaultpro.com audience: https://api.motovaultpro.com
external:
vpic_api_url: https://vpic.nhtsa.dot.gov/api/vehicles

View File

@@ -107,9 +107,6 @@ external_services:
google_maps: google_maps:
base_url: https://maps.googleapis.com/maps/api base_url: https://maps.googleapis.com/maps/api
vpic:
base_url: https://vpic.nhtsa.dot.gov/api/vehicles
# Development Configuration # Development Configuration
development: development:
debug_enabled: false debug_enabled: false

View File

@@ -11,6 +11,63 @@
# Shared services (from base compose): # Shared services (from base compose):
# mvp-traefik, mvp-postgres, mvp-redis # mvp-traefik, mvp-postgres, mvp-redis
# ========================================
# Extension fields (YAML anchors for DRY)
# ========================================
x-frontend-env: &frontend-env
VITE_API_BASE_URL: /api
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
x-frontend-volumes: &frontend-volumes
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
x-frontend-healthcheck: &frontend-healthcheck
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 5s
timeout: 5s
retries: 3
start_period: 10s
x-backend-env: &backend-env
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
STRIPE_PRO_MONTHLY_PRICE_ID: ${STRIPE_PRO_MONTHLY_PRICE_ID:-price_1T1ZHMJXoKkh5RcKwKSSGIlR}
STRIPE_PRO_YEARLY_PRICE_ID: ${STRIPE_PRO_YEARLY_PRICE_ID:-price_1T1ZHnJXoKkh5RcKWlG2MPpX}
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: ${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-price_1T1ZIBJXoKkh5RcKu2jyhqBN}
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: ${STRIPE_ENTERPRISE_YEARLY_PRICE_ID:-price_1T1ZIQJXoKkh5RcK34YXiJQm}
x-backend-volumes: &backend-volumes
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
x-backend-healthcheck: &backend-healthcheck
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 5s
timeout: 5s
retries: 5
start_period: 180s
services: services:
# ======================================== # ========================================
# BLUE Stack - Frontend # BLUE Stack - Frontend
@@ -19,25 +76,13 @@ services:
image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest} image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest}
container_name: mvp-frontend-blue container_name: mvp-frontend-blue
restart: unless-stopped restart: unless-stopped
environment: environment: *frontend-env
VITE_API_BASE_URL: /api volumes: *frontend-volumes
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
networks: networks:
- frontend - frontend
depends_on: depends_on:
- mvp-backend-blue - mvp-backend-blue
healthcheck: healthcheck: *frontend-healthcheck
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 5s
timeout: 5s
retries: 3
start_period: 10s
deploy: deploy:
resources: resources:
limits: limits:
@@ -55,50 +100,15 @@ services:
image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest} image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest}
container_name: mvp-backend-blue container_name: mvp-backend-blue
restart: unless-stopped restart: unless-stopped
environment: environment: *backend-env
NODE_ENV: production volumes: *backend-volumes
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
# Production Variables
#STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
#STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
#STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
#STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
# Sandbox Variables
STRIPE_PRO_MONTHLY_PRICE_ID: prod_TzGPZ13at4CGB5
STRIPE_PRO_YEARLY_PRICE_ID: prod_TzGODNOSOwWGFt
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_TzGO76gcuP9XI3
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_TzGM2nhayo7kc7
volumes:
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
networks: networks:
- backend - backend
- database - database
depends_on: depends_on:
- mvp-postgres - mvp-postgres
- mvp-redis - mvp-redis
healthcheck: healthcheck: *backend-healthcheck
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 5s
timeout: 5s
retries: 5
start_period: 180s
deploy: deploy:
resources: resources:
limits: limits:
@@ -116,25 +126,13 @@ services:
image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest} image: ${FRONTEND_IMAGE:-git.motovaultpro.com/egullickson/frontend:latest}
container_name: mvp-frontend-green container_name: mvp-frontend-green
restart: unless-stopped restart: unless-stopped
environment: environment: *frontend-env
VITE_API_BASE_URL: /api volumes: *frontend-volumes
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
networks: networks:
- frontend - frontend
depends_on: depends_on:
- mvp-backend-green - mvp-backend-green
healthcheck: healthcheck: *frontend-healthcheck
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 5s
timeout: 5s
retries: 3
start_period: 10s
deploy: deploy:
resources: resources:
limits: limits:
@@ -152,44 +150,15 @@ services:
image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest} image: ${BACKEND_IMAGE:-git.motovaultpro.com/egullickson/backend:latest}
container_name: mvp-backend-green container_name: mvp-backend-green
restart: unless-stopped restart: unless-stopped
environment: environment: *backend-env
NODE_ENV: production volumes: *backend-volumes
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
volumes:
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro
- ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
networks: networks:
- backend - backend
- database - database
depends_on: depends_on:
- mvp-postgres - mvp-postgres
- mvp-redis - mvp-redis
healthcheck: healthcheck: *backend-healthcheck
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 5s
timeout: 5s
retries: 5
start_period: 180s
deploy: deploy:
resources: resources:
limits: limits:

View File

@@ -6,18 +6,14 @@
# #
# This file removes development-only configurations: # This file removes development-only configurations:
# - Database port exposure (PostgreSQL, Redis) # - Database port exposure (PostgreSQL, Redis)
# - Development-specific settings # - Traefik dashboard auth middleware
#
# Environment-specific values (log levels, Stripe IDs) are driven by .env
# generated by CI/CD from Gitea variables + scripts/ci/generate-log-config.sh
services: services:
# Traefik - Production log level and dashboard auth # Traefik - Dashboard auth middleware
mvp-traefik: mvp-traefik:
environment:
# Traefik log levels: TRACE | DEBUG | INFO | WARN | ERROR
LOG_LEVEL: error
command:
- --configFile=/etc/traefik/traefik.yml
# Traefik log levels: TRACE | DEBUG | INFO | WARN | ERROR
- --log.level=ERROR
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.motovaultpro.local`)" - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.motovaultpro.local`)"
@@ -26,64 +22,10 @@ services:
- "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080" - "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080"
- "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$foobar" - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$foobar"
# Backend - Production log level # PostgreSQL - Remove dev ports
mvp-backend:
environment:
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
# Pino log levels: trace | debug | info | warn | error | fatal
LOG_LEVEL: error
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
# Production Variables
#STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl
#STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB
#STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j
#STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn
# Sandbox Variables
STRIPE_PRO_MONTHLY_PRICE_ID: prod_TzGPZ13at4CGB5
STRIPE_PRO_YEARLY_PRICE_ID: prod_TzGODNOSOwWGFt
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_TzGO76gcuP9XI3
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_TzGM2nhayo7kc7
# OCR - Production log level + engine config
mvp-ocr:
environment:
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL: error
REDIS_HOST: mvp-redis
REDIS_PORT: 6379
REDIS_DB: 1
# OCR engine configuration (Google Vision primary, PaddleOCR fallback)
OCR_PRIMARY_ENGINE: google_vision
OCR_FALLBACK_ENGINE: paddleocr
OCR_CONFIDENCE_THRESHOLD: "0.6"
OCR_FALLBACK_THRESHOLD: "0.6"
GOOGLE_VISION_KEY_PATH: /run/secrets/google-wif-config.json
VISION_MONTHLY_LIMIT: "1000"
# Vertex AI / Gemini configuration (maintenance schedule extraction)
VERTEX_AI_PROJECT: motovaultpro
VERTEX_AI_LOCATION: us-central1
GEMINI_MODEL: gemini-2.5-flash
# PostgreSQL - Remove dev ports, production log level
mvp-postgres: mvp-postgres:
ports: [] ports: []
environment:
POSTGRES_DB: motovaultpro
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_INITDB_ARGS: --encoding=UTF8
LOG_LEVEL: error
# PostgreSQL log statements: none | ddl | mod | all
POSTGRES_LOG_STATEMENT: none
# Minimum query duration to log: -1 (disabled) | 0 (all) | N (ms threshold)
POSTGRES_LOG_MIN_DURATION_STATEMENT: -1
PGDATA: /var/lib/postgresql/data/pgdata
# Redis - Remove dev ports, production log level # Redis - Remove dev ports
mvp-redis: mvp-redis:
ports: [] ports: []
# Redis log levels: debug | verbose | notice | warning
command: redis-server --appendonly yes --loglevel warning

View File

@@ -63,27 +63,6 @@ services:
mvp-ocr: mvp-ocr:
image: ${OCR_IMAGE:-git.motovaultpro.com/egullickson/ocr:latest} image: ${OCR_IMAGE:-git.motovaultpro.com/egullickson/ocr:latest}
container_name: mvp-ocr-staging container_name: mvp-ocr-staging
environment:
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL: debug
REDIS_HOST: mvp-redis
REDIS_PORT: 6379
REDIS_DB: 1
# OCR engine configuration (Google Vision primary, PaddleOCR fallback)
OCR_PRIMARY_ENGINE: google_vision
OCR_FALLBACK_ENGINE: paddleocr
OCR_CONFIDENCE_THRESHOLD: "0.6"
OCR_FALLBACK_THRESHOLD: "0.6"
GOOGLE_VISION_KEY_PATH: /run/secrets/google-wif-config.json
VISION_MONTHLY_LIMIT: "1000"
# Vertex AI / Gemini configuration (maintenance schedule extraction)
VERTEX_AI_PROJECT: motovaultpro
VERTEX_AI_LOCATION: us-central1
GEMINI_MODEL: gemini-2.5-flash
volumes:
- ./secrets/app/auth0-ocr-client-id.txt:/run/secrets/auth0-ocr-client-id:ro
- ./secrets/app/auth0-ocr-client-secret.txt:/run/secrets/auth0-ocr-client-secret:ro
- ./secrets/app/google-wif-config.json:/run/secrets/google-wif-config.json:ro
# ======================================== # ========================================
# PostgreSQL (Staging - Separate Database) # PostgreSQL (Staging - Separate Database)

View File

@@ -11,8 +11,9 @@ services:
command: command:
- --configFile=/etc/traefik/traefik.yml - --configFile=/etc/traefik/traefik.yml
environment: environment:
# Traefik log levels: TRACE | DEBUG | INFO | WARN | ERROR # Traefik natively reads TRAEFIK_LOG_LEVEL (maps to --log.level)
LOG_LEVEL: debug # Levels: TRACE | DEBUG | INFO | WARN | ERROR
TRAEFIK_LOG_LEVEL: ${TRAEFIK_LOG_LEVEL:-DEBUG}
CLOUDFLARE_DNS_API_TOKEN_FILE: /run/secrets/cloudflare-dns-token CLOUDFLARE_DNS_API_TOKEN_FILE: /run/secrets/cloudflare-dns-token
ports: ports:
- "80:80" - "80:80"
@@ -60,7 +61,7 @@ services:
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
VITE_STRIPE_PUBLISHABLE_KEY: ${VITE_STRIPE_PUBLISHABLE_KEY:-pk_live_51Sr2yQJk87CpWj04YNBIaUWUtnJjeVTgk5NqHdpjqxgsbjy3dMKkIsqhjcpSkCzp3KvLi23BGgxhwV021EnEW3H400HhPYVyfN} VITE_STRIPE_PUBLISHABLE_KEY: ${VITE_STRIPE_PUBLISHABLE_KEY}
container_name: mvp-frontend container_name: mvp-frontend
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -115,15 +116,15 @@ services:
CONFIG_PATH: /app/config/production.yml CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets SECRETS_DIR: /run/secrets
# Pino log levels: trace | debug | info | warn | error | fatal # Pino log levels: trace | debug | info | warn | error | fatal
LOG_LEVEL: debug LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
# Service references # Service references
DATABASE_HOST: mvp-postgres DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis REDIS_HOST: mvp-redis
#Stripe Variables # Stripe Price IDs (override via .env for staging/production)
STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl STRIPE_PRO_MONTHLY_PRICE_ID: ${STRIPE_PRO_MONTHLY_PRICE_ID:-price_1T1ZHMJXoKkh5RcKwKSSGIlR}
STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB STRIPE_PRO_YEARLY_PRICE_ID: ${STRIPE_PRO_YEARLY_PRICE_ID:-price_1T1ZHnJXoKkh5RcKWlG2MPpX}
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: ${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-price_1T1ZIBJXoKkh5RcKu2jyhqBN}
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn STRIPE_ENTERPRISE_YEARLY_PRICE_ID: ${STRIPE_ENTERPRISE_YEARLY_PRICE_ID:-price_1T1ZIQJXoKkh5RcK34YXiJQm}
volumes: volumes:
# Configuration files (K8s ConfigMap equivalent) # Configuration files (K8s ConfigMap equivalent)
- ./config/app/production.yml:/app/config/production.yml:ro - ./config/app/production.yml:/app/config/production.yml:ro
@@ -192,7 +193,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
# Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL # Python log levels: DEBUG | INFO | WARNING | ERROR | CRITICAL
LOG_LEVEL: debug LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
REDIS_HOST: mvp-redis REDIS_HOST: mvp-redis
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_DB: 1 REDIS_DB: 1
@@ -205,8 +206,8 @@ services:
VISION_MONTHLY_LIMIT: "1000" VISION_MONTHLY_LIMIT: "1000"
# Vertex AI / Gemini configuration (maintenance schedule extraction) # Vertex AI / Gemini configuration (maintenance schedule extraction)
VERTEX_AI_PROJECT: motovaultpro VERTEX_AI_PROJECT: motovaultpro
VERTEX_AI_LOCATION: us-central1 VERTEX_AI_LOCATION: global
GEMINI_MODEL: gemini-2.5-flash GEMINI_MODEL: gemini-3-flash-preview
volumes: volumes:
- /tmp/vin-debug:/tmp/vin-debug - /tmp/vin-debug:/tmp/vin-debug
- ./secrets/app/auth0-ocr-client-id.txt:/run/secrets/auth0-ocr-client-id:ro - ./secrets/app/auth0-ocr-client-id.txt:/run/secrets/auth0-ocr-client-id:ro
@@ -239,11 +240,11 @@ services:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_INITDB_ARGS: --encoding=UTF8 POSTGRES_INITDB_ARGS: --encoding=UTF8
LOG_LEVEL: debug LOG_LEVEL: ${BACKEND_LOG_LEVEL:-debug}
# PostgreSQL log statements: none | ddl | mod | all # PostgreSQL log statements: none | ddl | mod | all
POSTGRES_LOG_STATEMENT: all POSTGRES_LOG_STATEMENT: ${POSTGRES_LOG_STATEMENT:-all}
# Minimum query duration to log: -1 (disabled) | 0 (all) | N (ms threshold) # Minimum query duration to log: -1 (disabled) | 0 (all) | N (ms threshold)
POSTGRES_LOG_MIN_DURATION_STATEMENT: 0 POSTGRES_LOG_MIN_DURATION_STATEMENT: ${POSTGRES_LOG_MIN_DURATION:-0}
PGDATA: /var/lib/postgresql/data/pgdata PGDATA: /var/lib/postgresql/data/pgdata
volumes: volumes:
- mvp_postgres_data:/var/lib/postgresql/data/pgdata - mvp_postgres_data:/var/lib/postgresql/data/pgdata
@@ -271,7 +272,7 @@ services:
container_name: mvp-redis container_name: mvp-redis
restart: unless-stopped restart: unless-stopped
# Redis log levels: debug | verbose | notice | warning # Redis log levels: debug | verbose | notice | warning
command: redis-server --appendonly yes --loglevel debug command: redis-server --appendonly yes --loglevel ${REDIS_LOGLEVEL:-debug}
volumes: volumes:
- mvp_redis_data:/data - mvp_redis_data:/data
networks: networks:

View File

@@ -35,7 +35,7 @@ The platform provides vehicle hierarchical data lookups:
VIN decoding is planned but not yet implemented. Future capabilities will include: VIN decoding is planned but not yet implemented. Future capabilities will include:
- `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details - `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details
- PostgreSQL-based VIN decode function - PostgreSQL-based VIN decode function
- NHTSA vPIC API fallback with circuit breaker - Gemini VIN decode via OCR service
- Redis caching (7-day TTL for successful decodes) - Redis caching (7-day TTL for successful decodes)
**Data Source**: Vehicle data from standardized sources **Data Source**: Vehicle data from standardized sources

View File

@@ -74,7 +74,7 @@ docker compose exec mvp-frontend npm test -- --coverage
Example: `vehicles.service.test.ts` Example: `vehicles.service.test.ts`
- Tests VIN validation logic - Tests VIN validation logic
- Tests vehicle creation with mocked vPIC responses - Tests vehicle creation with mocked OCR service responses
- Tests caching behavior with mocked Redis - Tests caching behavior with mocked Redis
- Tests error handling paths - Tests error handling paths
@@ -194,7 +194,7 @@ All 15 features have test suites with unit and/or integration tests:
- `vehicles` - Unit + integration tests - `vehicles` - Unit + integration tests
### Mock Strategy ### Mock Strategy
- **External APIs**: Completely mocked (vPIC, Google Maps) - **External APIs**: Completely mocked (OCR service, Google Maps)
- **Database**: Real database with transactions - **Database**: Real database with transactions
- **Redis**: Mocked for unit tests, real for integration - **Redis**: Mocked for unit tests, real for integration
- **Auth**: Mocked JWT tokens for protected endpoints - **Auth**: Mocked JWT tokens for protected endpoints
@@ -319,8 +319,8 @@ describe('Error Handling', () => {
).rejects.toThrow('Invalid VIN format'); ).rejects.toThrow('Invalid VIN format');
}); });
it('should handle vPIC API failure', async () => { it('should handle OCR service failure', async () => {
mockVpicClient.decode.mockRejectedValue(new Error('API down')); mockOcrClient.decodeVin.mockRejectedValue(new Error('API down'));
const result = await vehicleService.create(validVehicle, 'user123'); const result = await vehicleService.create(validVehicle, 'user123');
expect(result.make).toBeNull(); // Graceful degradation expect(result.make).toBeNull(); // Graceful degradation

View File

@@ -644,7 +644,7 @@ When you attempt to use a Pro feature on the Free tier, an **Upgrade Required**
### VIN Camera Scanning and Decode (Pro) ### VIN Camera Scanning and Decode (Pro)
**What it does:** Use your device camera to photograph your vehicle's VIN plate, and the system automatically reads the VIN using OCR (Optical Character Recognition) and decodes it from the NHTSA database. **What it does:** Use your device camera to photograph your vehicle's VIN plate, and the system automatically reads the VIN using OCR (Optical Character Recognition) and decodes it from the vehicle database.
**How to use it:** **How to use it:**
@@ -655,7 +655,7 @@ When you attempt to use a Pro feature on the Free tier, an **Upgrade Required**
5. A **VIN OCR Review modal** appears showing the detected VIN with confidence indicators 5. A **VIN OCR Review modal** appears showing the detected VIN with confidence indicators
6. Confirm or correct the VIN, then click **Accept** 6. Confirm or correct the VIN, then click **Accept**
7. Click the **Decode VIN** button 7. Click the **Decode VIN** button
8. The system queries the NHTSA database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim 8. The system queries the vehicle database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim
9. Review the pre-filled fields and complete the remaining details 9. Review the pre-filled fields and complete the remaining details
This eliminates manual data entry errors and ensures accurate vehicle specifications. This eliminates manual data entry errors and ensures accurate vehicle specifications.

View File

@@ -1,21 +1,31 @@
{ {
"testModules": [ "testModules": [
{ {
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx", "moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/admin/__tests__/useAdmins.test.tsx",
"tests": [ "tests": [
{ {
"name": "renders both buttons with correct text", "name": "should fetch admin users",
"fullName": "ActionBar renders both buttons with correct text", "fullName": "Admin user management hooks useAdmins should fetch admin users",
"state": "passed" "state": "passed"
}, },
{ {
"name": "calls onAddVehicle when Add Vehicle button clicked", "name": "should create admin and show success toast",
"fullName": "ActionBar calls onAddVehicle when Add Vehicle button clicked", "fullName": "Admin user management hooks useCreateAdmin should create admin and show success toast",
"state": "passed" "state": "passed"
}, },
{ {
"name": "calls onLogFuel when Add Fuel Log button clicked", "name": "should handle create admin error",
"fullName": "ActionBar calls onLogFuel when Add Fuel Log button clicked", "fullName": "Admin user management hooks useCreateAdmin should handle create admin error",
"state": "passed"
},
{
"name": "should revoke admin and show success toast",
"fullName": "Admin user management hooks useRevokeAdmin should revoke admin and show success toast",
"state": "passed"
},
{
"name": "should reinstate admin and show success toast",
"fullName": "Admin user management hooks useReinstateAdmin should reinstate admin and show success toast",
"state": "passed" "state": "passed"
} }
] ]

View File

@@ -82,11 +82,13 @@ export const vehiclesApi = {
}, },
/** /**
* Decode VIN using NHTSA vPIC API * Decode VIN using VIN decode service
* Requires Pro or Enterprise tier * Requires Pro or Enterprise tier
*/ */
decodeVin: async (vin: string): Promise<DecodedVehicleData> => { decodeVin: async (vin: string): Promise<DecodedVehicleData> => {
const response = await apiClient.post('/vehicles/decode-vin', { vin }); const response = await apiClient.post('/vehicles/decode-vin', { vin }, {
timeout: 120000 // 120 seconds for Gemini + Google Search grounding
});
return response.data; return response.data;
} }
}; };

View File

@@ -114,6 +114,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const [isDecoding, setIsDecoding] = useState(false); const [isDecoding, setIsDecoding] = useState(false);
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
const [decodeError, setDecodeError] = useState<string | null>(null); const [decodeError, setDecodeError] = useState<string | null>(null);
const [decodeHint, setDecodeHint] = useState<string | null>(null);
// VIN OCR capture hook // VIN OCR capture hook
const vinOcr = useVinOcr(); const vinOcr = useVinOcr();
@@ -507,7 +508,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
/** /**
* Handle VIN decode button click * Handle VIN decode button click
* Calls NHTSA API and populates empty form fields * Calls VIN decode service and populates empty form fields
*/ */
const handleDecodeVin = async () => { const handleDecodeVin = async () => {
// Check tier access first // Check tier access first
@@ -524,6 +525,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
setIsDecoding(true); setIsDecoding(true);
setDecodeError(null); setDecodeError(null);
setDecodeHint(null);
try { try {
const decoded = await vehiclesApi.decodeVin(vin); const decoded = await vehiclesApi.decodeVin(vin);
@@ -588,6 +590,21 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
setValue('transmission', decoded.transmission.value); setValue('transmission', decoded.transmission.value);
} }
// Check if decode returned data but matching failed for key fields
const hasMatchedValue = decoded.year.value || decoded.make.value || decoded.model.value;
const hasSourceValue = decoded.year.sourceValue || decoded.make.sourceValue || decoded.model.sourceValue;
if (!hasMatchedValue && hasSourceValue) {
const parts = [
decoded.year.sourceValue,
decoded.make.sourceValue,
decoded.model.sourceValue,
decoded.trimLevel.sourceValue
].filter(Boolean);
setDecodeHint(
`Could not match VIN data to dropdowns. Decoded as: ${parts.join(' ')}. Please select values manually.`
);
}
setLoadingDropdowns(false); setLoadingDropdowns(false);
isVinDecoding.current = false; isVinDecoding.current = false;
} catch (error: any) { } catch (error: any) {
@@ -671,6 +688,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{decodeError && ( {decodeError && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p> <p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p>
)} )}
{decodeHint && (
<p className="mt-1 text-sm text-amber-600 dark:text-amber-400">{decodeHint}</p>
)}
{vinOcr.error && ( {vinOcr.error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{vinOcr.error}</p> <p className="mt-1 text-sm text-red-600 dark:text-red-400">{vinOcr.error}</p>
)} )}

View File

@@ -95,8 +95,8 @@ const ReviewContent: React.FC<{
const [selectedEngine, setSelectedEngine] = useState(''); const [selectedEngine, setSelectedEngine] = useState('');
const [selectedTransmission, setSelectedTransmission] = useState(''); const [selectedTransmission, setSelectedTransmission] = useState('');
// NHTSA reference values for unmatched fields // Source reference values for unmatched fields
const [nhtsaRefs, setNhtsaRefs] = useState<Record<string, string | null>>({}); const [sourceRefs, setSourceRefs] = useState<Record<string, string | null>>({});
// Initialize dropdown options and pre-select decoded values // Initialize dropdown options and pre-select decoded values
useEffect(() => { useEffect(() => {
@@ -109,13 +109,13 @@ const ReviewContent: React.FC<{
if (!decodedVehicle) return; if (!decodedVehicle) return;
// Store NHTSA reference values for unmatched fields // Store source reference values for unmatched fields
setNhtsaRefs({ setSourceRefs({
make: decodedVehicle.make.confidence === 'none' ? decodedVehicle.make.nhtsaValue : null, make: decodedVehicle.make.confidence === 'none' ? decodedVehicle.make.sourceValue : null,
model: decodedVehicle.model.confidence === 'none' ? decodedVehicle.model.nhtsaValue : null, model: decodedVehicle.model.confidence === 'none' ? decodedVehicle.model.sourceValue : null,
trim: decodedVehicle.trimLevel.confidence === 'none' ? decodedVehicle.trimLevel.nhtsaValue : null, trim: decodedVehicle.trimLevel.confidence === 'none' ? decodedVehicle.trimLevel.sourceValue : null,
engine: decodedVehicle.engine.confidence === 'none' ? decodedVehicle.engine.nhtsaValue : null, engine: decodedVehicle.engine.confidence === 'none' ? decodedVehicle.engine.sourceValue : null,
transmission: decodedVehicle.transmission.confidence === 'none' ? decodedVehicle.transmission.nhtsaValue : null, transmission: decodedVehicle.transmission.confidence === 'none' ? decodedVehicle.transmission.sourceValue : null,
}); });
const yearValue = decodedVehicle.year.value; const yearValue = decodedVehicle.year.value;
@@ -277,9 +277,9 @@ const ReviewContent: React.FC<{
}); });
}; };
/** Show NHTSA reference when field had no dropdown match */ /** Show source reference when field had no dropdown match */
const nhtsaHint = (field: string) => { const sourceHint = (field: string) => {
const ref = nhtsaRefs[field]; const ref = sourceRefs[field];
if (!ref) return null; if (!ref) return null;
// Only show hint when no value is currently selected // Only show hint when no value is currently selected
const selected: Record<string, string> = { const selected: Record<string, string> = {
@@ -292,7 +292,7 @@ const ReviewContent: React.FC<{
if (selected[field]) return null; if (selected[field]) return null;
return ( return (
<p className="mt-1 text-xs text-gray-500 dark:text-titanio"> <p className="mt-1 text-xs text-gray-500 dark:text-titanio">
NHTSA returned: {ref} Decoded value: {ref}
</p> </p>
); );
}; };
@@ -409,7 +409,7 @@ const ReviewContent: React.FC<{
</option> </option>
))} ))}
</select> </select>
{nhtsaHint('make')} {sourceHint('make')}
</div> </div>
{/* Model */} {/* Model */}
@@ -439,7 +439,7 @@ const ReviewContent: React.FC<{
</option> </option>
))} ))}
</select> </select>
{nhtsaHint('model')} {sourceHint('model')}
</div> </div>
{/* Trim */} {/* Trim */}
@@ -469,7 +469,7 @@ const ReviewContent: React.FC<{
</option> </option>
))} ))}
</select> </select>
{nhtsaHint('trim')} {sourceHint('trim')}
</div> </div>
{/* Engine */} {/* Engine */}
@@ -499,7 +499,7 @@ const ReviewContent: React.FC<{
</option> </option>
))} ))}
</select> </select>
{nhtsaHint('engine')} {sourceHint('engine')}
</div> </div>
{/* Transmission */} {/* Transmission */}
@@ -529,7 +529,7 @@ const ReviewContent: React.FC<{
</option> </option>
))} ))}
</select> </select>
{nhtsaHint('transmission')} {sourceHint('transmission')}
</div> </div>
</div> </div>
</Box> </Box>

View File

@@ -1,5 +1,5 @@
/** /**
* @ai-summary Hook to orchestrate VIN OCR extraction and NHTSA decode * @ai-summary Hook to orchestrate VIN OCR extraction and VIN decode
* @ai-context Handles camera capture -> OCR extraction -> VIN decode flow * @ai-context Handles camera capture -> OCR extraction -> VIN decode flow
*/ */
@@ -109,7 +109,7 @@ export function useVinOcr(): UseVinOcrReturn {
); );
} }
// Step 2: Decode VIN using NHTSA // Step 2: Decode VIN
setProcessingStep('decoding'); setProcessingStep('decoding');
let decodedVehicle: DecodedVehicleData | null = null; let decodedVehicle: DecodedVehicleData | null = null;
let decodeError: string | null = null; let decodeError: string | null = null;
@@ -121,7 +121,7 @@ export function useVinOcr(): UseVinOcrReturn {
if (err.response?.data?.error === 'TIER_REQUIRED') { if (err.response?.data?.error === 'TIER_REQUIRED') {
decodeError = 'VIN decode requires Pro or Enterprise subscription'; decodeError = 'VIN decode requires Pro or Enterprise subscription';
} else if (err.response?.data?.error === 'INVALID_VIN') { } else if (err.response?.data?.error === 'INVALID_VIN') {
decodeError = 'VIN format is not recognized by NHTSA'; decodeError = 'VIN format is not recognized';
} else { } else {
decodeError = 'Unable to decode vehicle information'; decodeError = 'Unable to decode vehicle information';
} }

View File

@@ -72,12 +72,12 @@ export type MatchConfidence = 'high' | 'medium' | 'none';
*/ */
export interface MatchedField<T> { export interface MatchedField<T> {
value: T | null; value: T | null;
nhtsaValue: string | null; sourceValue: string | null;
confidence: MatchConfidence; confidence: MatchConfidence;
} }
/** /**
* Decoded vehicle data from NHTSA vPIC API * Decoded vehicle data from VIN decode
* with match confidence per field * with match confidence per field
*/ */
export interface DecodedVehicleData { export interface DecodedVehicleData {

View File

@@ -43,7 +43,7 @@ export const SubscriptionSection = () => {
</h3> </h3>
<p className="text-titanio/70 leading-relaxed mb-4"> <p className="text-titanio/70 leading-relaxed mb-4">
<strong className="text-avus">What it does:</strong> Use your device camera to photograph your vehicle's VIN plate, and the system automatically reads the VIN using OCR (Optical Character Recognition) and decodes it from the NHTSA database. <strong className="text-avus">What it does:</strong> Use your device camera to photograph your vehicle's VIN plate, and the system automatically reads the VIN using OCR (Optical Character Recognition) and decodes it from the vehicle database.
</p> </p>
<p className="text-titanio/70 leading-relaxed mb-4"> <p className="text-titanio/70 leading-relaxed mb-4">
@@ -58,7 +58,7 @@ export const SubscriptionSection = () => {
<li>A <strong className="text-avus">VIN OCR Review modal</strong> appears showing the detected VIN with confidence indicators</li> <li>A <strong className="text-avus">VIN OCR Review modal</strong> appears showing the detected VIN with confidence indicators</li>
<li>Confirm or correct the VIN, then click <strong className="text-avus">Accept</strong></li> <li>Confirm or correct the VIN, then click <strong className="text-avus">Accept</strong></li>
<li>Click the <strong className="text-avus">Decode VIN</strong> button</li> <li>Click the <strong className="text-avus">Decode VIN</strong> button</li>
<li>The system queries the NHTSA database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim</li> <li>The system queries the vehicle database and auto-populates: Year, Make, Model, Engine, Transmission, and Trim</li>
<li>Review the pre-filled fields and complete the remaining details</li> <li>Review the pre-filled fields and complete the remaining details</li>
</ol> </ol>

View File

@@ -141,7 +141,7 @@ export const VehiclesSection = () => {
<GuideScreenshot <GuideScreenshot
src="/guide/vin-decode-desktop.png" src="/guide/vin-decode-desktop.png"
alt="VIN Decode feature showing auto-populated vehicle specifications" alt="VIN Decode feature showing auto-populated vehicle specifications"
caption="The VIN Decode feature automatically fills in vehicle details from the NHTSA database" caption="The VIN Decode feature automatically fills in vehicle details from the vehicle database"
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
# ocr/ # ocr/
Python OCR microservice. Primary engine: PaddleOCR PP-OCRv4 with optional Google Vision cloud fallback. Gemini 2.5 Flash for maintenance manual PDF extraction. Pluggable engine abstraction in `app/engines/`. Python OCR microservice. Primary engine: PaddleOCR PP-OCRv4 with optional Google Vision cloud fallback. Gemini 2.5 Flash for maintenance manual PDF extraction and VIN decode. Pluggable engine abstraction in `app/engines/`.
## Files ## Files

View File

@@ -7,7 +7,7 @@ Python OCR microservice (FastAPI). Primary engine: PaddleOCR PP-OCRv4 with optio
| File | What | When to read | | File | What | When to read |
| ---- | ---- | ------------ | | ---- | ---- | ------------ |
| `main.py` | FastAPI application entry point | Route registration, app setup | | `main.py` | FastAPI application entry point | Route registration, app setup |
| `config.py` | Configuration settings (OCR engines, Vertex AI, Redis, Vision API limits) | Environment variables, settings | | `config.py` | Configuration settings (OCR engines, Google GenAI, Redis, Vision API limits) | Environment variables, settings |
| `__init__.py` | Package init | Package structure | | `__init__.py` | Package init | Package structure |
## Subdirectories ## Subdirectories
@@ -19,7 +19,7 @@ Python OCR microservice (FastAPI). Primary engine: PaddleOCR PP-OCRv4 with optio
| `models/` | Data models and schemas | Request/response types | | `models/` | Data models and schemas | Request/response types |
| `patterns/` | Regex patterns and service name mapping (27 maintenance subtypes) | Pattern matching rules, service categorization | | `patterns/` | Regex patterns and service name mapping (27 maintenance subtypes) | Pattern matching rules, service categorization |
| `preprocessors/` | Image preprocessing pipeline | Image preparation before OCR | | `preprocessors/` | Image preprocessing pipeline | Image preparation before OCR |
| `routers/` | FastAPI route handlers (/extract, /extract/receipt, /extract/manual, /jobs) | API endpoint changes | | `routers/` | FastAPI route handlers (/extract, /extract/receipt, /extract/manual, /decode, /jobs) | API endpoint changes |
| `services/` | Business logic services (job queue with Redis) | Core OCR processing, async job management | | `services/` | Business logic services (job queue with Redis) | Core OCR processing, async job management |
| `table_extraction/` | Table detection and parsing | Structured data extraction from images | | `table_extraction/` | Table detection and parsing | Structured data extraction from images |
| `validators/` | Input validation | Validation rules | | `validators/` | Input validation | Validation rules |

View File

@@ -29,10 +29,10 @@ class Settings:
os.getenv("VISION_MONTHLY_LIMIT", "1000") os.getenv("VISION_MONTHLY_LIMIT", "1000")
) )
# Vertex AI / Gemini configuration # Google GenAI / Gemini configuration
self.vertex_ai_project: str = os.getenv("VERTEX_AI_PROJECT", "") self.vertex_ai_project: str = os.getenv("VERTEX_AI_PROJECT", "")
self.vertex_ai_location: str = os.getenv( self.vertex_ai_location: str = os.getenv(
"VERTEX_AI_LOCATION", "us-central1" "VERTEX_AI_LOCATION", "global"
) )
self.gemini_model: str = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") self.gemini_model: str = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")

View File

@@ -3,7 +3,7 @@
OCR engine abstraction layer. Two categories of engines: OCR engine abstraction layer. Two categories of engines:
1. **OcrEngine subclasses** (image-to-text): PaddleOCR, Google Vision, Hybrid. Accept image bytes, return text + confidence + word boxes. 1. **OcrEngine subclasses** (image-to-text): PaddleOCR, Google Vision, Hybrid. Accept image bytes, return text + confidence + word boxes.
2. **GeminiEngine** (PDF-to-structured-data): Standalone module for maintenance schedule extraction via Vertex AI. Accepts PDF bytes, returns structured JSON. Not an OcrEngine subclass because the interface signatures differ. 2. **GeminiEngine** (PDF-to-structured-data and VIN decode): Standalone module for maintenance schedule extraction and VIN decoding via google-genai SDK. Accepts PDF bytes or VIN strings, returns structured JSON. Not an OcrEngine subclass because the interface signatures differ.
## Files ## Files
@@ -15,7 +15,7 @@ OCR engine abstraction layer. Two categories of engines:
| `cloud_engine.py` | Google Vision TEXT_DETECTION fallback engine (WIF authentication) | Cloud OCR configuration, API quota | | `cloud_engine.py` | Google Vision TEXT_DETECTION fallback engine (WIF authentication) | Cloud OCR configuration, API quota |
| `hybrid_engine.py` | Combines primary + fallback engine with confidence threshold switching | Engine selection logic, fallback behavior | | `hybrid_engine.py` | Combines primary + fallback engine with confidence threshold switching | Engine selection logic, fallback behavior |
| `engine_factory.py` | Factory function and engine registry for instantiation | Adding new engine types | | `engine_factory.py` | Factory function and engine registry for instantiation | Adding new engine types |
| `gemini_engine.py` | Gemini 2.5 Flash integration for maintenance schedule extraction (Vertex AI SDK, 20MB PDF limit, structured JSON output) | Manual extraction debugging, Gemini configuration | | `gemini_engine.py` | Gemini 2.5 Flash integration for maintenance schedule extraction and VIN decoding (google-genai SDK, 20MB PDF limit, structured JSON output, Google Search grounding for VIN decode) | Manual extraction debugging, VIN decode, Gemini configuration |
## Engine Selection ## Engine Selection
@@ -30,4 +30,4 @@ create_engine(config)
HybridEngine (tries primary, falls back if confidence < threshold) HybridEngine (tries primary, falls back if confidence < threshold)
``` ```
GeminiEngine is created independently by ManualExtractor, not through the engine factory. GeminiEngine is created independently by ManualExtractor and the VIN decode router, not through the engine factory.

View File

@@ -1,14 +1,15 @@
"""Gemini 2.5 Flash engine for maintenance schedule extraction from PDFs. """Gemini 2.5 Flash engine for document understanding and VIN decode.
Standalone module (does NOT extend OcrEngine) because Gemini performs Standalone module (does NOT extend OcrEngine) because Gemini performs
semantic document understanding, not traditional OCR word-box extraction. semantic document understanding, not traditional OCR word-box extraction.
Uses Vertex AI SDK with structured JSON output enforcement. Uses google-genai SDK with structured JSON output enforcement.
""" """
import json import json
import logging import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Any from typing import Any
from app.config import settings from app.config import settings
@@ -37,18 +38,114 @@ Do not include one-time procedures, troubleshooting steps, or warranty informati
Return the results as a JSON object with a single "maintenanceSchedule" array.\ Return the results as a JSON object with a single "maintenanceSchedule" array.\
""" """
# VIN year code lookup: position 10 character -> base year (first cycle, 1980-2009).
# The 30-year cycle repeats: +30 for 2010-2039, +60 for 2040-2069.
# Disambiguation uses position 7: alphabetic -> 2010+ cycle, numeric -> 1980s cycle.
# Per NHTSA FMVSS No. 115: MY2010+ vehicles must use alphabetic position 7.
# For the 2040+ cycle (when position 7 is numeric again), we pick the most
# recent plausible year (not more than 2 years in the future).
_VIN_YEAR_CODES: dict[str, int] = {
"A": 1980, "B": 1981, "C": 1982, "D": 1983, "E": 1984,
"F": 1985, "G": 1986, "H": 1987, "J": 1988, "K": 1989,
"L": 1990, "M": 1991, "N": 1992, "P": 1993, "R": 1994,
"S": 1995, "T": 1996, "V": 1997, "W": 1998, "X": 1999,
"Y": 2000,
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
}
def resolve_vin_year(vin: str) -> int | None:
"""Deterministically resolve model year from VIN positions 7 and 10.
VIN year codes repeat on a 30-year cycle. Position 7 disambiguates:
- Alphabetic position 7 -> 2010-2039 cycle (NHTSA MY2010+ requirement)
- Numeric position 7 -> 1980-2009 or 2040-2069 cycle
For the numeric case with two possible cycles, picks the most recent
year that is not more than 2 years in the future.
Returns None if the VIN is too short or position 10 is not a valid year code.
"""
if len(vin) < 17:
return None
code = vin[9].upper() # position 10 (0-indexed)
pos7 = vin[6].upper() # position 7 (0-indexed)
base_year = _VIN_YEAR_CODES.get(code)
if base_year is None:
return None
if pos7.isalpha():
# Alphabetic position 7 -> second cycle (2010-2039)
return base_year + 30
# Numeric position 7 -> first cycle (1980-2009) or third cycle (2040-2069)
# Pick the most recent plausible year
max_plausible = datetime.now().year + 2
third_cycle = base_year + 60 # 2040-2069
if third_cycle <= max_plausible:
return third_cycle
return base_year
_VIN_DECODE_PROMPT = """\
Decode the following VIN (Vehicle Identification Number) using standard VIN structure rules.
VIN: {vin}
Model year: {year} (determined from position 10 code '{year_code}')
The model year has already been resolved deterministically. Use {year} as the year.
VIN position reference:
- Positions 1-3 (WMI): World Manufacturer Identifier (country + manufacturer)
- Positions 4-8 (VDS): Vehicle attributes (model, body, engine, etc.)
- Position 9: Check digit
- Position 10: Model year code (30-year cycle, extended through 2050):
A=1980/2010/2040 B=1981/2011/2041 C=1982/2012/2042 D=1983/2013/2043 E=1984/2014/2044
F=1985/2015/2045 G=1986/2016/2046 H=1987/2017/2047 J=1988/2018/2048 K=1989/2019/2049
L=1990/2020/2050 M=1991/2021 N=1992/2022 P=1993/2023 R=1994/2024
S=1995/2025 T=1996/2026 V=1997/2027 W=1998/2028 X=1999/2029
Y=2000/2030 1=2001/2031 2=2002/2032 3=2003/2033 4=2004/2034
5=2005/2035 6=2006/2036 7=2007/2037 8=2008/2038 9=2009/2039
- Position 11: Assembly plant
- Positions 12-17: Sequential production number
Return the vehicle's make, model, trim level, body type, drive type, fuel type, engine description, and transmission type. If a field cannot be determined from the VIN, return null for that field. Return a confidence score (0.0-1.0) indicating overall decode reliability.\
"""
_VIN_DECODE_SCHEMA: dict[str, Any] = {
"type": "OBJECT",
"properties": {
"year": {"type": "INTEGER", "nullable": True},
"make": {"type": "STRING", "nullable": True},
"model": {"type": "STRING", "nullable": True},
"trimLevel": {"type": "STRING", "nullable": True},
"bodyType": {"type": "STRING", "nullable": True},
"driveType": {"type": "STRING", "nullable": True},
"fuelType": {"type": "STRING", "nullable": True},
"engine": {"type": "STRING", "nullable": True},
"transmission": {"type": "STRING", "nullable": True},
"confidence": {"type": "NUMBER"},
},
"required": ["confidence"],
}
_RESPONSE_SCHEMA: dict[str, Any] = { _RESPONSE_SCHEMA: dict[str, Any] = {
"type": "object", "type": "OBJECT",
"properties": { "properties": {
"maintenanceSchedule": { "maintenanceSchedule": {
"type": "array", "type": "ARRAY",
"items": { "items": {
"type": "object", "type": "OBJECT",
"properties": { "properties": {
"serviceName": {"type": "string"}, "serviceName": {"type": "STRING"},
"intervalMiles": {"type": "number", "nullable": True}, "intervalMiles": {"type": "NUMBER", "nullable": True},
"intervalMonths": {"type": "number", "nullable": True}, "intervalMonths": {"type": "NUMBER", "nullable": True},
"details": {"type": "string", "nullable": True}, "details": {"type": "STRING", "nullable": True},
}, },
"required": ["serviceName"], "required": ["serviceName"],
}, },
@@ -70,6 +167,22 @@ class GeminiProcessingError(GeminiEngineError):
"""Raised when Gemini fails to process a document.""" """Raised when Gemini fails to process a document."""
@dataclass
class VinDecodeResult:
"""Result from Gemini VIN decode."""
year: int | None = None
make: str | None = None
model: str | None = None
trim_level: str | None = None
body_type: str | None = None
drive_type: str | None = None
fuel_type: str | None = None
engine: str | None = None
transmission: str | None = None
confidence: float = 0.0
@dataclass @dataclass
class MaintenanceItem: class MaintenanceItem:
"""A single extracted maintenance schedule item.""" """A single extracted maintenance schedule item."""
@@ -89,25 +202,26 @@ class MaintenanceExtractionResult:
class GeminiEngine: class GeminiEngine:
"""Gemini 2.5 Flash wrapper for maintenance schedule extraction. """Gemini 2.5 Flash wrapper for maintenance schedule extraction and VIN decode.
Standalone class (not an OcrEngine subclass) because Gemini performs Standalone class (not an OcrEngine subclass) because Gemini performs
semantic document understanding rather than traditional OCR. semantic document understanding rather than traditional OCR.
Uses lazy initialization: the Vertex AI client is not created until Uses lazy initialization: the Gemini client is not created until
the first ``extract_maintenance()`` call. the first call to ``extract_maintenance()`` or ``decode_vin()``.
""" """
def __init__(self) -> None: def __init__(self) -> None:
self._model: Any | None = None self._client: Any | None = None
self._model_name: str = ""
def _get_model(self) -> Any: def _get_client(self) -> Any:
"""Create the GenerativeModel on first use. """Create the genai.Client on first use.
Authentication uses the same WIF credential path as Google Vision. Authentication uses the same WIF credential path as Google Vision.
""" """
if self._model is not None: if self._client is not None:
return self._model return self._client
key_path = settings.google_vision_key_path key_path = settings.google_vision_key_path
if not os.path.isfile(key_path): if not os.path.isfile(key_path):
@@ -117,46 +231,37 @@ class GeminiEngine:
) )
try: try:
from google.cloud import aiplatform # type: ignore[import-untyped] from google import genai # type: ignore[import-untyped]
from vertexai.generative_models import ( # type: ignore[import-untyped]
GenerationConfig,
GenerativeModel,
)
# Point ADC at the WIF credential config # Point ADC at the WIF credential config (must be set BEFORE Client construction)
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = key_path os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = key_path
os.environ["GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"] = "1" os.environ["GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"] = "1"
aiplatform.init( self._client = genai.Client(
vertexai=True,
project=settings.vertex_ai_project, project=settings.vertex_ai_project,
location=settings.vertex_ai_location, location=settings.vertex_ai_location,
) )
self._model_name = settings.gemini_model
model_name = settings.gemini_model
self._model = GenerativeModel(model_name)
self._generation_config = GenerationConfig(
response_mime_type="application/json",
response_schema=_RESPONSE_SCHEMA,
)
logger.info( logger.info(
"Gemini engine initialized (model=%s, project=%s, location=%s)", "Gemini engine initialized (model=%s, project=%s, location=%s)",
model_name, self._model_name,
settings.vertex_ai_project, settings.vertex_ai_project,
settings.vertex_ai_location, settings.vertex_ai_location,
) )
return self._model return self._client
except ImportError as exc: except ImportError as exc:
logger.exception("Vertex AI SDK import failed") logger.exception("google-genai SDK import failed")
raise GeminiUnavailableError( raise GeminiUnavailableError(
"google-cloud-aiplatform is not installed. " "google-genai is not installed. "
"Install with: pip install google-cloud-aiplatform" "Install with: pip install google-genai"
) from exc ) from exc
except Exception as exc: except Exception as exc:
logger.exception("Vertex AI authentication failed") logger.exception("Gemini authentication failed: %s", type(exc).__name__)
raise GeminiUnavailableError( raise GeminiUnavailableError(
f"Vertex AI authentication failed: {exc}" f"Gemini authentication failed: {exc}"
) from exc ) from exc
def extract_maintenance( def extract_maintenance(
@@ -181,19 +286,23 @@ class GeminiEngine:
"inline processing. Upload to GCS and use a gs:// URI instead." "inline processing. Upload to GCS and use a gs:// URI instead."
) )
model = self._get_model() client = self._get_client()
try: try:
from vertexai.generative_models import Part # type: ignore[import-untyped] from google.genai import types # type: ignore[import-untyped]
pdf_part = Part.from_data( pdf_part = types.Part.from_bytes(
data=pdf_bytes, data=pdf_bytes,
mime_type="application/pdf", mime_type="application/pdf",
) )
response = model.generate_content( response = client.models.generate_content(
[pdf_part, _EXTRACTION_PROMPT], model=self._model_name,
generation_config=self._generation_config, contents=[pdf_part, _EXTRACTION_PROMPT],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_RESPONSE_SCHEMA,
),
) )
raw = json.loads(response.text) raw = json.loads(response.text)
@@ -228,3 +337,94 @@ class GeminiEngine:
raise GeminiProcessingError( raise GeminiProcessingError(
f"Gemini maintenance extraction failed: {exc}" f"Gemini maintenance extraction failed: {exc}"
) from exc ) from exc
def decode_vin(self, vin: str) -> VinDecodeResult:
"""Decode a VIN string into structured vehicle data via Gemini.
The model year is resolved deterministically from VIN positions 7
and 10 -- never delegated to the LLM. Gemini handles make, model,
trim, and other fields that require manufacturer knowledge.
Args:
vin: A 17-character Vehicle Identification Number.
Returns:
Structured vehicle specification result.
Raises:
GeminiProcessingError: If Gemini fails to decode the VIN.
GeminiUnavailableError: If the engine cannot be initialized.
"""
client = self._get_client()
# Resolve year deterministically from VIN structure
resolved_year = resolve_vin_year(vin)
year_code = vin[9].upper() if len(vin) >= 10 else "?"
logger.info(
"VIN year resolved: code=%s pos7=%s -> year=%s",
year_code,
vin[6] if len(vin) >= 7 else "?",
resolved_year,
)
try:
from google.genai import types # type: ignore[import-untyped]
prompt = _VIN_DECODE_PROMPT.format(
vin=vin,
year=resolved_year or "unknown",
year_code=year_code,
)
response = client.models.generate_content(
model=self._model_name,
contents=[prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_VIN_DECODE_SCHEMA,
tools=[types.Tool(google_search=types.GoogleSearch())],
),
)
raw = json.loads(response.text)
# Override year with deterministic value -- never trust the LLM
# for a mechanical lookup
gemini_year = raw.get("year")
if resolved_year and gemini_year != resolved_year:
logger.warning(
"Gemini returned year %s but resolved year is %s for VIN %s -- overriding",
gemini_year,
resolved_year,
vin,
)
logger.info(
"Gemini decoded VIN %s (confidence=%.2f) raw=%s",
vin,
raw.get("confidence", 0),
json.dumps(raw, default=str),
)
return VinDecodeResult(
year=resolved_year if resolved_year else raw.get("year"),
make=raw.get("make"),
model=raw.get("model"),
trim_level=raw.get("trimLevel"),
body_type=raw.get("bodyType"),
drive_type=raw.get("driveType"),
fuel_type=raw.get("fuelType"),
engine=raw.get("engine"),
transmission=raw.get("transmission"),
confidence=raw.get("confidence", 0.0),
)
except (GeminiEngineError,):
raise
except json.JSONDecodeError as exc:
raise GeminiProcessingError(
f"Gemini returned invalid JSON for VIN decode: {exc}"
) from exc
except Exception as exc:
raise GeminiProcessingError(
f"Gemini VIN decode failed: {exc}"
) from exc

View File

@@ -14,6 +14,7 @@ import time
from typing import Any, Optional from typing import Any, Optional
from app.config import settings from app.config import settings
from app.engines.gemini_engine import GeminiUnavailableError
from app.extractors.receipt_extractor import ( from app.extractors.receipt_extractor import (
ExtractedField, ExtractedField,
ReceiptExtractionResult, ReceiptExtractionResult,
@@ -54,16 +55,16 @@ OCR Text:
""" """
_RECEIPT_RESPONSE_SCHEMA: dict[str, Any] = { _RECEIPT_RESPONSE_SCHEMA: dict[str, Any] = {
"type": "object", "type": "OBJECT",
"properties": { "properties": {
"serviceName": {"type": "string", "nullable": True}, "serviceName": {"type": "STRING", "nullable": True},
"serviceDate": {"type": "string", "nullable": True}, "serviceDate": {"type": "STRING", "nullable": True},
"totalCost": {"type": "number", "nullable": True}, "totalCost": {"type": "NUMBER", "nullable": True},
"shopName": {"type": "string", "nullable": True}, "shopName": {"type": "STRING", "nullable": True},
"laborCost": {"type": "number", "nullable": True}, "laborCost": {"type": "NUMBER", "nullable": True},
"partsCost": {"type": "number", "nullable": True}, "partsCost": {"type": "NUMBER", "nullable": True},
"odometerReading": {"type": "number", "nullable": True}, "odometerReading": {"type": "NUMBER", "nullable": True},
"vehicleInfo": {"type": "string", "nullable": True}, "vehicleInfo": {"type": "STRING", "nullable": True},
}, },
"required": [ "required": [
"serviceName", "serviceName",
@@ -87,8 +88,8 @@ class MaintenanceReceiptExtractor:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self._model: Any | None = None self._client: Any | None = None
self._generation_config: Any | None = None self._model_name: str = ""
def extract( def extract(
self, self,
@@ -169,47 +170,52 @@ class MaintenanceReceiptExtractor:
processing_time_ms=processing_time_ms, processing_time_ms=processing_time_ms,
) )
def _get_model(self) -> Any: def _get_client(self) -> Any:
"""Lazy-initialize Vertex AI Gemini model. """Lazy-initialize google-genai Gemini client.
Uses the same authentication pattern as GeminiEngine. Uses the same authentication pattern as GeminiEngine.
""" """
if self._model is not None: if self._client is not None:
return self._model return self._client
key_path = settings.google_vision_key_path key_path = settings.google_vision_key_path
if not os.path.isfile(key_path): if not os.path.isfile(key_path):
raise RuntimeError( raise GeminiUnavailableError(
f"Google credential config not found at {key_path}. " f"Google credential config not found at {key_path}. "
"Set GOOGLE_VISION_KEY_PATH or mount the secret." "Set GOOGLE_VISION_KEY_PATH or mount the secret."
) )
from google.cloud import aiplatform # type: ignore[import-untyped] try:
from vertexai.generative_models import ( # type: ignore[import-untyped] from google import genai # type: ignore[import-untyped]
GenerationConfig,
GenerativeModel,
)
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = key_path # Point ADC at the WIF credential config (must be set BEFORE Client construction)
os.environ["GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"] = "1" os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = key_path
os.environ["GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"] = "1"
aiplatform.init( self._client = genai.Client(
project=settings.vertex_ai_project, vertexai=True,
location=settings.vertex_ai_location, project=settings.vertex_ai_project,
) location=settings.vertex_ai_location,
)
self._model_name = settings.gemini_model
model_name = settings.gemini_model logger.info(
self._model = GenerativeModel(model_name) "Maintenance receipt Gemini client initialized (model=%s)",
self._generation_config = GenerationConfig( self._model_name,
response_mime_type="application/json", )
response_schema=_RECEIPT_RESPONSE_SCHEMA, return self._client
)
logger.info( except ImportError as exc:
"Maintenance receipt Gemini model initialized (model=%s)", logger.exception("google-genai SDK import failed")
model_name, raise GeminiUnavailableError(
) "google-genai is not installed. "
return self._model "Install with: pip install google-genai"
) from exc
except Exception as exc:
logger.exception("Gemini authentication failed: %s", type(exc).__name__)
raise GeminiUnavailableError(
f"Gemini authentication failed: {exc}"
) from exc
def _extract_with_gemini(self, ocr_text: str) -> dict: def _extract_with_gemini(self, ocr_text: str) -> dict:
"""Send OCR text to Gemini for semantic field extraction. """Send OCR text to Gemini for semantic field extraction.
@@ -220,13 +226,19 @@ class MaintenanceReceiptExtractor:
Returns: Returns:
Dictionary of field_name -> extracted_value from Gemini. Dictionary of field_name -> extracted_value from Gemini.
""" """
model = self._get_model() client = self._get_client()
from google.genai import types # type: ignore[import-untyped]
prompt = _RECEIPT_EXTRACTION_PROMPT.format(ocr_text=ocr_text) prompt = _RECEIPT_EXTRACTION_PROMPT.format(ocr_text=ocr_text)
response = model.generate_content( response = client.models.generate_content(
[prompt], model=self._model_name,
generation_config=self._generation_config, contents=[prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_RECEIPT_RESPONSE_SCHEMA,
),
) )
raw = json.loads(response.text) raw = json.loads(response.text)

View File

@@ -6,7 +6,7 @@ from typing import AsyncIterator
from fastapi import FastAPI from fastapi import FastAPI
from app.config import settings from app.config import settings
from app.routers import extract_router, jobs_router from app.routers import decode_router, extract_router, jobs_router
from app.services import job_queue from app.services import job_queue
# Configure logging # Configure logging
@@ -36,6 +36,7 @@ app = FastAPI(
) )
# Include routers # Include routers
app.include_router(decode_router)
app.include_router(extract_router) app.include_router(extract_router)
app.include_router(jobs_router) app.include_router(jobs_router)
@@ -54,6 +55,7 @@ async def root() -> dict:
"version": "1.0.0", "version": "1.0.0",
"log_level": settings.log_level, "log_level": settings.log_level,
"endpoints": [ "endpoints": [
"POST /decode/vin - VIN string decode via Gemini",
"POST /extract - Synchronous OCR extraction", "POST /extract - Synchronous OCR extraction",
"POST /extract/vin - VIN-specific extraction with validation", "POST /extract/vin - VIN-specific extraction with validation",
"POST /extract/receipt - Receipt extraction (fuel, general)", "POST /extract/receipt - Receipt extraction (fuel, general)",

View File

@@ -14,6 +14,8 @@ from .schemas import (
ReceiptExtractedField, ReceiptExtractedField,
ReceiptExtractionResponse, ReceiptExtractionResponse,
VinAlternative, VinAlternative,
VinDecodeRequest,
VinDecodeResponse,
VinExtractionResponse, VinExtractionResponse,
) )
@@ -32,5 +34,7 @@ __all__ = [
"ReceiptExtractedField", "ReceiptExtractedField",
"ReceiptExtractionResponse", "ReceiptExtractionResponse",
"VinAlternative", "VinAlternative",
"VinDecodeRequest",
"VinDecodeResponse",
"VinExtractionResponse", "VinExtractionResponse",
] ]

View File

@@ -169,3 +169,30 @@ class ManualJobResponse(BaseModel):
error: Optional[str] = None error: Optional[str] = None
model_config = {"populate_by_name": True} model_config = {"populate_by_name": True}
class VinDecodeRequest(BaseModel):
"""Request body for VIN decode endpoint."""
vin: str
class VinDecodeResponse(BaseModel):
"""Response from VIN decode endpoint."""
success: bool
vin: str
year: Optional[int] = None
make: Optional[str] = None
model: Optional[str] = None
trim_level: Optional[str] = Field(default=None, alias="trimLevel")
body_type: Optional[str] = Field(default=None, alias="bodyType")
drive_type: Optional[str] = Field(default=None, alias="driveType")
fuel_type: Optional[str] = Field(default=None, alias="fuelType")
engine: Optional[str] = None
transmission: Optional[str] = None
confidence: float = Field(ge=0.0, le=1.0)
processing_time_ms: int = Field(alias="processingTimeMs")
error: Optional[str] = None
model_config = {"populate_by_name": True}

View File

@@ -1,5 +1,6 @@
"""OCR API routers.""" """OCR API routers."""
from .decode import router as decode_router
from .extract import router as extract_router from .extract import router as extract_router
from .jobs import router as jobs_router from .jobs import router as jobs_router
__all__ = ["extract_router", "jobs_router"] __all__ = ["decode_router", "extract_router", "jobs_router"]

67
ocr/app/routers/decode.py Normal file
View File

@@ -0,0 +1,67 @@
"""VIN decode router - Gemini-powered VIN string decoding."""
import logging
import re
import time
from fastapi import APIRouter, HTTPException
from app.engines.gemini_engine import (
GeminiEngine,
GeminiProcessingError,
GeminiUnavailableError,
)
from app.models import VinDecodeRequest, VinDecodeResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/decode", tags=["decode"])
_VIN_REGEX = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
# Shared engine instance (lazy init on first request)
_gemini_engine = GeminiEngine()
@router.post("/vin", response_model=VinDecodeResponse)
async def decode_vin(request: VinDecodeRequest) -> VinDecodeResponse:
"""Decode a VIN string into structured vehicle data using Gemini.
Accepts a 17-character VIN and returns year, make, model, trim, etc.
"""
vin = request.vin.upper().strip()
if not _VIN_REGEX.match(vin):
raise HTTPException(
status_code=400,
detail=f"Invalid VIN format: must be 17 alphanumeric characters (excluding I, O, Q). Got: {vin}",
)
start_ms = time.monotonic_ns() // 1_000_000
try:
result = _gemini_engine.decode_vin(vin)
except GeminiUnavailableError as exc:
logger.error("Gemini unavailable for VIN decode: %s", exc)
raise HTTPException(status_code=503, detail=str(exc)) from exc
except GeminiProcessingError as exc:
logger.error("Gemini processing error for VIN %s: %s", vin, exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
elapsed_ms = (time.monotonic_ns() // 1_000_000) - start_ms
return VinDecodeResponse(
success=True,
vin=vin,
year=result.year,
make=result.make,
model=result.model,
trimLevel=result.trim_level,
bodyType=result.body_type,
driveType=result.drive_type,
fuelType=result.fuel_type,
engine=result.engine,
transmission=result.transmission,
confidence=result.confidence,
processingTimeMs=elapsed_ms,
error=None,
)

View File

@@ -21,8 +21,8 @@ google-cloud-vision>=3.7.0
# PDF Processing # PDF Processing
PyMuPDF>=1.23.0 PyMuPDF>=1.23.0
# Vertex AI / Gemini (maintenance schedule extraction) # Google GenAI / Gemini (maintenance schedule extraction, VIN decode)
google-cloud-aiplatform>=1.40.0 google-genai>=1.0.0
# Redis for job queue # Redis for job queue
redis>=5.0.0 redis>=5.0.0

View File

@@ -2,11 +2,11 @@
Covers: GeminiEngine initialization, PDF size validation, Covers: GeminiEngine initialization, PDF size validation,
successful extraction, empty results, and error handling. successful extraction, empty results, and error handling.
All Vertex AI SDK calls are mocked. All google-genai SDK calls are mocked.
""" """
import json import json
from unittest.mock import MagicMock, patch, PropertyMock from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -156,22 +156,16 @@ class TestExtractMaintenance:
}, },
] ]
mock_model = MagicMock() mock_client = MagicMock()
mock_model.generate_content.return_value = _make_gemini_response(schedule) mock_client.models.generate_content.return_value = _make_gemini_response(schedule)
with ( with patch.dict("sys.modules", {
patch( "google.genai": MagicMock(),
"app.engines.gemini_engine.importlib_vertex_ai" "google.genai.types": MagicMock(),
) if False else patch.dict("sys.modules", { }):
"google.cloud": MagicMock(),
"google.cloud.aiplatform": MagicMock(),
"vertexai": MagicMock(),
"vertexai.generative_models": MagicMock(),
}),
):
engine = GeminiEngine() engine = GeminiEngine()
engine._model = mock_model engine._client = mock_client
engine._generation_config = MagicMock() engine._model_name = "gemini-2.5-flash"
result = engine.extract_maintenance(_make_pdf_bytes()) result = engine.extract_maintenance(_make_pdf_bytes())
@@ -200,12 +194,12 @@ class TestExtractMaintenance:
mock_settings.vertex_ai_location = "us-central1" mock_settings.vertex_ai_location = "us-central1"
mock_settings.gemini_model = "gemini-2.5-flash" mock_settings.gemini_model = "gemini-2.5-flash"
mock_model = MagicMock() mock_client = MagicMock()
mock_model.generate_content.return_value = _make_gemini_response([]) mock_client.models.generate_content.return_value = _make_gemini_response([])
engine = GeminiEngine() engine = GeminiEngine()
engine._model = mock_model engine._client = mock_client
engine._generation_config = MagicMock() engine._model_name = "gemini-2.5-flash"
result = engine.extract_maintenance(_make_pdf_bytes()) result = engine.extract_maintenance(_make_pdf_bytes())
@@ -223,12 +217,12 @@ class TestExtractMaintenance:
schedule = [{"serviceName": "Brake Fluid Replacement"}] schedule = [{"serviceName": "Brake Fluid Replacement"}]
mock_model = MagicMock() mock_client = MagicMock()
mock_model.generate_content.return_value = _make_gemini_response(schedule) mock_client.models.generate_content.return_value = _make_gemini_response(schedule)
engine = GeminiEngine() engine = GeminiEngine()
engine._model = mock_model engine._client = mock_client
engine._generation_config = MagicMock() engine._model_name = "gemini-2.5-flash"
result = engine.extract_maintenance(_make_pdf_bytes()) result = engine.extract_maintenance(_make_pdf_bytes())
@@ -264,7 +258,8 @@ class TestErrorHandling:
with ( with (
patch("app.engines.gemini_engine.settings") as mock_settings, patch("app.engines.gemini_engine.settings") as mock_settings,
patch.dict("sys.modules", { patch.dict("sys.modules", {
"google.cloud.aiplatform": None, "google": None,
"google.genai": None,
}), }),
): ):
mock_settings.google_vision_key_path = "/fake/creds.json" mock_settings.google_vision_key_path = "/fake/creds.json"
@@ -283,12 +278,12 @@ class TestErrorHandling:
mock_settings.vertex_ai_location = "us-central1" mock_settings.vertex_ai_location = "us-central1"
mock_settings.gemini_model = "gemini-2.5-flash" mock_settings.gemini_model = "gemini-2.5-flash"
mock_model = MagicMock() mock_client = MagicMock()
mock_model.generate_content.side_effect = RuntimeError("API quota exceeded") mock_client.models.generate_content.side_effect = RuntimeError("API quota exceeded")
engine = GeminiEngine() engine = GeminiEngine()
engine._model = mock_model engine._client = mock_client
engine._generation_config = MagicMock() engine._model_name = "gemini-2.5-flash"
with pytest.raises(GeminiProcessingError, match="maintenance extraction failed"): with pytest.raises(GeminiProcessingError, match="maintenance extraction failed"):
engine.extract_maintenance(_make_pdf_bytes()) engine.extract_maintenance(_make_pdf_bytes())
@@ -307,12 +302,12 @@ class TestErrorHandling:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = "not valid json {{" mock_response.text = "not valid json {{"
mock_model = MagicMock() mock_client = MagicMock()
mock_model.generate_content.return_value = mock_response mock_client.models.generate_content.return_value = mock_response
engine = GeminiEngine() engine = GeminiEngine()
engine._model = mock_model engine._client = mock_client
engine._generation_config = MagicMock() engine._model_name = "gemini-2.5-flash"
with pytest.raises(GeminiProcessingError, match="invalid JSON"): with pytest.raises(GeminiProcessingError, match="invalid JSON"):
engine.extract_maintenance(_make_pdf_bytes()) engine.extract_maintenance(_make_pdf_bytes())
@@ -322,32 +317,32 @@ class TestErrorHandling:
class TestLazyInitialization: class TestLazyInitialization:
"""Verify the model is not created until first use.""" """Verify the client is not created until first use."""
def test_model_is_none_after_construction(self): def test_client_is_none_after_construction(self):
"""GeminiEngine should not initialize the model in __init__.""" """GeminiEngine should not initialize the client in __init__."""
engine = GeminiEngine() engine = GeminiEngine()
assert engine._model is None assert engine._client is None
@patch("app.engines.gemini_engine.settings") @patch("app.engines.gemini_engine.settings")
@patch("app.engines.gemini_engine.os.path.isfile", return_value=True) @patch("app.engines.gemini_engine.os.path.isfile", return_value=True)
def test_model_reused_on_second_call(self, mock_isfile, mock_settings): def test_client_reused_on_second_call(self, mock_isfile, mock_settings):
"""Once initialized, the same model instance is reused.""" """Once initialized, the same client instance is reused."""
mock_settings.google_vision_key_path = "/fake/creds.json" mock_settings.google_vision_key_path = "/fake/creds.json"
mock_settings.vertex_ai_project = "test-project" mock_settings.vertex_ai_project = "test-project"
mock_settings.vertex_ai_location = "us-central1" mock_settings.vertex_ai_location = "us-central1"
mock_settings.gemini_model = "gemini-2.5-flash" mock_settings.gemini_model = "gemini-2.5-flash"
schedule = [{"serviceName": "Oil Change", "intervalMiles": 5000}] schedule = [{"serviceName": "Oil Change", "intervalMiles": 5000}]
mock_model = MagicMock() mock_client = MagicMock()
mock_model.generate_content.return_value = _make_gemini_response(schedule) mock_client.models.generate_content.return_value = _make_gemini_response(schedule)
engine = GeminiEngine() engine = GeminiEngine()
engine._model = mock_model engine._client = mock_client
engine._generation_config = MagicMock() engine._model_name = "gemini-2.5-flash"
engine.extract_maintenance(_make_pdf_bytes()) engine.extract_maintenance(_make_pdf_bytes())
engine.extract_maintenance(_make_pdf_bytes()) engine.extract_maintenance(_make_pdf_bytes())
# Model's generate_content should have been called twice # Client's generate_content should have been called twice
assert mock_model.generate_content.call_count == 2 assert mock_client.models.generate_content.call_count == 2

View File

@@ -0,0 +1,122 @@
"""Tests for deterministic VIN year resolution.
Covers: all three 30-year cycles (1980-2009, 2010-2039, 2040-2050),
position 7 disambiguation, edge cases, and invalid inputs.
"""
import pytest
from unittest.mock import patch
from datetime import datetime
from app.engines.gemini_engine import resolve_vin_year
class TestSecondCycle:
"""Position 7 alphabetic -> 2010-2039 cycle (NHTSA MY2010+ requirement)."""
def test_p_with_alpha_pos7_returns_2023(self):
"""P=2023 when position 7 is alphabetic (the bug that triggered this fix)."""
# VIN: 1G1YE2D32P5602473 -- pos7='D' (alphabetic), pos10='P'
assert resolve_vin_year("1G1YE2D32P5602473") == 2023
def test_a_with_alpha_pos7_returns_2010(self):
"""A=2010 when position 7 is alphabetic."""
assert resolve_vin_year("1G1YE2D12A5602473") == 2010
def test_l_with_alpha_pos7_returns_2020(self):
"""L=2020 when position 7 is alphabetic."""
assert resolve_vin_year("1G1YE2D12L5602473") == 2020
def test_9_with_alpha_pos7_returns_2039(self):
"""9=2039 when position 7 is alphabetic."""
assert resolve_vin_year("1G1YE2D1295602473") == 2039
def test_digit_1_with_alpha_pos7_returns_2031(self):
"""1=2031 when position 7 is alphabetic."""
assert resolve_vin_year("1G1YE2D1215602473") == 2031
def test_s_with_alpha_pos7_returns_2025(self):
"""S=2025 when position 7 is alphabetic."""
assert resolve_vin_year("1G1YE2D12S5602473") == 2025
def test_t_with_alpha_pos7_returns_2026(self):
"""T=2026 when position 7 is alphabetic."""
assert resolve_vin_year("1G1YE2D12T5602473") == 2026
class TestFirstCycle:
"""Position 7 numeric -> 1980-2009 cycle."""
def test_m_with_numeric_pos7_returns_1991(self):
"""M=1991 when position 7 is numeric."""
assert resolve_vin_year("1G1YE2132M5602473") == 1991
def test_n_with_numeric_pos7_returns_1992(self):
"""N=1992 when position 7 is numeric."""
assert resolve_vin_year("1G1YE2132N5602473") == 1992
def test_p_with_numeric_pos7_returns_1993(self):
"""P=1993 when position 7 is numeric."""
assert resolve_vin_year("1G1YE2132P5602473") == 1993
def test_y_with_numeric_pos7_returns_2000(self):
"""Y=2000 when position 7 is numeric."""
assert resolve_vin_year("1G1YE2132Y5602473") == 2000
class TestThirdCycle:
"""Position 7 numeric + third cycle year (2040-2050) is plausible."""
@patch("app.engines.gemini_engine.datetime")
def test_a_with_numeric_pos7_returns_2040_when_plausible(self, mock_dt):
"""A=2040 when position 7 is numeric and year 2040 is plausible."""
mock_dt.now.return_value = datetime(2039, 1, 1)
# 2039 + 2 = 2041 >= 2040, so third cycle is plausible
assert resolve_vin_year("1G1YE2132A5602473") == 2040
@patch("app.engines.gemini_engine.datetime")
def test_l_with_numeric_pos7_returns_2050_when_plausible(self, mock_dt):
"""L=2050 when position 7 is numeric and year 2050 is plausible."""
mock_dt.now.return_value = datetime(2049, 6, 1)
assert resolve_vin_year("1G1YE2132L5602473") == 2050
@patch("app.engines.gemini_engine.datetime")
def test_a_with_numeric_pos7_returns_1980_when_2040_not_plausible(self, mock_dt):
"""A=1980 when third cycle year (2040) exceeds max plausible."""
mock_dt.now.return_value = datetime(2026, 2, 20)
# 2026 + 2 = 2028 < 2040, so third cycle not plausible -> first cycle
assert resolve_vin_year("1G1YE2132A5602473") == 1980
@patch("app.engines.gemini_engine.datetime")
def test_k_with_numeric_pos7_returns_2049_when_plausible(self, mock_dt):
"""K=2049 when position 7 is numeric and year is plausible."""
mock_dt.now.return_value = datetime(2048, 1, 1)
assert resolve_vin_year("1G1YE2132K5602473") == 2049
class TestEdgeCases:
"""Invalid inputs and boundary conditions."""
def test_short_vin_returns_none(self):
"""VIN shorter than 17 chars returns None."""
assert resolve_vin_year("1G1YE2D32") is None
def test_empty_vin_returns_none(self):
"""Empty string returns None."""
assert resolve_vin_year("") is None
def test_invalid_year_code_returns_none(self):
"""Position 10 with invalid code (e.g., 'Z') returns None."""
# Z is not a valid year code
assert resolve_vin_year("1G1YE2D32Z5602473") is None
def test_lowercase_vin_handled(self):
"""Lowercase VIN characters are handled correctly."""
assert resolve_vin_year("1g1ye2d32p5602473") == 2023
def test_i_o_q_not_valid_year_codes(self):
"""Letters I, O, Q are not valid VIN year codes."""
# These are excluded from VINs entirely but test graceful handling
assert resolve_vin_year("1G1YE2D32I5602473") is None
assert resolve_vin_year("1G1YE2D32O5602473") is None
assert resolve_vin_year("1G1YE2D32Q5602473") is None

View File

@@ -0,0 +1,199 @@
"""Tests for the VIN decode endpoint (POST /decode/vin).
Covers: valid VIN returns 200 with correct response shape,
invalid VIN format returns 400, Gemini unavailable returns 503,
and Gemini processing error returns 422.
All GeminiEngine calls are mocked.
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.engines.gemini_engine import (
GeminiProcessingError,
GeminiUnavailableError,
VinDecodeResult,
)
from app.main import app
client = TestClient(app)
# A valid 17-character VIN (no I, O, Q)
_VALID_VIN = "1HGBH41JXMN109186"
_FULL_RESULT = VinDecodeResult(
year=2021,
make="Honda",
model="Civic",
trim_level="EX",
body_type="Sedan",
drive_type="FWD",
fuel_type="Gasoline",
engine="2.0L I4",
transmission="CVT",
confidence=0.95,
)
# --- Valid VIN ---
class TestDecodeVinSuccess:
"""Verify successful VIN decode returns 200 with correct response shape."""
@patch("app.routers.decode._gemini_engine")
def test_valid_vin_returns_200(self, mock_engine):
"""Normal: Valid VIN returns 200 with all vehicle fields populated."""
mock_engine.decode_vin.return_value = _FULL_RESULT
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["vin"] == _VALID_VIN
assert data["year"] == 2021
assert data["make"] == "Honda"
assert data["model"] == "Civic"
assert data["trimLevel"] == "EX"
assert data["bodyType"] == "Sedan"
assert data["driveType"] == "FWD"
assert data["fuelType"] == "Gasoline"
assert data["engine"] == "2.0L I4"
assert data["transmission"] == "CVT"
assert data["confidence"] == 0.95
assert "processingTimeMs" in data
assert data["error"] is None
@patch("app.routers.decode._gemini_engine")
def test_vin_uppercased_before_decode(self, mock_engine):
"""VIN submitted in lowercase is normalised to uppercase before decoding."""
mock_engine.decode_vin.return_value = _FULL_RESULT
response = client.post("/decode/vin", json={"vin": _VALID_VIN.lower()})
assert response.status_code == 200
data = response.json()
assert data["vin"] == _VALID_VIN
mock_engine.decode_vin.assert_called_once_with(_VALID_VIN)
@patch("app.routers.decode._gemini_engine")
def test_nullable_fields_allowed(self, mock_engine):
"""Edge: VIN decode with only confidence set returns valid response."""
mock_engine.decode_vin.return_value = VinDecodeResult(confidence=0.3)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["year"] is None
assert data["make"] is None
assert data["confidence"] == 0.3
# --- Invalid VIN format ---
class TestDecodeVinValidation:
"""Verify invalid VIN formats return 400."""
def test_too_short_vin_returns_400(self):
"""VIN shorter than 17 characters is rejected."""
response = client.post("/decode/vin", json={"vin": "1HGBH41JXM"})
assert response.status_code == 400
assert "Invalid VIN format" in response.json()["detail"]
def test_too_long_vin_returns_400(self):
"""VIN longer than 17 characters is rejected."""
response = client.post("/decode/vin", json={"vin": "1HGBH41JXMN109186X"})
assert response.status_code == 400
def test_vin_with_letter_i_returns_400(self):
"""VIN containing the letter I (invalid character) is rejected."""
# Replace position 0 with I to create invalid VIN
invalid_vin = "IHGBH41JXMN109186"
response = client.post("/decode/vin", json={"vin": invalid_vin})
assert response.status_code == 400
assert "Invalid VIN format" in response.json()["detail"]
def test_vin_with_letter_o_returns_400(self):
"""VIN containing the letter O (invalid character) is rejected."""
invalid_vin = "OHGBH41JXMN109186"
response = client.post("/decode/vin", json={"vin": invalid_vin})
assert response.status_code == 400
def test_vin_with_letter_q_returns_400(self):
"""VIN containing the letter Q (invalid character) is rejected."""
invalid_vin = "QHGBH41JXMN109186"
response = client.post("/decode/vin", json={"vin": invalid_vin})
assert response.status_code == 400
def test_empty_vin_returns_400(self):
"""Empty VIN string is rejected."""
response = client.post("/decode/vin", json={"vin": ""})
assert response.status_code == 400
def test_vin_with_special_chars_returns_400(self):
"""VIN containing special characters is rejected."""
response = client.post("/decode/vin", json={"vin": "1HGBH41J-MN109186"})
assert response.status_code == 400
# --- Gemini unavailable ---
class TestDecodeVinGeminiUnavailable:
"""Verify Gemini service unavailability returns 503."""
@patch("app.routers.decode._gemini_engine")
def test_gemini_unavailable_returns_503(self, mock_engine):
"""When Gemini cannot be initialized, endpoint returns 503."""
mock_engine.decode_vin.side_effect = GeminiUnavailableError(
"Google credential config not found"
)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 503
assert "Google credential config not found" in response.json()["detail"]
# --- Gemini processing error ---
class TestDecodeVinGeminiProcessingError:
"""Verify Gemini processing failures return 422."""
@patch("app.routers.decode._gemini_engine")
def test_gemini_processing_error_returns_422(self, mock_engine):
"""When Gemini returns invalid output, endpoint returns 422."""
mock_engine.decode_vin.side_effect = GeminiProcessingError(
"Gemini returned invalid JSON for VIN decode: ..."
)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 422
assert "Gemini returned invalid JSON" in response.json()["detail"]
@patch("app.routers.decode._gemini_engine")
def test_gemini_api_failure_returns_422(self, mock_engine):
"""When Gemini API call fails at runtime, endpoint returns 422."""
mock_engine.decode_vin.side_effect = GeminiProcessingError(
"Gemini VIN decode failed: API quota exceeded"
)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 422
assert "Gemini VIN decode failed" in response.json()["detail"]

View File

@@ -1,11 +1,12 @@
#!/bin/bash #!/bin/bash
# generate-log-config.sh - Generate .env.logging from LOG_LEVEL # generate-log-config.sh - Generate log-level environment variables
# Maps a single LOG_LEVEL environment variable to per-container settings # Maps a single LOG_LEVEL to per-container settings and writes to stdout
# #
# Usage: ./generate-log-config.sh [LOG_LEVEL] # Usage: ./generate-log-config.sh [LOG_LEVEL]
# LOG_LEVEL: DEBUG, INFO, WARN, or ERROR (default: INFO) # LOG_LEVEL: DEBUG, INFO, WARN, or ERROR (default: INFO)
# #
# Output: Creates .env.logging file with container-specific log settings # Output: Log configuration variables on stdout (append to .env)
# Example: ./generate-log-config.sh INFO >> .env
# #
# Exit codes: # Exit codes:
# 0 - Configuration generated successfully # 0 - Configuration generated successfully
@@ -43,27 +44,13 @@ case "$LOG_LEVEL" in
ERROR) REDIS_LOGLEVEL="warning" ;; ERROR) REDIS_LOGLEVEL="warning" ;;
esac esac
# Generate .env.logging file # Output log configuration to stdout
cat > .env.logging << EOF cat << EOF
# Generated by generate-log-config.sh - DO NOT EDIT MANUALLY
# Regenerate with: ./scripts/ci/generate-log-config.sh $LOG_LEVEL
LOG_LEVEL=$LOG_LEVEL
# Backend/OCR (Pino) # Log levels (generated by generate-log-config.sh $LOG_LEVEL)
BACKEND_LOG_LEVEL=$LOG_LEVEL_LOWER BACKEND_LOG_LEVEL=$LOG_LEVEL_LOWER
TRAEFIK_LOG_LEVEL=$LOG_LEVEL
# Frontend (Vite)
VITE_LOG_LEVEL=$LOG_LEVEL_LOWER
# PostgreSQL
POSTGRES_LOG_STATEMENT=$POSTGRES_LOG_STATEMENT POSTGRES_LOG_STATEMENT=$POSTGRES_LOG_STATEMENT
POSTGRES_LOG_MIN_DURATION=$POSTGRES_LOG_MIN_DURATION POSTGRES_LOG_MIN_DURATION=$POSTGRES_LOG_MIN_DURATION
# Redis
REDIS_LOGLEVEL=$REDIS_LOGLEVEL REDIS_LOGLEVEL=$REDIS_LOGLEVEL
# Traefik
TRAEFIK_LOG_LEVEL=$LOG_LEVEL
EOF EOF
echo "Generated .env.logging with LOG_LEVEL=$LOG_LEVEL"

View File

@@ -174,7 +174,7 @@ echo " Subject: $SUBJECT"
# Build JSON payload # Build JSON payload
JSON_PAYLOAD=$(cat <<EOF JSON_PAYLOAD=$(cat <<EOF
{ {
"from": "MotoVaultPro <deploy@motovaultpro.com>", "from": "MotoVaultPro <hello@notify.motovaultpro.com>",
"to": ["$NOTIFY_EMAIL"], "to": ["$NOTIFY_EMAIL"],
"subject": "$SUBJECT", "subject": "$SUBJECT",
"html": $(echo "$HTML_BODY" | jq -Rs .) "html": $(echo "$HTML_BODY" | jq -Rs .)