UX Improvements

This commit is contained in:
Brennan Wilkes (Text Groove) 2026-02-09 22:07:13 -08:00
parent cec8f1f35c
commit 13e691f0d0
2 changed files with 51 additions and 29 deletions

View file

@ -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;
@ -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 });
@ -587,6 +596,7 @@ export function recommendSimilar(
function fnv1a32u(str) {
let h = 0x811c9dc5;
str = String(str || "");

View file

@ -161,8 +161,9 @@ 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;
@ -170,6 +171,8 @@ export async function renderSkuLinker($app) {
let pinnedL = null;
let pinnedR = null;
@ -759,11 +762,20 @@ export async function renderSkuLinker($app) {
}
}
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;
}
}