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>
73 lines
2.8 KiB
TypeScript
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));
|
|
}
|