From 936753fac22161c95e51ea8f67b0118b07ec259e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:02:26 -0600 Subject: [PATCH] fix: VIN Decoding timeouts and logic errors --- .../src/features/vehicles/api/vehicles.api.ts | 2 +- ocr/app/engines/gemini_engine.py | 20 ++-- ocr/tests/test_resolve_vin_year.py | 94 +++++++++---------- 3 files changed, 60 insertions(+), 56 deletions(-) diff --git a/frontend/src/features/vehicles/api/vehicles.api.ts b/frontend/src/features/vehicles/api/vehicles.api.ts index 49ab6dd..3004455 100644 --- a/frontend/src/features/vehicles/api/vehicles.api.ts +++ b/frontend/src/features/vehicles/api/vehicles.api.ts @@ -87,7 +87,7 @@ export const vehiclesApi = { */ decodeVin: async (vin: string): Promise => { const response = await apiClient.post('/vehicles/decode-vin', { vin }, { - timeout: 60000 // 60 seconds for Gemini cold start + timeout: 120000 // 120 seconds for Gemini + Google Search grounding }); return response.data; } diff --git a/ocr/app/engines/gemini_engine.py b/ocr/app/engines/gemini_engine.py index e6f4cd5..8e9d36b 100644 --- a/ocr/app/engines/gemini_engine.py +++ b/ocr/app/engines/gemini_engine.py @@ -40,8 +40,9 @@ 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: numeric -> 2010+ cycle, alphabetic -> 1980s cycle. -# For the 2040+ cycle (when position 7 is alphabetic again), we pick the most +# 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, @@ -58,10 +59,10 @@ 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: - - Numeric position 7 -> 2010-2039 cycle - - Alphabetic position 7 -> 1980-2009 or 2040-2050+ cycle + - Alphabetic position 7 -> 2010-2039 cycle (NHTSA MY2010+ requirement) + - Numeric position 7 -> 1980-2009 or 2040-2069 cycle - For the alphabetic case with three possible cycles, picks the most recent + 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. @@ -76,11 +77,11 @@ def resolve_vin_year(vin: str) -> int | None: if base_year is None: return None - if pos7.isdigit(): - # Numeric position 7 -> second cycle (2010-2039) + if pos7.isalpha(): + # Alphabetic position 7 -> second cycle (2010-2039) return base_year + 30 - # Alphabetic position 7 -> first cycle (1980-2009) or third cycle (2040-2069) + # Numeric position 7 -> first cycle (1980-2009) or third cycle (2040-2069) # Pick the most recent plausible year max_plausible = datetime.now().year + 2 @@ -381,6 +382,9 @@ class GeminiEngine: response_mime_type="application/json", response_schema=_VIN_DECODE_SCHEMA, tools=[types.Tool(google_search=types.GoogleSearch())], + automatic_function_calling=types.AutomaticFunctionCallingConfig( + max_remote_calls=3, + ), ), ) diff --git a/ocr/tests/test_resolve_vin_year.py b/ocr/tests/test_resolve_vin_year.py index 54ea5f9..fcb63a6 100644 --- a/ocr/tests/test_resolve_vin_year.py +++ b/ocr/tests/test_resolve_vin_year.py @@ -12,86 +12,86 @@ from app.engines.gemini_engine import resolve_vin_year class TestSecondCycle: - """Position 7 numeric -> 2010-2039 cycle.""" + """Position 7 alphabetic -> 2010-2039 cycle (NHTSA MY2010+ requirement).""" - def test_p_with_numeric_pos7_returns_2023(self): - """P=2023 when position 7 is numeric (the bug that triggered this fix).""" - # VIN: 1G1YE2D32P5602473 -- pos7='2' (numeric), pos10='P' + 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_numeric_pos7_returns_2010(self): - """A=2010 when position 7 is numeric.""" - assert resolve_vin_year("1G1YE2112A5602473") == 2010 + 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_numeric_pos7_returns_2020(self): - """L=2020 when position 7 is numeric.""" - assert resolve_vin_year("1G1YE2112L5602473") == 2020 + 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_numeric_pos7_returns_2039(self): - """9=2039 when position 7 is numeric.""" - assert resolve_vin_year("1G1YE211295602473") == 2039 + 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_numeric_pos7_returns_2031(self): - """1=2031 when position 7 is numeric.""" - assert resolve_vin_year("1G1YE211215602473") == 2031 + 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_numeric_pos7_returns_2025(self): - """S=2025 when position 7 is numeric.""" - assert resolve_vin_year("1G1YE2112S5602473") == 2025 + 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_numeric_pos7_returns_2026(self): - """T=2026 when position 7 is numeric.""" - assert resolve_vin_year("1G1YE2112T5602473") == 2026 + 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 alphabetic -> 1980-2009 cycle (when 2040+ is not yet plausible).""" + """Position 7 numeric -> 1980-2009 cycle.""" - def test_m_with_alpha_pos7_returns_1991(self): - """M=1991 when position 7 is alphabetic (third cycle 2051 is not plausible).""" - assert resolve_vin_year("1G1YE2J32M5602473") == 1991 + 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_alpha_pos7_returns_1992(self): - """N=1992 when position 7 is alphabetic.""" - assert resolve_vin_year("1G1YE2J32N5602473") == 1992 + 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_alpha_pos7_returns_1993(self): - """P=1993 when position 7 is alphabetic (third cycle 2053 not plausible).""" - assert resolve_vin_year("1G1YE2J32P5602473") == 1993 + 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_alpha_pos7_returns_2000(self): - """Y=2000 when position 7 is alphabetic.""" - assert resolve_vin_year("1G1YE2J32Y5602473") == 2000 + 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 alphabetic + third cycle year (2040-2050) is plausible.""" + """Position 7 numeric + third cycle year (2040-2050) is plausible.""" @patch("app.engines.gemini_engine.datetime") - def test_a_with_alpha_pos7_returns_2040_when_plausible(self, mock_dt): - """A=2040 when position 7 is alphabetic and year 2040 is plausible.""" + 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("1G1YE2J32A5602473") == 2040 + assert resolve_vin_year("1G1YE2132A5602473") == 2040 @patch("app.engines.gemini_engine.datetime") - def test_l_with_alpha_pos7_returns_2050_when_plausible(self, mock_dt): - """L=2050 when position 7 is alphabetic and year 2050 is plausible.""" + 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("1G1YE2J32L5602473") == 2050 + assert resolve_vin_year("1G1YE2132L5602473") == 2050 @patch("app.engines.gemini_engine.datetime") - def test_a_with_alpha_pos7_returns_1980_when_2040_not_plausible(self, mock_dt): + 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("1G1YE2J32A5602473") == 1980 + assert resolve_vin_year("1G1YE2132A5602473") == 1980 @patch("app.engines.gemini_engine.datetime") - def test_k_with_alpha_pos7_returns_2049_when_plausible(self, mock_dt): - """K=2049 when position 7 is alphabetic and year is plausible.""" + 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("1G1YE2J32K5602473") == 2049 + assert resolve_vin_year("1G1YE2132K5602473") == 2049 class TestEdgeCases: