Compare commits
35 Commits
714ed92438
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e2bb9ef36 | |||
|
|
56df5d48f3 | ||
|
|
1add6c8240 | ||
|
|
936753fac2 | ||
|
|
96e1dde7b2 | ||
|
|
1464a0e1af | ||
|
|
9f51e62b94 | ||
|
|
b7f472b3e8 | ||
|
|
398d67304f | ||
|
|
0055d9f0f3 | ||
|
|
9dc56a3773 | ||
|
|
283ba6b108 | ||
|
|
7d90f4b25a | ||
| e2e6471c5e | |||
|
|
3b5b84729f | ||
| d9df9193dc | |||
|
|
781241966c | ||
|
|
bf6742f6ea | ||
|
|
5bb44be8bc | ||
|
|
361f58d7c6 | ||
|
|
d96736789e | ||
|
|
f590421058 | ||
|
|
5cbf9c764d | ||
|
|
3cd61256ba | ||
|
|
a75f7b5583 | ||
| 00aa2a5411 | |||
|
|
1dac6d342b | ||
|
|
3b62f5a621 | ||
|
|
4f4fb8a886 | ||
|
|
d57c5d6cf8 | ||
|
|
8a73352ddc | ||
|
|
72e557346c | ||
|
|
853a075e8b | ||
|
|
07c3d8511d | ||
|
|
15956a8711 |
@@ -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
36
.env.example
Normal 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
|
||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}">
|
||||||
© {new Date().getFullYear()} MotoVaultPro. All rights reserved.
|
© ${new Date().getFullYear()} MotoVaultPro. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
51
backend/src/features/ocr/external/ocr-client.ts
vendored
51
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -592,7 +593,7 @@ export class VehiclesService {
|
|||||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||||
await cacheService.del(cacheKey);
|
await cacheService.del(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDropdownMakes(year: number): Promise<string[]> {
|
async getDropdownMakes(year: number): Promise<string[]> {
|
||||||
const vehicleDataService = getVehicleDataService();
|
const vehicleDataService = getVehicleDataService();
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: price_1T1ZHMJXoKkh5RcKwKSSGIlR
|
|
||||||
STRIPE_PRO_YEARLY_PRICE_ID: price_1T1ZHnJXoKkh5RcKWlG2MPpX
|
|
||||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: price_1T1ZIBJXoKkh5RcKu2jyhqBN
|
|
||||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: price_1T1ZIQJXoKkh5RcK34YXiJQm
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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: price_1T1ZHMJXoKkh5RcKwKSSGIlR
|
|
||||||
STRIPE_PRO_YEARLY_PRICE_ID: price_1T1ZHnJXoKkh5RcKWlG2MPpX
|
|
||||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: price_1T1ZIBJXoKkh5RcKu2jyhqBN
|
|
||||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: price_1T1ZIQJXoKkh5RcK34YXiJQm
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,9 +319,9 @@ 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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
67
ocr/app/routers/decode.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
122
ocr/tests/test_resolve_vin_year.py
Normal file
122
ocr/tests/test_resolve_vin_year.py
Normal 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
|
||||||
199
ocr/tests/test_vin_decode.py
Normal file
199
ocr/tests/test_vin_decode.py
Normal 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"]
|
||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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 .)
|
||||||
|
|||||||
Reference in New Issue
Block a user