mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
UX Improvements
This commit is contained in:
parent
cec8f1f35c
commit
13e691f0d0
2 changed files with 51 additions and 29 deletions
|
|
@ -268,13 +268,15 @@ export function recommendSimilar(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function computeInitialPairsFast(
|
export function computeInitialPairsFast(
|
||||||
allAgg,
|
allAgg,
|
||||||
mappedSkus,
|
mappedSkus,
|
||||||
limitPairs,
|
limitPairs,
|
||||||
isIgnoredPairFn,
|
isIgnoredPairFn,
|
||||||
sameStoreFn,
|
sameStoreFn,
|
||||||
sizePenaltyFn, // ✅ NEW: pass sizePenaltyForPair in
|
sameGroupFn, // ✅ NEW
|
||||||
|
sizePenaltyFn,
|
||||||
pricePenaltyFn
|
pricePenaltyFn
|
||||||
) {
|
) {
|
||||||
const itemsAll = allAgg.filter((it) => !!it);
|
const itemsAll = allAgg.filter((it) => !!it);
|
||||||
|
|
@ -302,13 +304,17 @@ export function recommendSimilar(
|
||||||
return stores * 3 + hasPrice * 2 + hasName * 0.5 + unknown * 0.25;
|
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) {
|
function smwsPairsFirst(workArr, limit) {
|
||||||
const buckets = new Map(); // code -> items[]
|
const buckets = new Map(); // code -> items[]
|
||||||
for (const it of workArr) {
|
for (const it of workArr) {
|
||||||
if (!it) continue;
|
if (!it) continue;
|
||||||
const sku = String(it.sku || "");
|
const sku = String(it.sku || "");
|
||||||
if (!sku) continue;
|
if (!sku) continue;
|
||||||
|
|
||||||
|
// ✅ NEW: keep SMWS stage unmapped-only
|
||||||
|
if (mappedSkus && mappedSkus.has(sku)) continue;
|
||||||
|
|
||||||
const code = smwsKeyFromName(it.name || "");
|
const code = smwsKeyFromName(it.name || "");
|
||||||
if (!code) continue;
|
if (!code) continue;
|
||||||
let arr = buckets.get(code);
|
let arr = buckets.get(code);
|
||||||
|
|
@ -326,7 +332,6 @@ export function recommendSimilar(
|
||||||
.sort((a, b) => itemRank(b) - itemRank(a))
|
.sort((a, b) => itemRank(b) - itemRank(a))
|
||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
|
|
||||||
// Prefer an unmapped anchor if possible; otherwise best overall
|
|
||||||
const anchor = arr.slice().sort((a, b) => itemRank(b) - itemRank(a))[0];
|
const anchor = arr.slice().sort((a, b) => itemRank(b) - itemRank(a))[0];
|
||||||
if (!anchor) continue;
|
if (!anchor) continue;
|
||||||
|
|
||||||
|
|
@ -338,12 +343,15 @@ export function recommendSimilar(
|
||||||
const bSku = String(b.sku || "");
|
const bSku = String(b.sku || "");
|
||||||
if (!aSku || !bSku || aSku === bSku) continue;
|
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 sameStoreFn === "function" && sameStoreFn(aSku, bSku)) continue;
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(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);
|
const s = 1e9 + itemRank(a) + itemRank(b);
|
||||||
candPairs.push({ a, b, score: s });
|
candPairs.push({ a, b, score: s });
|
||||||
}
|
}
|
||||||
|
|
@ -365,23 +373,24 @@ export function recommendSimilar(
|
||||||
return { pairs: out0, usedUnmapped };
|
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 used = new Set(smwsFirst.usedUnmapped);
|
||||||
const out = smwsFirst.pairs.slice();
|
const out = smwsFirst.pairs.slice();
|
||||||
if (out.length >= limitPairs) return out.slice(0, limitPairs);
|
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(
|
const seeds = topSuggestions(work, Math.min(220, work.length), "", mappedSkus).filter(
|
||||||
(it) => !used.has(String(it?.sku || ""))
|
(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 TOKEN_BUCKET_CAP = 700;
|
||||||
const tokMap = new Map(); // token -> items[]
|
const tokMap = new Map(); // token -> items[]
|
||||||
const itemRawToks = new Map(); // sku -> raw tokens
|
const itemRawToks = new Map(); // sku -> raw tokens
|
||||||
const itemNorm = new Map(); // sku -> norm name
|
const itemNorm = new Map(); // sku -> norm name
|
||||||
const itemFilt = new Map(); // sku -> filtered tokens (for first-token logic)
|
const itemFilt = new Map(); // sku -> filtered tokens
|
||||||
|
|
||||||
for (const it of work) {
|
for (const it of work) {
|
||||||
const sku = String(it.sku || "");
|
const sku = String(it.sku || "");
|
||||||
|
|
@ -395,7 +404,6 @@ export function recommendSimilar(
|
||||||
itemRawToks.set(sku, raw);
|
itemRawToks.set(sku, raw);
|
||||||
itemFilt.set(sku, filt);
|
itemFilt.set(sku, filt);
|
||||||
|
|
||||||
// bucket using a handful of filtered tokens (higher signal)
|
|
||||||
for (const t of filt.slice(0, 12)) {
|
for (const t of filt.slice(0, 12)) {
|
||||||
let arr = tokMap.get(t);
|
let arr = tokMap.get(t);
|
||||||
if (!arr) tokMap.set(t, (arr = []));
|
if (!arr) tokMap.set(t, (arr = []));
|
||||||
|
|
@ -405,7 +413,6 @@ export function recommendSimilar(
|
||||||
|
|
||||||
const bestByPair = new Map();
|
const bestByPair = new Map();
|
||||||
const MAX_CAND_TOTAL = 450;
|
const MAX_CAND_TOTAL = 450;
|
||||||
const MAX_CHEAP = 40;
|
|
||||||
const MAX_FINE = 18;
|
const MAX_FINE = 18;
|
||||||
|
|
||||||
for (const a of seeds) {
|
for (const a of seeds) {
|
||||||
|
|
@ -432,18 +439,20 @@ export function recommendSimilar(
|
||||||
const bSku = String(b.sku || "");
|
const bSku = String(b.sku || "");
|
||||||
if (!bSku || bSku === aSku) continue;
|
if (!bSku || bSku === aSku) continue;
|
||||||
if (used.has(bSku)) continue;
|
if (used.has(bSku)) continue;
|
||||||
// if (mappedSkus && mappedSkus.has(bSku)) continue;
|
|
||||||
|
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
||||||
if (typeof sameStoreFn === "function" && sameStoreFn(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);
|
cand.set(bSku, b);
|
||||||
}
|
}
|
||||||
if (cand.size >= MAX_CAND_TOTAL) break;
|
if (cand.size >= MAX_CAND_TOTAL) break;
|
||||||
}
|
}
|
||||||
if (!cand.size) continue;
|
if (!cand.size) continue;
|
||||||
|
|
||||||
// Cheap score stage (fastSimilarity + containment + size + age + first-token mismatch penalty)
|
// Cheap score stage
|
||||||
const cheap = [];
|
const cheap = [];
|
||||||
for (const b of cand.values()) {
|
for (const b of cand.values()) {
|
||||||
const bSku = String(b.sku || "");
|
const bSku = String(b.sku || "");
|
||||||
|
|
@ -467,7 +476,6 @@ export function recommendSimilar(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof sizePenaltyFn === "function") s *= sizePenaltyFn(aSku, bSku);
|
if (typeof sizePenaltyFn === "function") s *= sizePenaltyFn(aSku, bSku);
|
||||||
|
|
||||||
if (typeof pricePenaltyFn === "function") s *= pricePenaltyFn(aSku, bSku);
|
if (typeof pricePenaltyFn === "function") s *= pricePenaltyFn(aSku, bSku);
|
||||||
|
|
||||||
const bAge = extractAgeFromText(bNorm);
|
const bAge = extractAgeFromText(bNorm);
|
||||||
|
|
@ -484,7 +492,7 @@ export function recommendSimilar(
|
||||||
if (!cheap.length) continue;
|
if (!cheap.length) continue;
|
||||||
cheap.sort((x, y) => y.s - x.s);
|
cheap.sort((x, y) => y.s - x.s);
|
||||||
|
|
||||||
// Fine stage (expensive similarityScore + same penalties again)
|
// Fine stage
|
||||||
let bestB = null;
|
let bestB = null;
|
||||||
let bestS = 0;
|
let bestS = 0;
|
||||||
|
|
||||||
|
|
@ -495,7 +503,6 @@ export function recommendSimilar(
|
||||||
let s = similarityScore(a.name || "", b.name || "");
|
let s = similarityScore(a.name || "", b.name || "");
|
||||||
if (s <= 0) continue;
|
if (s <= 0) continue;
|
||||||
|
|
||||||
// first-token mismatch soft penalty
|
|
||||||
if (!x.firstMatch) {
|
if (!x.firstMatch) {
|
||||||
const smallN = Math.min(aFilt.length || 0, (x.bFilt || []).length || 0);
|
const smallN = Math.min(aFilt.length || 0, (x.bFilt || []).length || 0);
|
||||||
let mult = 0.10 + 0.95 * x.contain;
|
let mult = 0.10 + 0.95 * x.contain;
|
||||||
|
|
@ -508,7 +515,7 @@ export function recommendSimilar(
|
||||||
s *= sizePenaltyFn(aSku, bSku);
|
s *= sizePenaltyFn(aSku, bSku);
|
||||||
if (s <= 0) continue;
|
if (s <= 0) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof pricePenaltyFn === "function") {
|
if (typeof pricePenaltyFn === "function") {
|
||||||
s *= pricePenaltyFn(aSku, bSku);
|
s *= pricePenaltyFn(aSku, bSku);
|
||||||
if (s <= 0) continue;
|
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;
|
if (!bestB || bestS < 0.50) continue;
|
||||||
|
|
||||||
const bSku = String(bestB.sku || "");
|
const bSku = String(bestB.sku || "");
|
||||||
|
|
@ -541,7 +547,7 @@ export function recommendSimilar(
|
||||||
const pairs = Array.from(bestByPair.values());
|
const pairs = Array.from(bestByPair.values());
|
||||||
pairs.sort((x, y) => y.score - x.score);
|
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);
|
const need = Math.max(0, limitPairs - out.length);
|
||||||
if (!need) return out.slice(0, limitPairs);
|
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 sameStoreFn === "function" && sameStoreFn(aSku, bSku)) return false;
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(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(aSku);
|
||||||
used.add(bSku);
|
used.add(bSku);
|
||||||
out.push({ a: p.a, b: p.b, score: p.score });
|
out.push({ a: p.a, b: p.b, score: p.score });
|
||||||
|
|
@ -583,6 +592,7 @@ export function recommendSimilar(
|
||||||
return out.slice(0, limitPairs);
|
return out.slice(0, limitPairs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,13 +161,16 @@ export async function renderSkuLinker($app) {
|
||||||
28,
|
28,
|
||||||
isIgnoredPair,
|
isIgnoredPair,
|
||||||
sameStoreCanon,
|
sameStoreCanon,
|
||||||
sizePenaltyForPair, // ✅ NEW
|
sameGroup, // ✅ NEW: hard-block already-linked pairs (incl SMWS stage)
|
||||||
pricePenaltyForPair // ✅ NEW
|
sizePenaltyForPair,
|
||||||
|
pricePenaltyForPair
|
||||||
);
|
);
|
||||||
|
|
||||||
return initialPairs;
|
return initialPairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let pinnedL = null;
|
let pinnedL = null;
|
||||||
|
|
@ -748,7 +751,7 @@ export async function renderSkuLinker($app) {
|
||||||
|
|
||||||
function buildMappedSkuSet(links, rules0) {
|
function buildMappedSkuSet(links, rules0) {
|
||||||
const s = new Set();
|
const s = new Set();
|
||||||
|
|
||||||
function add(k) {
|
function add(k) {
|
||||||
const x = String(k || "").trim();
|
const x = String(k || "").trim();
|
||||||
if (!x) return;
|
if (!x) return;
|
||||||
|
|
@ -758,12 +761,21 @@ export async function renderSkuLinker($app) {
|
||||||
if (c) s.add(c);
|
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?.fromSku);
|
||||||
add(x?.toSku);
|
add(x?.toSku);
|
||||||
}
|
}
|
||||||
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue