From 13e691f0d01bc7a2d7be6144c77a3c9d1d91bf0a Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Mon, 9 Feb 2026 22:07:13 -0800 Subject: [PATCH] UX Improvements --- viz/app/linker/suggestions.js | 56 +++++++++++++++++++++-------------- viz/app/linker_page.js | 24 +++++++++++---- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/viz/app/linker/suggestions.js b/viz/app/linker/suggestions.js index 51941cf..9fbd5af 100644 --- a/viz/app/linker/suggestions.js +++ b/viz/app/linker/suggestions.js @@ -268,13 +268,15 @@ export function recommendSimilar( + export function computeInitialPairsFast( allAgg, mappedSkus, limitPairs, isIgnoredPairFn, sameStoreFn, - sizePenaltyFn, // ✅ NEW: pass sizePenaltyForPair in + sameGroupFn, // ✅ NEW + sizePenaltyFn, pricePenaltyFn ) { const itemsAll = allAgg.filter((it) => !!it); @@ -302,13 +304,17 @@ export function recommendSimilar( return stores * 3 + hasPrice * 2 + hasName * 0.5 + unknown * 0.25; } - // --- SMWS exact-code pairs first (kept as-is, but apply sameStore/isIgnored) --- + // --- SMWS exact-code pairs first (now blocks sameGroup + mapped) --- function smwsPairsFirst(workArr, limit) { const buckets = new Map(); // code -> items[] for (const it of workArr) { if (!it) continue; const sku = String(it.sku || ""); if (!sku) continue; + + // ✅ NEW: keep SMWS stage unmapped-only + if (mappedSkus && mappedSkus.has(sku)) continue; + const code = smwsKeyFromName(it.name || ""); if (!code) continue; let arr = buckets.get(code); @@ -326,7 +332,6 @@ export function recommendSimilar( .sort((a, b) => itemRank(b) - itemRank(a)) .slice(0, 80); - // Prefer an unmapped anchor if possible; otherwise best overall const anchor = arr.slice().sort((a, b) => itemRank(b) - itemRank(a))[0]; if (!anchor) continue; @@ -338,12 +343,15 @@ export function recommendSimilar( const bSku = String(b.sku || ""); if (!aSku || !bSku || aSku === bSku) continue; - // Only link *unmapped* targets in this stage - // if (mappedSkus && mappedSkus.has(bSku)) continue; - if (typeof sameStoreFn === "function" && sameStoreFn(aSku, bSku)) continue; if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue; + // ✅ NEW: do not suggest if already linked + if (typeof sameGroupFn === "function" && sameGroupFn(aSku, bSku)) continue; + + // ✅ NEW: extra safety (should already be unmapped-only, but keep) + if (mappedSkus && (mappedSkus.has(aSku) || mappedSkus.has(bSku))) continue; + const s = 1e9 + itemRank(a) + itemRank(b); candPairs.push({ a, b, score: s }); } @@ -365,23 +373,24 @@ export function recommendSimilar( return { pairs: out0, usedUnmapped }; } - const smwsFirst = smwsPairsFirst(workAll, limitPairs); + // ✅ CHANGED: SMWS stage now runs on `work` (unmapped-only), not `workAll` + const smwsFirst = smwsPairsFirst(work, limitPairs); const used = new Set(smwsFirst.usedUnmapped); const out = smwsFirst.pairs.slice(); if (out.length >= limitPairs) return out.slice(0, limitPairs); - // --- Improved general pairing logic (uses same “good” scoring knobs) --- + // --- Improved general pairing logic --- const seeds = topSuggestions(work, Math.min(220, work.length), "", mappedSkus).filter( (it) => !used.has(String(it?.sku || "")) ); - // Build token buckets over *normalized* names (better hits) + // Build token buckets over normalized names const TOKEN_BUCKET_CAP = 700; - const tokMap = new Map(); // token -> items[] - const itemRawToks = new Map(); // sku -> raw tokens - const itemNorm = new Map(); // sku -> norm name - const itemFilt = new Map(); // sku -> filtered tokens (for first-token logic) + const tokMap = new Map(); // token -> items[] + const itemRawToks = new Map(); // sku -> raw tokens + const itemNorm = new Map(); // sku -> norm name + const itemFilt = new Map(); // sku -> filtered tokens for (const it of work) { const sku = String(it.sku || ""); @@ -395,7 +404,6 @@ export function recommendSimilar( itemRawToks.set(sku, raw); itemFilt.set(sku, filt); - // bucket using a handful of filtered tokens (higher signal) for (const t of filt.slice(0, 12)) { let arr = tokMap.get(t); if (!arr) tokMap.set(t, (arr = [])); @@ -405,7 +413,6 @@ export function recommendSimilar( const bestByPair = new Map(); const MAX_CAND_TOTAL = 450; - const MAX_CHEAP = 40; const MAX_FINE = 18; for (const a of seeds) { @@ -432,18 +439,20 @@ export function recommendSimilar( const bSku = String(b.sku || ""); if (!bSku || bSku === aSku) continue; if (used.has(bSku)) continue; - // if (mappedSkus && mappedSkus.has(bSku)) continue; if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue; if (typeof sameStoreFn === "function" && sameStoreFn(aSku, bSku)) continue; + // ✅ NEW: block already-linked groups here too + if (typeof sameGroupFn === "function" && sameGroupFn(aSku, bSku)) continue; + cand.set(bSku, b); } if (cand.size >= MAX_CAND_TOTAL) break; } if (!cand.size) continue; - // Cheap score stage (fastSimilarity + containment + size + age + first-token mismatch penalty) + // Cheap score stage const cheap = []; for (const b of cand.values()) { const bSku = String(b.sku || ""); @@ -467,7 +476,6 @@ export function recommendSimilar( } if (typeof sizePenaltyFn === "function") s *= sizePenaltyFn(aSku, bSku); - if (typeof pricePenaltyFn === "function") s *= pricePenaltyFn(aSku, bSku); const bAge = extractAgeFromText(bNorm); @@ -484,7 +492,7 @@ export function recommendSimilar( if (!cheap.length) continue; cheap.sort((x, y) => y.s - x.s); - // Fine stage (expensive similarityScore + same penalties again) + // Fine stage let bestB = null; let bestS = 0; @@ -495,7 +503,6 @@ export function recommendSimilar( let s = similarityScore(a.name || "", b.name || ""); if (s <= 0) continue; - // first-token mismatch soft penalty if (!x.firstMatch) { const smallN = Math.min(aFilt.length || 0, (x.bFilt || []).length || 0); let mult = 0.10 + 0.95 * x.contain; @@ -508,7 +515,7 @@ export function recommendSimilar( s *= sizePenaltyFn(aSku, bSku); if (s <= 0) continue; } - + if (typeof pricePenaltyFn === "function") { s *= pricePenaltyFn(aSku, bSku); if (s <= 0) continue; @@ -527,7 +534,6 @@ export function recommendSimilar( } } - // Threshold (slightly lower than before, because we now punish mismatches more intelligently) if (!bestB || bestS < 0.50) continue; const bSku = String(bestB.sku || ""); @@ -541,7 +547,7 @@ export function recommendSimilar( const pairs = Array.from(bestByPair.values()); pairs.sort((x, y) => y.score - x.score); - // ---- light randomness inside a top band (same behavior as before) ---- + // ---- light randomness inside a top band ---- const need = Math.max(0, limitPairs - out.length); if (!need) return out.slice(0, limitPairs); @@ -562,6 +568,9 @@ export function recommendSimilar( if (typeof sameStoreFn === "function" && sameStoreFn(aSku, bSku)) return false; if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) return false; + // ✅ NEW: block already-linked groups here too + if (typeof sameGroupFn === "function" && sameGroupFn(aSku, bSku)) return false; + used.add(aSku); used.add(bSku); out.push({ a: p.a, b: p.b, score: p.score }); @@ -583,6 +592,7 @@ export function recommendSimilar( return out.slice(0, limitPairs); } + diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index 71a6c0e..e591941 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -161,13 +161,16 @@ export async function renderSkuLinker($app) { 28, isIgnoredPair, sameStoreCanon, - sizePenaltyForPair, // ✅ NEW - pricePenaltyForPair // ✅ NEW + sameGroup, // ✅ NEW: hard-block already-linked pairs (incl SMWS stage) + sizePenaltyForPair, + pricePenaltyForPair ); return initialPairs; } + + let pinnedL = null; @@ -748,7 +751,7 @@ export async function renderSkuLinker($app) { function buildMappedSkuSet(links, rules0) { const s = new Set(); - + function add(k) { const x = String(k || "").trim(); if (!x) return; @@ -758,12 +761,21 @@ export async function renderSkuLinker($app) { if (c) s.add(c); } } - - for (const x of Array.isArray(links) ? links : []) { + + // ✅ NEW: always include rules.links (meta can be incomplete) + const merged = [ + ...((rules0 && Array.isArray(rules0.links)) ? rules0.links : []), + ...(Array.isArray(links) ? links : []), + ]; + + for (const x of merged) { add(x?.fromSku); add(x?.toSku); } - + return s; } + + + }