WFDASH-WEBAPP/src/lib/item-match-text.ts
Dave 16a1cec562 feat(pos-match): one size-aware shortlist for the whole app
The POS shortlist ranked by shared words only, so "BOLT HEX M16X60
GR8.8 ZP" put the M22x65, the M27x170 and the M20x160 all on 0.75 —
they share "BOLT 8.8 ZP" — and the M16 the customer asked for never
reached the AI.

pos-match.ts is now the shared POS matcher: the word pull plus a
size-targeted pull (name ~ "M16" || description ~ "M16"), ranked by
the size comparison and then by word score. A line with no size
information keeps today's word-only behaviour.

- shortlistStockItems delegates to it, so the quote assistant gets
  the better list for free.
- the intel popover's fuzzy stock section uses the same ranking, so
  the right size wins there too.
- tokenizeIntel / informativeTokens / combinedScore moved to
  item-match-text.ts so the matcher and the intel service can share
  one implementation without an import cycle.

PocketBase has no IN operator: every filter here is an OR-chain of
~ and = clauses, escaped with escapeForPbString, capped at 300 rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:52:48 +02:00

73 lines
2.8 KiB
TypeScript

/**
* 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<string> {
const out = new Set<string>();
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<string>, b: Set<string>): 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<string>, b: Set<string>): number {
return Math.max(jaccard(a, b), overlapScore(a, b));
}