feat: rewire vehicles controller to OCR VIN decode (refs #226)
Replace NHTSAClient with OcrClient in vehicles controller. Move cache logic into VehiclesService with format-aware reads (Gemini vs legacy NHTSA entries). Rename nhtsaValue to sourceValue in MatchedField. Remove vpic config from Zod schema and YAML config files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,8 @@ import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/v
|
||||
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
||||
import { getVehicleDataService, getPool } from '../../platform';
|
||||
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 { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
|
||||
@@ -592,6 +593,71 @@ export class VehiclesService {
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||
await cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check vin_cache for existing VIN data.
|
||||
* Format-aware: validates raw_data has `success` field (Gemini format).
|
||||
* Old NHTSA-format entries are treated as cache misses and expire via TTL.
|
||||
*/
|
||||
async getVinCached(vin: string): Promise<VinDecodeResponse | null> {
|
||||
try {
|
||||
const result = await this.pool.query<{
|
||||
raw_data: any;
|
||||
cached_at: Date;
|
||||
}>(
|
||||
`SELECT raw_data, cached_at
|
||||
FROM vin_cache
|
||||
WHERE vin = $1
|
||||
AND cached_at > NOW() - INTERVAL '365 days'`,
|
||||
[vin]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawData = result.rows[0].raw_data;
|
||||
|
||||
// Format-aware check: Gemini responses have `success` field,
|
||||
// old NHTSA responses do not. Treat old format as cache miss.
|
||||
if (!rawData || typeof rawData !== 'object' || !('success' in rawData)) {
|
||||
logger.debug('VIN cache format mismatch (legacy NHTSA entry), treating as miss', { vin });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('VIN cache hit', { vin });
|
||||
return rawData as VinDecodeResponse;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check VIN cache', { vin, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save VIN decode response to cache with ON CONFLICT upsert.
|
||||
*/
|
||||
async saveVinCache(vin: string, response: VinDecodeResponse): Promise<void> {
|
||||
try {
|
||||
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, response.make, response.model, response.year, response.engine, response.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
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownMakes(year: number): Promise<string[]> {
|
||||
const vehicleDataService = getVehicleDataService();
|
||||
@@ -657,82 +723,82 @@ 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
|
||||
*/
|
||||
async mapNHTSAResponse(response: NHTSADecodeResponse): Promise<DecodedVehicleData> {
|
||||
async mapVinDecodeResponse(response: VinDecodeResponse): Promise<DecodedVehicleData> {
|
||||
const vehicleDataService = getVehicleDataService();
|
||||
const pool = getPool();
|
||||
|
||||
// Extract raw values from NHTSA response
|
||||
const nhtsaYear = NHTSAClient.extractYear(response);
|
||||
const nhtsaMake = NHTSAClient.extractValue(response, 'Make');
|
||||
const nhtsaModel = NHTSAClient.extractValue(response, 'Model');
|
||||
const nhtsaTrim = NHTSAClient.extractValue(response, 'Trim');
|
||||
const nhtsaBodyType = NHTSAClient.extractValue(response, 'Body Class');
|
||||
const nhtsaDriveType = NHTSAClient.extractValue(response, 'Drive Type');
|
||||
const nhtsaFuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary');
|
||||
const nhtsaEngine = NHTSAClient.extractEngine(response);
|
||||
const nhtsaTransmission = NHTSAClient.extractValue(response, 'Transmission Style');
|
||||
// Read flat fields directly from Gemini response
|
||||
const sourceYear = response.year;
|
||||
const sourceMake = response.make;
|
||||
const sourceModel = response.model;
|
||||
const sourceTrim = response.trimLevel;
|
||||
const sourceBodyType = response.bodyType;
|
||||
const sourceDriveType = response.driveType;
|
||||
const sourceFuelType = response.fuelType;
|
||||
const sourceEngine = response.engine;
|
||||
const sourceTransmission = response.transmission;
|
||||
|
||||
// Year is always high confidence if present (exact numeric match)
|
||||
const year: MatchedField<number> = {
|
||||
value: nhtsaYear,
|
||||
nhtsaValue: nhtsaYear?.toString() || null,
|
||||
confidence: nhtsaYear ? 'high' : 'none'
|
||||
value: sourceYear,
|
||||
sourceValue: sourceYear?.toString() || null,
|
||||
confidence: sourceYear ? 'high' : 'none'
|
||||
};
|
||||
|
||||
// Match make against dropdown options
|
||||
let make: MatchedField<string> = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' };
|
||||
if (nhtsaYear && nhtsaMake) {
|
||||
const makes = await vehicleDataService.getMakes(pool, nhtsaYear);
|
||||
make = this.matchField(nhtsaMake, makes);
|
||||
let make: MatchedField<string> = { value: null, sourceValue: sourceMake, confidence: 'none' };
|
||||
if (sourceYear && sourceMake) {
|
||||
const makes = await vehicleDataService.getMakes(pool, sourceYear);
|
||||
make = this.matchField(sourceMake, makes);
|
||||
}
|
||||
|
||||
// Match model against dropdown options
|
||||
let model: MatchedField<string> = { value: null, nhtsaValue: nhtsaModel, confidence: 'none' };
|
||||
if (nhtsaYear && make.value && nhtsaModel) {
|
||||
const models = await vehicleDataService.getModels(pool, nhtsaYear, make.value);
|
||||
model = this.matchField(nhtsaModel, models);
|
||||
let model: MatchedField<string> = { value: null, sourceValue: sourceModel, confidence: 'none' };
|
||||
if (sourceYear && make.value && sourceModel) {
|
||||
const models = await vehicleDataService.getModels(pool, sourceYear, make.value);
|
||||
model = this.matchField(sourceModel, models);
|
||||
}
|
||||
|
||||
// Match trim against dropdown options
|
||||
let trimLevel: MatchedField<string> = { value: null, nhtsaValue: nhtsaTrim, confidence: 'none' };
|
||||
if (nhtsaYear && make.value && model.value && nhtsaTrim) {
|
||||
const trims = await vehicleDataService.getTrims(pool, nhtsaYear, make.value, model.value);
|
||||
trimLevel = this.matchField(nhtsaTrim, trims);
|
||||
let trimLevel: MatchedField<string> = { value: null, sourceValue: sourceTrim, confidence: 'none' };
|
||||
if (sourceYear && make.value && model.value && sourceTrim) {
|
||||
const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value);
|
||||
trimLevel = this.matchField(sourceTrim, trims);
|
||||
}
|
||||
|
||||
// Match engine against dropdown options
|
||||
let engine: MatchedField<string> = { value: null, nhtsaValue: nhtsaEngine, confidence: 'none' };
|
||||
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaEngine) {
|
||||
const engines = await vehicleDataService.getEngines(pool, nhtsaYear, make.value, model.value, trimLevel.value);
|
||||
engine = this.matchField(nhtsaEngine, engines);
|
||||
let engine: MatchedField<string> = { value: null, sourceValue: sourceEngine, confidence: 'none' };
|
||||
if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) {
|
||||
const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value);
|
||||
engine = this.matchField(sourceEngine, engines);
|
||||
}
|
||||
|
||||
// Match transmission against dropdown options
|
||||
let transmission: MatchedField<string> = { value: null, nhtsaValue: nhtsaTransmission, confidence: 'none' };
|
||||
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaTransmission) {
|
||||
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, nhtsaYear, make.value, model.value, trimLevel.value);
|
||||
transmission = this.matchField(nhtsaTransmission, transmissions);
|
||||
let transmission: MatchedField<string> = { value: null, sourceValue: sourceTransmission, confidence: 'none' };
|
||||
if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) {
|
||||
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value);
|
||||
transmission = this.matchField(sourceTransmission, transmissions);
|
||||
}
|
||||
|
||||
// Body type, drive type, and fuel type are display-only (no dropdown matching)
|
||||
const bodyType: MatchedField<string> = {
|
||||
value: null,
|
||||
nhtsaValue: nhtsaBodyType,
|
||||
sourceValue: sourceBodyType,
|
||||
confidence: 'none'
|
||||
};
|
||||
|
||||
const driveType: MatchedField<string> = {
|
||||
value: null,
|
||||
nhtsaValue: nhtsaDriveType,
|
||||
sourceValue: sourceDriveType,
|
||||
confidence: 'none'
|
||||
};
|
||||
|
||||
const fuelType: MatchedField<string> = {
|
||||
value: null,
|
||||
nhtsaValue: nhtsaFuelType,
|
||||
sourceValue: sourceFuelType,
|
||||
confidence: 'none'
|
||||
};
|
||||
|
||||
@@ -754,42 +820,42 @@ export class VehiclesService {
|
||||
* Returns the matched dropdown value with confidence level
|
||||
* Matching order: exact -> normalized -> prefix -> contains
|
||||
*/
|
||||
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> {
|
||||
if (!nhtsaValue || options.length === 0) {
|
||||
return { value: null, nhtsaValue, confidence: 'none' };
|
||||
private matchField(sourceValue: string, options: string[]): MatchedField<string> {
|
||||
if (!sourceValue || options.length === 0) {
|
||||
return { value: null, sourceValue, confidence: 'none' };
|
||||
}
|
||||
|
||||
const normalizedNhtsa = nhtsaValue.toLowerCase().trim();
|
||||
const normalizedSource = sourceValue.toLowerCase().trim();
|
||||
|
||||
// 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) {
|
||||
return { value: exactMatch, nhtsaValue, confidence: 'high' };
|
||||
return { value: exactMatch, sourceValue, confidence: 'high' };
|
||||
}
|
||||
|
||||
// Try normalized comparison (remove special chars)
|
||||
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) {
|
||||
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' };
|
||||
return { value: normalizedMatch, sourceValue, confidence: 'medium' };
|
||||
}
|
||||
|
||||
// Try prefix match - option starts with NHTSA value
|
||||
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa));
|
||||
// Try prefix match - option starts with source value
|
||||
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource));
|
||||
if (prefixMatch) {
|
||||
return { value: prefixMatch, nhtsaValue, confidence: 'medium' };
|
||||
return { value: prefixMatch, sourceValue, confidence: 'medium' };
|
||||
}
|
||||
|
||||
// Try contains match - option contains NHTSA value
|
||||
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa));
|
||||
// Try contains match - option contains source value
|
||||
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource));
|
||||
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
|
||||
return { value: null, nhtsaValue, confidence: 'none' };
|
||||
// No match found - return source value as hint with no match
|
||||
return { value: null, sourceValue, confidence: 'none' };
|
||||
}
|
||||
|
||||
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||
|
||||
Reference in New Issue
Block a user