/** * item-match-text — the pure text scoring behind the POS matcher. * * These three helpers used to live in `item-intel-service.ts`. They * moved here so that `pos-match.ts` (the shared, size-aware POS * matcher) and `item-intel-service.ts` (the ⓘ popover) can share one * implementation without importing each other. * * Pure and client-safe: no PocketBase, no AI, no DOM. */ import { jaccard } from "@/lib/server/order-quote-match"; /** Tokenizer tuned for item-intel fuzzy matching. * * Differs from `order-quote-match.tokenize` in three ways that * matter for catalogue fuzziness: * * 1. Keeps short numeric tokens ("50", "20L", "M20") — these * carry product identity (sizes, capacities, model numbers). * Dropping them made every SAE oil tie at overlap=1.0 and * ruined price_list matching against the AFRIQ SKUs. * 2. Smaller stopword set — only tokens that are pure noise * across every catalogue. No "mm" / "qty" filter because * "qty" doesn't appear in our descriptions and "mm" only * does so meaningfully (e.g. "M6 X 30MM" — keep it). * 3. Allows single-character numeric tokens ("L", "5") so * compound unit strings like "20LT" or "5L" tokenise as * one unit token (the regex matches the whole run, not * per-character). */ const INTEL_STOPWORDS = new Set([ "the", "and", "with", "for", "of", "to", "a", "an", "in", "on", "at", "by", "from", "as", "is", "or", "x", ]); export function tokenizeIntel(s: string): Set { const out = new Set(); if (!s) return out; for (const tok of String(s).toLowerCase().match(/[a-z0-9]+/g) || []) { if (INTEL_STOPWORDS.has(tok)) continue; out.add(tok); } return out; } /** Overlap coefficient: |A ∩ B| / min(|A|, |B|). * Better than Jaccard for "short query against a rich candidate" — * doesn't penalise extra tokens on the candidate side. Used for * ranking against the price_list `search_text` blob. */ export function overlapScore(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0; let inter = 0; for (const t of a) if (b.has(t)) inter++; return inter / Math.min(a.size, b.size); } /** Drop stopwords + short tokens. */ const INFORMATIVE_MIN_LEN = 3; /** Pick the top N tokens by length desc (most distinctive first). */ export function informativeTokens(s: string, max = 4): string[] { const toks = tokenizeIntel(s || ""); return [...toks] .filter((t) => !INTEL_STOPWORDS.has(t) && t.length >= INFORMATIVE_MIN_LEN) .sort((a, b) => b.length - a.length) .slice(0, max); } /** Combined Jaccard + overlap score, capped at 1. */ export function combinedScore(a: Set, b: Set): number { return Math.max(jaccard(a, b), overlapScore(a, b)); }