diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index daab4e8..9e8d20d 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -1,8 +1,20 @@ import { esc, renderThumbHtml } from "./dom.js"; -import { tokenizeQuery, matchesAllTokens, isUnknownSkuKey, displaySku, keySkuForRow, normSearchText } from "./sku.js"; +import { + tokenizeQuery, + matchesAllTokens, + isUnknownSkuKey, + displaySku, + keySkuForRow, + normSearchText, +} from "./sku.js"; import { loadIndex } from "./state.js"; import { aggregateBySku } from "./catalog.js"; -import { isLocalWriteMode, loadSkuMetaBestEffort, apiWriteSkuLink, apiWriteSkuIgnore } from "./api.js"; +import { + isLocalWriteMode, + loadSkuMetaBestEffort, + apiWriteSkuLink, + apiWriteSkuIgnore, +} from "./api.js"; import { loadSkuRules } from "./mapping.js"; /* ---------------- Similarity helpers ---------------- */ @@ -10,7 +22,8 @@ import { loadSkuRules } from "./mapping.js"; function levenshtein(a, b) { a = String(a || ""); b = String(b || ""); - const n = a.length, m = b.length; + const n = a.length, + m = b.length; if (!n) return m; if (!m) return n; @@ -62,11 +75,28 @@ function fastSimilarityScore(aTokens, bTokens, aNormName, bNormName) { const a = String(aNormName || ""); const b = String(bNormName || ""); - const pref = a.slice(0, 10) && b.slice(0, 10) && a.slice(0, 10) === b.slice(0, 10) ? 0.2 : 0; + const pref = + a.slice(0, 10) && b.slice(0, 10) && a.slice(0, 10) === b.slice(0, 10) + ? 0.2 + : 0; return overlap * 2.0 + pref; } +/* ---------------- Store-overlap rule ---------------- */ + +function storesOverlap(aItem, bItem) { + const a = aItem?.stores; + const b = bItem?.stores; + if (!a || !b) return false; + + // stores are Set(storeLabel). Exact-label overlap is the intended rule. + for (const s of a) { + if (b.has(s)) return true; + } + return false; +} + /* ---------------- Mapping helpers ---------------- */ function buildMappedSkuSet(links) { @@ -83,7 +113,9 @@ function buildMappedSkuSet(links) { function openLinkHtml(url) { const u = String(url || "").trim(); if (!u) return ""; - return `open`; + return `open`; } function isBCStoreLabel(label) { @@ -120,8 +152,16 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { return scored.slice(0, limit).map((x) => x.it); } -function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) { - if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); +function recommendSimilar( + allAgg, + pinned, + limit, + otherPinnedSku, + mappedSkus, + isIgnoredPairFn +) { + if (!pinned || !pinned.name) + return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); const base = String(pinned.name || ""); const pinnedSku = String(pinned.sku || ""); @@ -134,7 +174,11 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI if (it.sku === pinned.sku) continue; if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; - if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) continue; + // NEW: never suggest same-store pairs + if (storesOverlap(pinned, it)) continue; + + if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) + continue; const s = similarityScore(base, it.name || ""); if (s > 0) scored.push({ it, s }); @@ -143,7 +187,7 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI return scored.slice(0, limit).map((x) => x.it); } -// FAST initial pairing (approx) with ignore-pair exclusion +// FAST initial pairing (approx) with ignore-pair exclusion + same-store exclusion function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) { const items = allAgg.filter((it) => { if (!it) return false; @@ -160,7 +204,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn const itemNormName = new Map(); for (const it of items) { - const toks = Array.from(new Set(tokenizeQuery(it.name || ""))).filter(Boolean).slice(0, 10); + const toks = Array.from(new Set(tokenizeQuery(it.name || ""))) + .filter(Boolean) + .slice(0, 10); itemTokens.set(it.sku, toks); itemNormName.set(it.sku, normSearchText(it.name || "")); for (const t of toks) { @@ -183,6 +229,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn for (const t of aToks) { const arr = tokMap.get(t); if (!arr) continue; + for (let i = 0; i < arr.length && cand.size < MAX_CAND_TOTAL; i++) { const b = arr[i]; if (!b) continue; @@ -193,6 +240,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue; + // NEW: never suggest same-store pairs + if (storesOverlap(a, b)) continue; + cand.set(bSku, b); } if (cand.size >= MAX_CAND_TOTAL) break; @@ -239,6 +289,10 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn const bSku = String(p.b.sku || ""); if (!aSku || !bSku || aSku === bSku) continue; if (used.has(aSku) || used.has(bSku)) continue; + + // NEW: extra guard + if (storesOverlap(p.a, p.b)) continue; + used.add(aSku); used.add(bSku); out.push({ a: p.a, b: p.b, score: p.score }); @@ -264,7 +318,7 @@ export async function renderSkuLinker($app) {