feat: Replace NHTSA VIN decode with Google Gemini via OCR service (#223) #229

Merged
egullickson merged 9 commits from issue-223-replace-nhtsa-vin-decode-gemini into main 2026-02-20 03:10:48 +00:00
2 changed files with 50 additions and 3 deletions
Showing only changes of commit 361f58d7c6 - Show all commits

View File

@@ -648,11 +648,12 @@ export class VehiclesService {
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)]
cached_at = NOW()
WHERE (vin_cache.raw_data->>'confidence')::float <= $8`,
[vin, response.make, response.model, response.year, response.engine, response.bodyType, JSON.stringify(response), response.confidence ?? 1]
);
logger.debug('VIN cached', { vin });
logger.debug('VIN cached', { vin, confidence: response.confidence });
} catch (error) {
logger.error('Failed to cache VIN data', { vin, error });
// Don't throw - caching failure shouldn't break the decode flow
@@ -741,6 +742,12 @@ export class VehiclesService {
const sourceEngine = response.engine;
const sourceTransmission = response.transmission;
logger.debug('VIN decode raw values', {
vin: response.vin,
year: sourceYear, make: sourceMake, model: sourceModel,
trim: sourceTrim, confidence: response.confidence
});
// Year is always high confidence if present (exact numeric match)
const year: MatchedField<number> = {
value: sourceYear,
@@ -854,6 +861,26 @@ export class VehiclesService {
return { value: containsMatch, sourceValue, confidence: 'medium' };
}
// Try reverse contains - source value contains option (e.g., source "X5 xDrive35i" contains option "X5")
// 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' };
}

View File

@@ -114,6 +114,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const [isDecoding, setIsDecoding] = useState(false);
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
const [decodeError, setDecodeError] = useState<string | null>(null);
const [decodeHint, setDecodeHint] = useState<string | null>(null);
// VIN OCR capture hook
const vinOcr = useVinOcr();
@@ -524,6 +525,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
setIsDecoding(true);
setDecodeError(null);
setDecodeHint(null);
try {
const decoded = await vehiclesApi.decodeVin(vin);
@@ -588,6 +590,21 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
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);
isVinDecoding.current = false;
} catch (error: any) {
@@ -671,6 +688,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{decodeError && (
<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 && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{vinOcr.error}</p>
)}