feat(quote-assistant): POS shortlist from stock_items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f0e7848eed
commit
0adf23ef4e
3 changed files with 233 additions and 4 deletions
|
|
@ -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")
|
||||
|
|
|
|||
120
src/lib/server/quote-assistant-shortlist.test.ts
Normal file
120
src/lib/server/quote-assistant-shortlist.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
109
src/lib/server/quote-assistant-shortlist.ts
Normal file
109
src/lib/server/quote-assistant-shortlist.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
Loading…
Reference in a new issue