fix(vinpin): reject stale-breadcrumb decodes (require SINCOM) + harden matcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A failed ePER VIN lookup ("vehicle not found") whose Cyrillic "не найден" alert
OCRs to garbage ("He HaaeHs") slipped the not-found check; the parser then
grabbed the operator's last-browsed catalog from the breadcrumb
("FIAT » TIPO-EGEA") and false-mapped it to a catalog — NM4131 landed on the
ancient "Tipo 1100-1370-1580 (1987-1993)", plus 2× PANDA. Every genuine decode
carries a SINCOM; the breadcrumb false positives never do.
- parser: isUsableParse now requires a SINCOM (a model alone is not a decode)
- matcher: modelTokens keeps 4-digit engine displacements (1100/1370/1580) —
only 1950-2039 count as years — so ancient catalogs no longer collapse to
["TIPO"] and win the fewest-extra-tokens tiebreak
- matcher: recency tiebreak on equal score — never fall back to an ancient
generation for a bare / year-less model
15 unit tests (parser + matcher) pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,18 @@ describe("modelTokens", () => {
|
||||
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
|
||||
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
|
||||
});
|
||||
|
||||
it("keeps engine displacements as tokens (only 1950-2039 count as years)", () => {
|
||||
// The 4-digit engine sizes must survive — dropping them made the ancient
|
||||
// "TIPO 1100-1370-1580" collapse to just ["TIPO"] and win the tiebreak.
|
||||
expect(modelTokens("TIPO 1100-1370-1580 (1987-1993)")).toEqual([
|
||||
"TIPO",
|
||||
"1100",
|
||||
"1370",
|
||||
"1580",
|
||||
]);
|
||||
expect(modelTokens("TIPO 1750-2000 (1990-1993)")).toEqual(["TIPO", "1750"]); // 2000 = year-range
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseYearRange", () => {
|
||||
@@ -52,4 +64,25 @@ describe("pickBestCatalogMatch", () => {
|
||||
it("returns null for an empty model", () => {
|
||||
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
|
||||
});
|
||||
|
||||
it("never falls back onto an ancient catalog for a bare, year-less model", () => {
|
||||
const withAncient: CatalogCandidate[] = [
|
||||
...candidates,
|
||||
{ id: "tipo-1987", model: "TIPO 1100-1370-1580 (1987-1993)", year: "1987-1993" },
|
||||
];
|
||||
// Bare "TIPO", no year (the exact shape of the NM4131 false positive that
|
||||
// used to land on the 1987 Tipo). Must resolve to a modern Egea instead.
|
||||
const id = pickBestCatalogMatch({ model: "TIPO", modelYear: null }, withAncient);
|
||||
expect(id).not.toBe("tipo-1987");
|
||||
expect(["egea", "egea-mca"]).toContain(id);
|
||||
});
|
||||
|
||||
it("breaks a score tie toward the newer catalog", () => {
|
||||
const puntos: CatalogCandidate[] = [
|
||||
{ id: "punto-old", model: "PUNTO (1993-1999)", year: "1993-1999" },
|
||||
{ id: "punto-new", model: "PUNTO (2012-2018)", year: "2012-2018" },
|
||||
];
|
||||
// Identical tokens + no year → equal score → recency decides.
|
||||
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,14 +26,18 @@ export interface DecodedForMatch {
|
||||
}
|
||||
|
||||
/** Tokenize a model string into upper-case model tokens, dropping parenthetical
|
||||
* groups (year ranges) and bare 4-digit years. "TIPO - EGEA (2015-2021)" →
|
||||
* ["TIPO","EGEA"]. Keeps "500" / "500X" (not 4-digit years). */
|
||||
* groups (year ranges) and bare 4-digit *years*. "TIPO - EGEA (2015-2021)" →
|
||||
* ["TIPO","EGEA"]. Keeps "500"/"500X" AND engine displacements like 1100 / 1370
|
||||
* / 1580 / 2400 — only 1950-2039 (plausible car years) are treated as years.
|
||||
* Dropping displacements made ancient catalogs ("TIPO 1100-1370-1580") collapse
|
||||
* to just ["TIPO"], looking falsely specific and winning the fewest-extra-tokens
|
||||
* tiebreak over the modern Egea. */
|
||||
export function modelTokens(s: string | null | undefined): string[] {
|
||||
if (!s) return [];
|
||||
return s
|
||||
.toUpperCase()
|
||||
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
|
||||
.replace(/\b\d{4}\b/g, " ") // drop bare years (keeps 3-digit "500")
|
||||
.replace(/\b(19[5-9]\d|20[0-3]\d)\b/g, " ") // drop bare YEARS 1950-2039; keep displacements
|
||||
.replace(/[^A-Z0-9]+/g, " ")
|
||||
.split(" ")
|
||||
.map((t) => t.trim())
|
||||
@@ -81,7 +85,7 @@ export function pickBestCatalogMatch(
|
||||
if (decTokens.length === 0) return null;
|
||||
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
|
||||
|
||||
let best: { id: string; score: number } | null = null;
|
||||
let best: { id: string; score: number; startYear: number } | null = null;
|
||||
|
||||
for (const cand of candidates) {
|
||||
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
|
||||
@@ -100,8 +104,12 @@ export function pickBestCatalogMatch(
|
||||
const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
|
||||
score -= extra;
|
||||
|
||||
if (!best || score > best.score) {
|
||||
best = { id: cand.id, score };
|
||||
// Final tiebreak on equal score: prefer the newer catalog. Without a year
|
||||
// signal an ambiguous model (e.g. bare "TIPO") must never fall back onto an
|
||||
// ancient generation — modern is the overwhelmingly likelier intent.
|
||||
const startYear = range?.start ?? 0;
|
||||
if (!best || score > best.score || (score === best.score && startYear > best.startYear)) {
|
||||
best = { id: cand.id, score, startYear };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,4 +35,18 @@ describe("parseVinpinModal", () => {
|
||||
expect(isUsableParse(p)).toBe(false);
|
||||
expect(isUsableParse(parseVinpinModal(null))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a stale-breadcrumb model with no SINCOM (vehicle-not-found false positive)", () => {
|
||||
// Real garbled OCR of a failed NM4131 decode: the "не найден" alert is
|
||||
// mangled ("He HaaeHs") so it slips the not-found check, and the screen
|
||||
// still shows the operator's last catalog ("FIAT » TIPO - EGEA"). A model
|
||||
// token is present but there is NO SINCOM → must be treated as unusable so
|
||||
// it is not false-mapped onto the ancient "TIPO 1987-1993" catalog.
|
||||
const p = parseVinpinModal(
|
||||
"Fiat Dealer Spare Parts FIAT TIPO - EGBA 500 HYBRID TIPO-EGEA MCA (2020) DOBLO FREEMONT LINEA IDEA To yKasaHHeIM N3paMETpaM 3BTOMOBHMN He HaaeHs",
|
||||
);
|
||||
expect(p.model).not.toBeNull(); // breadcrumb model still extracted…
|
||||
expect(p.sincom).toBeNull(); // …but no SINCOM
|
||||
expect(isUsableParse(p)).toBe(false); // → not a real decode
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,18 @@ export function parseVinpinModal(rawText: string | null | undefined): VinpinPars
|
||||
return { model, sincom, trim, modelYear, engine };
|
||||
}
|
||||
|
||||
/** A parse is usable if we got at least a model or a SINCOM code. */
|
||||
/**
|
||||
* A parse is usable only when we got a SINCOM code — the trim identifier that a
|
||||
* genuine ePER decode always emits ("356G37001162"). A model *alone* is a stale
|
||||
* breadcrumb: when the VIN search returns "vehicle not found" (a TR NM4 VIN ePER
|
||||
* can't resolve), the screen still shows whatever catalog the operator was last
|
||||
* browsing (e.g. "FIAT » TIPO - EGEA"), and the OCR of the Cyrillic "не найден"
|
||||
* alert garbles enough to slip past the not-found check. Grabbing that
|
||||
* breadcrumb model ("TIPO"/"PANDA") and matching it to a catalog is a false
|
||||
* positive that lands users on the wrong (often ancient) catalog. Requiring the
|
||||
* SINCOM cleanly separates the two: every genuine decode carries one; the
|
||||
* breadcrumb false positives never do.
|
||||
*/
|
||||
export function isUsableParse(p: VinpinParsed): boolean {
|
||||
return Boolean(p.model || p.sincom);
|
||||
return Boolean(p.sincom);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user