feat(quote-assistant): POS shortlist from stock_items

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 19:12:48 +02:00
parent f0e7848eed
commit 0adf23ef4e
3 changed files with 233 additions and 4 deletions

View file

@ -96,7 +96,7 @@ const INTEL_STOPWORDS = new Set([
"the", "and", "with", "for", "of", "to", "a", "an", "in", "on", "at",
"by", "from", "as", "is", "or", "x",
]);
function tokenizeIntel(s: string): Set<string> {
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) || []) {
@ -130,7 +130,7 @@ function daysAgo(iso: string | null | undefined): number {
const INFORMATIVE_MIN_LEN = 3;
/** Pick the top N tokens by length desc (most distinctive first). */
function informativeTokens(s: string, max = 4): string[] {
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)
@ -148,7 +148,7 @@ function buildPbContainsOR(field: string, targetDesc: string, maxTokens = 4): st
}
/** Combined Jaccard + overlap score, capped at 1. */
function combinedScore(a: Set<string>, b: Set<string>): number {
export function combinedScore(a: Set<string>, b: Set<string>): number {
return Math.max(jaccard(a, b), overlapScore(a, b));
}
@ -174,7 +174,7 @@ async function safeGetList<T>(
}
}
async function loadSuppliersById(pb: Awaited<ReturnType<typeof getAdminPocketBase>>): Promise<Map<string, string>> {
export async function loadSuppliersById(pb: Awaited<ReturnType<typeof getAdminPocketBase>>): Promise<Map<string, string>> {
try {
const rows = await pb
.collection("suppliers")

View file

@ -0,0 +1,120 @@
import { describe, expect, it, vi } from "vitest";
import { shortlistStockItems, type AdminPb } from "./quote-assistant-shortlist";
type Row = {
id: string;
sku: string;
name: string;
description: string;
notes?: string;
unit?: string;
cost_price?: number;
selling_price?: number;
markup?: number;
supplier?: string;
};
function makePb(rows: Row[]) {
const getList = vi.fn<(page: number, per: number, opts: { filter?: string; fields?: string }) => Promise<{ items: Row[] }>>(
async () => ({ items: rows }),
);
const getFullList = vi.fn(async () => [
{ id: "sup1", suppliername: "Bolt Supplies" },
{ id: "sup2", suppliername: "Other Co" },
]);
const collection = vi.fn((name: string) => {
if (name === "stock_items") return { getList };
if (name === "suppliers") return { getFullList };
throw new Error(`unexpected collection ${name}`);
});
return { pb: { collection } as unknown as AdminPb, collection, getList, getFullList };
}
const NUT: Row = {
id: "s1", sku: "NUT-M18", name: "M18 NUT 8 PLN", description: "M18 NUT 8 PLN",
unit: "ea", cost_price: 2, selling_price: 3, markup: 1.5, supplier: "sup1",
};
const SCREW: Row = {
id: "s2", sku: "SCR-M6", name: "M6x30 SET SCR A2 PLN", description: "M6x30 SET SCR A2 PLN",
cost_price: 1, selling_price: 2, markup: 2, supplier: "sup2",
};
describe("shortlistStockItems", () => {
it("filters active rows and ORs name/description/sku per informative token", async () => {
const { pb, getList } = makePb([NUT]);
await shortlistStockItems(pb, { description: "M18 hex nut grade 8" });
expect(getList).toHaveBeenCalledTimes(1);
const opts = getList.mock.calls[0][2];
expect(opts.filter!.startsWith("is_active = true && (")).toBe(true);
for (const tok of ["hex", "nut", "grade"]) {
expect(opts.filter).toContain(`name ~ "${tok}"`);
expect(opts.filter).toContain(`description ~ "${tok}"`);
expect(opts.filter).toContain(`sku ~ "${tok}"`);
}
expect(opts.fields).toBe("id,sku,name,description,notes,unit,cost_price,selling_price,markup,supplier");
expect(getList.mock.calls[0][1]).toBe(500);
});
it("returns only rows that match the description", async () => {
const { pb } = makePb([NUT, SCREW]);
const out = await shortlistStockItems(pb, { description: "M18 hex nut grade 8" });
expect(out.map((r) => r.id)).toEqual(["s1"]);
});
it("sorts by score descending and caps at limit", async () => {
const rows: Row[] = [
{ id: "a", sku: "A", name: "bolt hex", description: "bolt hex" },
{ id: "b", sku: "B", name: "bolt hex zinc long", description: "bolt hex zinc long" },
{ id: "c", sku: "C", name: "bolt", description: "bolt" },
];
const { pb } = makePb(rows);
const all = await shortlistStockItems(pb, { description: "bolt hex" });
expect(all.length).toBe(3);
for (let i = 1; i < all.length; i++) expect(all[i - 1].score).toBeGreaterThanOrEqual(all[i].score);
const capped = await shortlistStockItems(pb, { description: "bolt hex" }, 2);
expect(capped).toHaveLength(2);
expect(capped.map((r) => r.id)).toEqual(all.slice(0, 2).map((r) => r.id));
});
it("ranks a normalised SKU hit first with score 1 even with a weak description", async () => {
const rows: Row[] = [
NUT,
{ id: "sku1", sku: "ab-123/x", name: "Widget", description: "totally different thing" },
];
const { pb } = makePb(rows);
const out = await shortlistStockItems(pb, { description: "M18 hex nut grade 8", sku: "AB 123 X" });
expect(out[0].id).toBe("sku1");
expect(out[0].score).toBe(1);
expect(out.map((r) => r.id)).toContain("s1");
});
it("includes the escaped SKU in the PB filter", async () => {
const { pb, getList } = makePb([]);
await shortlistStockItems(pb, { description: "", sku: 'AB"123' });
expect(getList.mock.calls[0][2].filter).toContain('sku ~ "AB\\"123"');
});
it("drops rows scoring below 0.2", async () => {
const weak: Row = { id: "w", sku: "W", name: "unrelated gadget", description: "unrelated gadget" };
const { pb } = makePb([weak]);
expect(await shortlistStockItems(pb, { description: "M18 hex nut grade 8" })).toEqual([]);
});
it("resolves supplier_name and maps fields", async () => {
const { pb } = makePb([NUT]);
const [row] = await shortlistStockItems(pb, { description: "M18 hex nut grade 8" });
expect(row).toMatchObject({
id: "s1", name: "M18 NUT 8 PLN", sku: "NUT-M18", unit: "ea",
cost_price: 2, selling_price: 3, markup: 1.5,
supplier_id: "sup1", supplier_name: "Bolt Supplies", notes: "",
});
expect(row.score).toBeGreaterThanOrEqual(0.2);
});
it("returns [] without any PB call for empty or stopword-only descriptions with no SKU", async () => {
const { pb, collection } = makePb([NUT]);
expect(await shortlistStockItems(pb, { description: "" })).toEqual([]);
expect(await shortlistStockItems(pb, { description: "the and of" })).toEqual([]);
expect(collection).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,109 @@
/**
* quote-assistant-shortlist — deterministic (no AI) candidate lookup of POS
* items (`stock_items`) for one quote line. A later step sends these
* candidates to the AI to choose from.
*
* Candidate pull mirrors item-intel-service's stock section: PB `~` OR'd over
* the top informative tokens, then in-app Jaccard/overlap scoring. Differences:
* only active items, name + description + sku are searched, and an exact
* (normalised) SKU hit always wins with score 1.
*/
import type { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import {
combinedScore,
informativeTokens,
loadSuppliersById,
tokenizeIntel,
} from "@/lib/server/item-intel-service";
import { escapeForPbString, normaliseSku } from "@/lib/server/price-list-match";
export interface StockCandidate {
id: string;
name: string;
sku: string;
description: string;
notes: string;
unit: string;
cost_price: number;
selling_price: number;
markup: number;
supplier_id: string;
supplier_name: string;
score: number;
}
export type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
const FETCH_LIMIT = 500;
const MIN_SCORE = 0.2;
const MAX_TOKENS = 4;
type StockRow = {
id: string;
sku?: string;
name?: string;
description?: string;
notes?: string;
unit?: string;
cost_price?: number;
selling_price?: number;
markup?: number;
supplier?: string;
};
export async function shortlistStockItems(
pb: AdminPb,
line: { description: string; sku?: string },
limit = 8,
): Promise<StockCandidate[]> {
const tokens = informativeTokens(line.description || "", MAX_TOKENS);
const rawSku = (line.sku || "").trim();
const wantedSku = normaliseSku(rawSku);
const clauses: string[] = [];
for (const t of tokens) {
const v = escapeForPbString(t);
clauses.push(`name ~ "${v}"`, `description ~ "${v}"`, `sku ~ "${v}"`);
}
if (rawSku) clauses.push(`sku ~ "${escapeForPbString(rawSku)}"`);
if (clauses.length === 0) return [];
const res = await pb.collection("stock_items").getList<StockRow>(1, FETCH_LIMIT, {
filter: `is_active = true && (${clauses.join(" || ")})`,
fields: "id,sku,name,description,notes,unit,cost_price,selling_price,markup,supplier",
});
const lineTokens = tokenizeIntel(line.description || "");
const scored = res.items
.map((row) => {
const skuHit = wantedSku !== "" && normaliseSku(row.sku) === wantedSku;
const score = skuHit
? 1
: Math.max(
combinedScore(lineTokens, tokenizeIntel(row.name || "")),
combinedScore(lineTokens, tokenizeIntel(row.description || "")),
);
return { row, score, skuHit };
})
.filter((r) => r.skuHit || r.score >= MIN_SCORE)
.sort((a, b) => b.score - a.score || Number(b.skuHit) - Number(a.skuHit))
.slice(0, limit);
if (scored.length === 0) return [];
const supplierNameById = await loadSuppliersById(pb);
return scored.map(({ row, score }) => ({
id: row.id,
name: row.name || "",
sku: row.sku || "",
description: row.description || "",
notes: row.notes || "",
unit: row.unit || "",
cost_price: row.cost_price || 0,
selling_price: row.selling_price || 0,
markup: row.markup || 0,
supplier_id: row.supplier || "",
supplier_name: supplierNameById.get(row.supplier || "") || "",
score: +score.toFixed(3),
}));
}