From 361f58d7c600c656a16a0e57d0bcfcaafe4b834f Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:14:54 -0600 Subject: [PATCH] fix: resolve VIN decode cache race, fuzzy matching, and silent failure (refs #229) Prevent lower-confidence Gemini results from overwriting higher-confidence cache entries, add reverse-contains matching so values like "X5 xDrive35i" match DB option "X5", and show amber hint when dropdown matching fails. Co-Authored-By: Claude Opus 4.6 --- .../vehicles/domain/vehicles.service.ts | 33 +++++++++++++++++-- .../vehicles/components/VehicleForm.tsx | 20 +++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index bb5b668..cbce71a 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -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 = { 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' }; } diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index bfe2955..624fbbc 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -114,6 +114,7 @@ export const VehicleForm: React.FC = ({ const [isDecoding, setIsDecoding] = useState(false); const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); const [decodeError, setDecodeError] = useState(null); + const [decodeHint, setDecodeHint] = useState(null); // VIN OCR capture hook const vinOcr = useVinOcr(); @@ -524,6 +525,7 @@ export const VehicleForm: React.FC = ({ setIsDecoding(true); setDecodeError(null); + setDecodeHint(null); try { const decoded = await vehiclesApi.decodeVin(vin); @@ -588,6 +590,21 @@ export const VehicleForm: React.FC = ({ 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 = ({ {decodeError && (

{decodeError}

)} + {decodeHint && ( +

{decodeHint}

+ )} {vinOcr.error && (

{vinOcr.error}

)}