fix(vinpin): prefer exact model over wrong-market catalog in 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
The Vinpin catalog matcher could route a European Renault VIN (VF1RFE00653633190, decoded "KADJAR") onto the China-market catalog "KADJAR ÇİN" (source XZH, 22 categories) instead of the correct European "KADJAR" (XFE, 44 categories), serving wrong parts. Root cause: the Turkish "ÇİN" lost its Ç/İ to the ASCII token strip and collapsed to a dropped 1-char "N", so "KADJAR ÇİN" tokenized identically to "KADJAR" — the market qualifier was invisible to the scorer. Fixes: - modelTokens now folds diacritics (NFD + combining-mark strip) so "ÇİN" survives as the ASCII token "CIN". - Scorer prefers an EXACT normalized model match (no extra tokens) over a superset, via a bonus kept smaller than the year-range swing so multi-generation routing (2022 TIPO-EGEA → MCA) is unaffected. - New MARKET_QUALIFIERS set (ÇİN/CIN, CHINA, CHINE, RUSYA, ... grounded in the real Renault/Dacia catalog rows) heavily penalizes candidates whose EXTRA tokens are region qualifiers; generation tokens are not penalized, so generations stay matchable. - Richer-catalog (categoryCount) tiebreak among equal-score candidates; match() query selects the category count via a correlated subquery. Adds unit tests incl. the KADJAR case, CLIO/DUSTER generation cases, and diacritic-folding. Fiat behavior unchanged; all 39 vinpin tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -85,4 +85,103 @@ describe("pickBestCatalogMatch", () => {
|
||||
// Identical tokens + no year → equal score → recency decides.
|
||||
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
|
||||
});
|
||||
|
||||
it("breaks a score tie toward the richer catalog (more categories)", () => {
|
||||
const koleos: CatalogCandidate[] = [
|
||||
{ id: "koleos-thin", model: "KOLEOS", year: null, categoryCount: 12 },
|
||||
{ id: "koleos-full", model: "KOLEOS", year: null, categoryCount: 44 },
|
||||
];
|
||||
// Identical tokens + no year → equal score → category count decides.
|
||||
expect(pickBestCatalogMatch({ model: "KOLEOS", modelYear: null }, koleos)).toBe("koleos-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickBestCatalogMatch — market-qualifier precision (Renault/Dacia)", () => {
|
||||
it("prefers the EXACT model over a wrong-market superset (KADJAR beats KADJAR ÇİN)", () => {
|
||||
// The confirmed prod bug: a European VF1 Renault decoded as "KADJAR" landed
|
||||
// on the China-market "KADJAR ÇİN" catalog (fewer categories, wrong parts).
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 22 },
|
||||
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 44 },
|
||||
];
|
||||
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: "2018" }, candidates)).toBe(
|
||||
"kadjar-eu",
|
||||
);
|
||||
});
|
||||
|
||||
it("still picks the exact model even when the market candidate is richer", () => {
|
||||
// Market penalty must dominate — an exact match wins regardless of category
|
||||
// count or ordering.
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 999 },
|
||||
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 1 },
|
||||
];
|
||||
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: null }, candidates)).toBe(
|
||||
"kadjar-eu",
|
||||
);
|
||||
});
|
||||
|
||||
it("penalizes a market qualifier but NOT a generation token (CLIO)", () => {
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
||||
{ id: "clio", model: "CLIO", year: null },
|
||||
];
|
||||
// Exact "CLIO" beats the China-market superset.
|
||||
expect(pickBestCatalogMatch({ model: "CLIO", modelYear: null }, candidates)).toBe("clio");
|
||||
});
|
||||
|
||||
it("keeps generations matchable — a generation extra token is not a market penalty", () => {
|
||||
// Decoded "CLIO" with only generation-qualified catalogs (no exact bare CLIO)
|
||||
// and one China catalog. The generation candidate must win over the market one,
|
||||
// proving the market penalty doesn't collateral-damage generations.
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
||||
{
|
||||
id: "clio-4",
|
||||
model: "CLIO 4 / LUTECIA 4 (2012-2019)",
|
||||
year: "2012-2019",
|
||||
categoryCount: 40,
|
||||
},
|
||||
{
|
||||
id: "clio-3",
|
||||
model: "CLIO 3 / LUTECIA 3 (2005-2014)",
|
||||
year: "2005-2014",
|
||||
categoryCount: 30,
|
||||
},
|
||||
];
|
||||
const id = pickBestCatalogMatch({ model: "CLIO", modelYear: "2015" }, candidates);
|
||||
// Year-overlap picks the 2012-2019 gen-4; the China catalog is penalized out.
|
||||
expect(id).toBe("clio-4");
|
||||
});
|
||||
|
||||
it("matches an exact generation model and penalizes only the market superset (DUSTER II)", () => {
|
||||
// Roman-numeral generations survive tokenization (single digits are dropped by
|
||||
// the length>=2 filter, an existing behavior). Decoded "DUSTER II" is exact on
|
||||
// duster-2; "DUSTER II ÇİN" carries an extra market token and is penalized out.
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "duster-2", model: "DUSTER II", year: null, categoryCount: 30 },
|
||||
{ id: "duster-cn", model: "DUSTER II ÇİN", year: null, categoryCount: 30 },
|
||||
];
|
||||
expect(pickBestCatalogMatch({ model: "DUSTER II", modelYear: null }, candidates)).toBe(
|
||||
"duster-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a market catalog only when it is the sole candidate", () => {
|
||||
// If the ONLY catalog for a decoded model is a regional one, still match it —
|
||||
// the penalty lowers the score but does not disqualify.
|
||||
const candidates: CatalogCandidate[] = [{ id: "x62-cn", model: "X62 CHINE", year: null }];
|
||||
expect(pickBestCatalogMatch({ model: "X62", modelYear: null }, candidates)).toBe("x62-cn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelTokens — diacritic folding", () => {
|
||||
it("folds Turkish diacritics so market qualifiers survive as ASCII tokens", () => {
|
||||
// Without folding, ÇİN loses Ç/İ to the ASCII strip and collapses to a
|
||||
// dropped 1-char "N", making KADJAR ÇİN tokenize identically to KADJAR.
|
||||
expect(modelTokens("KADJAR ÇİN")).toEqual(["KADJAR", "CIN"]);
|
||||
expect(modelTokens("ARKANA RUSYA")).toEqual(["ARKANA", "RUSYA"]);
|
||||
// The single-digit "2" is dropped by the length>=2 filter (existing behavior).
|
||||
expect(modelTokens("KOLEOS 2 - ÇİN")).toEqual(["KOLEOS", "CIN"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,62 @@ export interface CatalogCandidate {
|
||||
id: string;
|
||||
model: string | null;
|
||||
year: string | null;
|
||||
/** How many catalog categories this vehicle has fetched. Used only as a final
|
||||
* tiebreak among otherwise-equal candidates — the fuller catalog is the
|
||||
* mainstream one. Optional so the pure picker can be unit-tested without it. */
|
||||
categoryCount?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Market/region qualifier tokens (diacritic-folded, upper-case). A catalog model
|
||||
* that carries one of these tokens BEYOND the decoded model targets a specific
|
||||
* regional market — e.g. "KADJAR ÇİN" is the China-market Kadjar catalog (source
|
||||
* XZH, 22 categories) vs the mainstream European "KADJAR" (XFE, 44 categories).
|
||||
* For a European (VF1…) VIN the regional catalog serves the wrong parts, so a
|
||||
* candidate whose EXTRA tokens are region qualifiers is heavily penalized — but
|
||||
* only when the token is extra (the decode itself doesn't carry a market today).
|
||||
*
|
||||
* Generation tokens (roman numerals, digits, platform codes like "II"/"3"/"XM3")
|
||||
* are deliberately NOT here, so multi-generation models stay fully matchable;
|
||||
* only MARKET qualifiers get the penalty.
|
||||
*
|
||||
* Grounded in prod data — the real Renault/Dacia catalog models that carry a
|
||||
* region qualifier are: KADJAR ÇİN, CAPTUR II ÇİN, KOLEOS 2 - ÇİN, ARKANA RUSYA
|
||||
* and X62 CHINE. ("EUROPE" appears too — ARKANA EUROPE, CAPTUR II EUROPE — but
|
||||
* that is the mainstream market and is never penalized.)
|
||||
*/
|
||||
export const MARKET_QUALIFIERS = new Set<string>([
|
||||
// China — "ÇİN" folds to CIN via diacritic normalization; CHINE is the French
|
||||
// spelling seen in "X62 CHINE".
|
||||
"CIN",
|
||||
"CHINA",
|
||||
"CHINE",
|
||||
"CN",
|
||||
// Russia
|
||||
"RUSYA",
|
||||
"RUSSIA",
|
||||
"RU",
|
||||
// Brazil
|
||||
"BREZILYA",
|
||||
"BRAZIL",
|
||||
"BR",
|
||||
// India
|
||||
"HINDISTAN",
|
||||
"INDIA",
|
||||
"IN",
|
||||
// Korea
|
||||
"KORE",
|
||||
"KOREA",
|
||||
// Mexico / Mercosur
|
||||
"MEKSIKA",
|
||||
"MEXICO",
|
||||
"MERCOSUR",
|
||||
// Other regional
|
||||
"GCC",
|
||||
"USA",
|
||||
"AMERIKA",
|
||||
]);
|
||||
|
||||
export interface DecodedForMatch {
|
||||
model: string | null;
|
||||
modelYear: string | null;
|
||||
@@ -37,14 +91,23 @@ export interface DecodedForMatch {
|
||||
* 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(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())
|
||||
.filter((t) => t.length >= 2);
|
||||
return (
|
||||
s
|
||||
.toUpperCase()
|
||||
// Fold diacritics to ASCII so market qualifiers survive tokenization. Without
|
||||
// this the Turkish "ÇİN" (China) loses its Ç/İ to the [^A-Z0-9] strip below
|
||||
// and collapses to a dropped 1-char "N" — making "KADJAR ÇİN" tokenize
|
||||
// IDENTICALLY to "KADJAR" and defeating both the specificity tiebreak and the
|
||||
// market penalty. NFD + combining-mark removal maps ÇİN→CIN, Ş→S, Ğ→G, Ö→O …
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}/gu, "") // strip the combining marks NFD split off
|
||||
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
|
||||
.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())
|
||||
.filter((t) => t.length >= 2)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse a year range from free text. Returns {start, end} where end===null
|
||||
@@ -77,8 +140,18 @@ function yearInRange(year: number, range: { start: number; end: number | null })
|
||||
/**
|
||||
* Pick the best catalog_vehicles row for a decoded model + year. A candidate
|
||||
* qualifies only when EVERY decoded model token appears in the candidate's
|
||||
* model tokens (subset match). Among qualifiers, prefer year-range overlap,
|
||||
* then the most specific (fewest extra tokens). Returns the id or null.
|
||||
* model tokens (subset match). Scoring, strongest signal first:
|
||||
* 1. Year-range overlap — the strongest signal; routes multi-generation models
|
||||
* (a 2022 "TIPO-EGEA" → the 2020+ MCA catalog, not the 2015-2021 one).
|
||||
* 2. EXACT normalized model match (candidate tokens ≡ decoded tokens) — a
|
||||
* smaller bonus that breaks the tie in favour of a plain "KADJAR" over the
|
||||
* superset "KADJAR ÇİN" when no year distinguishes them.
|
||||
* 3. Market-qualifier penalty — extra tokens that are region qualifiers (ÇİN,
|
||||
* RUSYA, …) make this a WRONG-market catalog; penalized hard so it only ever
|
||||
* wins as the sole candidate. Generation / other extra tokens keep only the
|
||||
* mild "fewer extras = more specific" penalty, so generations stay matchable.
|
||||
* 4. Tiebreaks on equal score: richer catalog (more categories), then newer.
|
||||
* Returns the id or null.
|
||||
*/
|
||||
export function pickBestCatalogMatch(
|
||||
decoded: DecodedForMatch,
|
||||
@@ -88,7 +161,12 @@ 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; startYear: number } | null = null;
|
||||
let best: {
|
||||
id: string;
|
||||
score: number;
|
||||
categoryCount: number;
|
||||
startYear: number;
|
||||
} | null = null;
|
||||
|
||||
for (const cand of candidates) {
|
||||
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
|
||||
@@ -98,21 +176,47 @@ export function pickBestCatalogMatch(
|
||||
// Subset requirement — all decoded model tokens must be present.
|
||||
if (!decTokens.every((t) => candSet.has(t))) continue;
|
||||
|
||||
const extraTokens = candTokens.filter((t) => !decTokens.includes(t));
|
||||
|
||||
let score = 1000; // base for any qualifying candidate
|
||||
|
||||
// (1) EXACT normalized model-name match: candidate carries NO tokens beyond
|
||||
// the decoded model. This breaks the tie in favour of the plain model over a
|
||||
// superset (e.g. "KADJAR" over "KADJAR ÇİN" when neither has a distinguishing
|
||||
// year). Kept SMALLER than the year-range swing (±700) on purpose, so a
|
||||
// decisive model-year still routes generations correctly — a 2022 "TIPO-EGEA"
|
||||
// must still land on the 2020+ MCA catalog, not the exact-named 2015-2021 one.
|
||||
if (extraTokens.length === 0) score += 300;
|
||||
|
||||
// (2) Year-range overlap.
|
||||
const range = parseYearRange(candText);
|
||||
if (decYear && Number.isFinite(decYear) && range) {
|
||||
score += yearInRange(decYear, range) ? 500 : -200;
|
||||
}
|
||||
// Tiebreak: fewer extra tokens = more specific match.
|
||||
const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
|
||||
score -= extra;
|
||||
|
||||
// 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.
|
||||
// (3) Extra-token penalties. A region-qualifier extra token means the
|
||||
// candidate is a wrong-market catalog for a decode that carries no market —
|
||||
// penalize hard so it can only win when it is the ONLY candidate. Everything
|
||||
// else (generation numerals, platform codes) keeps the mild specificity
|
||||
// penalty, leaving multi-generation models fully matchable.
|
||||
const marketExtras = extraTokens.filter((t) => MARKET_QUALIFIERS.has(t)).length;
|
||||
const otherExtras = extraTokens.length - marketExtras;
|
||||
score -= otherExtras;
|
||||
score -= marketExtras * 5000;
|
||||
|
||||
// (4) Tiebreaks on equal score: prefer the richer catalog (more categories —
|
||||
// the fuller catalog is the mainstream one), then the newer generation. A
|
||||
// bare, year-less model (e.g. "TIPO") must never fall back onto an ancient
|
||||
// generation — modern is the overwhelmingly likelier intent.
|
||||
const categoryCount = cand.categoryCount ?? 0;
|
||||
const startYear = range?.start ?? 0;
|
||||
if (!best || score > best.score || (score === best.score && startYear > best.startYear)) {
|
||||
best = { id: cand.id, score, startYear };
|
||||
const better =
|
||||
!best ||
|
||||
score > best.score ||
|
||||
(score === best.score && categoryCount > best.categoryCount) ||
|
||||
(score === best.score && categoryCount === best.categoryCount && startYear > best.startYear);
|
||||
if (better) {
|
||||
best = { id: cand.id, score, categoryCount, startYear };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +241,12 @@ export class VinpinCatalogMatcher {
|
||||
id: catalogVehicles.id,
|
||||
model: catalogVehicles.model,
|
||||
year: catalogVehicles.year,
|
||||
// Category count feeds the richer-catalog tiebreak (more categories = the
|
||||
// mainstream catalog). Correlated subquery keeps the row shape flat.
|
||||
categoryCount: sql<number>`(
|
||||
SELECT count(*)::int FROM categories
|
||||
WHERE categories.catalog_vehicle_id = ${catalogVehicles.id}
|
||||
)`,
|
||||
})
|
||||
.from(catalogVehicles)
|
||||
.where(
|
||||
|
||||
Reference in New Issue
Block a user