From a705215f82b139397753bd329628a0ed493dd6cd Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 22:57:32 +0200 Subject: [PATCH] feat(quote-assistant): the AI answers exact, closest or none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI could only say "this is the item" or "no idea" — it had no way to say "this is the nearest one, but it is 5mm too short". So a near-miss either got priced as if it were right, or was thrown away with nothing shown. The submit_line_matches tool now takes a match of "exact", "closest" or "none" plus the list of what differs. The user text shows the line's parsed size and each candidate's parsed size with the differences we computed, and the prompt spells out when to use each kind. A response with no match field still reads as "exact" when it named a candidate — the old tool had no match, so those responses keep working. pickPosItems() in pos-match.ts runs the same shortlist and the same tool for any list of lines, one AI call per 15 lines. Co-Authored-By: Claude Opus 5.5 --- src/lib/server/pos-match.test.ts | 144 +++++++++++++++++++- src/lib/server/pos-match.ts | 104 ++++++++++++++ src/lib/server/quote-assistant-ai.test.ts | 77 +++++++++++ src/lib/server/quote-assistant-ai.ts | 46 ++++++- src/lib/server/quote-assistant-core.test.ts | 50 +++++++ src/lib/server/quote-assistant-core.ts | 72 +++++++++- src/lib/server/quote-assistant-prompt.ts | 18 +-- src/lib/types.ts | 18 +++ 8 files changed, 509 insertions(+), 20 deletions(-) diff --git a/src/lib/server/pos-match.test.ts b/src/lib/server/pos-match.test.ts index 74303c5..1d475ec 100644 --- a/src/lib/server/pos-match.test.ts +++ b/src/lib/server/pos-match.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { shortlistPosItems, parseCandidateSize, type AdminPb } from "./pos-match"; +import { + pickPosItems, + POS_PICK_MAX_LINES, + shortlistPosItems, + parseCandidateSize, + type AdminPb, +} from "./pos-match"; type Row = { id: string; @@ -193,3 +199,139 @@ describe("parseCandidateSize", () => { }); }); }); + +describe("pickPosItems", () => { + const line = { + id: "l1", + description: "BOLT HEX M16X60 GR8.8 ZP", + quantity: 10, + sku: "", + notes: "", + }; + + function stubDecide(decision: (input: { lines: Array<{ quote_item_id: string }> }) => unknown) { + return vi.fn(async (input: never) => ({ + model: "test-model", + decisions: new Map( + Object.entries(decision(input as never) as Record).map(([id, d]) => [ + id, + { + quote_item_id: id, + stock_item_id: null, + confidence: "high", + reason: "", + doubts: [], + ...(d as object), + }, + ]), + ), + })); + } + + it("shortlists the line and returns the AI's pick", async () => { + const decide = stubDecide(() => ({ + l1: { + stock_item_id: "stk1", + match: "exact", + differs: [], + confidence: "high", + reason: "same bolt", + }, + })); + const { picks, model } = await pickPosItems([line], { + pb: {} as AdminPb, + decide: decide as never, + shortlist: (async () => [ + { + id: "stk1", + name: "HEX BOLT M16X60 GR8.8 ZP", + sku: "B1", + description: "", + notes: "", + unit: "ea", + cost_price: 4, + selling_price: 6, + markup: 50, + supplier_id: "sup1", + supplier_name: "Bolt Co", + score: 1, + }, + ]) as never, + }); + expect(model).toBe("test-model"); + expect(picks.get("l1")).toMatchObject({ + line_id: "l1", + stock_item_id: "stk1", + match: "exact", + differs: [], + }); + }); + + it("keeps the closest match and what differs", async () => { + const decide = stubDecide(() => ({ + l1: { + stock_item_id: "stk2", + match: "closest", + differs: ["length 65 vs 60"], + confidence: "low", + reason: "nearest", + }, + })); + const { picks } = await pickPosItems([line], { + pb: {} as AdminPb, + decide: decide as never, + shortlist: (async () => []) as never, + }); + expect(picks.get("l1")).toMatchObject({ + match: "closest", + stock_item_id: "stk2", + differs: ["length 65 vs 60"], + }); + }); + + it("records a none as no match", async () => { + const decide = stubDecide(() => ({ + l1: { stock_item_id: null, match: "none", differs: [], confidence: "low", reason: "nothing" }, + })); + const { picks } = await pickPosItems([line], { + pb: {} as AdminPb, + decide: decide as never, + shortlist: (async () => []) as never, + }); + expect(picks.get("l1")).toMatchObject({ match: "none", stock_item_id: null }); + }); + + it("makes one AI call per batch of 15 lines", async () => { + const decide = vi.fn(async (input: never) => ({ + model: "m", + decisions: new Map( + (input as { lines: Array<{ quote_item_id: string }> }).lines.map((l) => [ + l.quote_item_id, + { + quote_item_id: l.quote_item_id, + stock_item_id: null, + match: "none", + differs: [], + confidence: "low", + reason: "", + doubts: [], + }, + ]), + ), + })); + const lines = Array.from({ length: 16 }, (_, i) => ({ ...line, id: `l${i}` })); + const { picks } = await pickPosItems(lines, { + pb: {} as AdminPb, + decide: decide as never, + shortlist: (async () => []) as never, + }); + expect(decide).toHaveBeenCalledTimes(2); + expect((decide.mock.calls[0][0] as { lines: unknown[] }).lines).toHaveLength(POS_PICK_MAX_LINES); + expect(picks.size).toBe(16); + }); + + it("does nothing for no lines", async () => { + const { picks } = await pickPosItems([]); + expect(picks.size).toBe(0); + }); +}); diff --git a/src/lib/server/pos-match.ts b/src/lib/server/pos-match.ts index 7c51869..9effce1 100644 --- a/src/lib/server/pos-match.ts +++ b/src/lib/server/pos-match.ts @@ -352,3 +352,107 @@ export async function shortlistPosItems( sizeCompare, })); } + +// ─── The AI pick ────────────────────────────────────────────────────── + +/** A line the AI may pick a POS item for. */ +export interface PosPickLine { + id: string; + description: string; + quantity: number; + sku: string; + notes: string; +} + +export interface PickPosDeps { + /** The PocketBase client. Defaults to the admin client. */ + pb?: AdminPb; + /** The AI call. Defaults to the quote assistant's `decideWithAi` — the + * same tool and the same prompt rules the quote assistant uses. */ + decide?: (input: DecideInput) => Promise; + /** The shortlist. Defaults to `shortlistPosItems`. */ + shortlist?: ( + pb: AdminPb, + line: { description: string; sku?: string; notes?: string }, + limit: number, + ) => Promise; + /** How many candidates the AI sees per line. */ + candidateLimit?: number; +} + +/** Type-only import — erased at build time, so no import cycle. */ +type DecideInput = import("./quote-assistant-ai").DecideInput; +type DecideResult = import("./quote-assistant-ai").DecideResult; + +/** + * Let the AI pick a POS item for each line. + * + * One shortlist per line, then one AI call per batch of + * `POS_PICK_MAX_LINES` lines. The AI may only choose from the + * candidates it was given. + * + * The AI's `AiDisabledError` is NOT swallowed — the caller decides + * whether to fall back to the fuzzy behaviour. + */ +export async function pickPosItems( + lines: PosPickLine[], + deps: PickPosDeps = {}, +): Promise<{ picks: Map; model: string }> { + const picks = new Map(); + if (lines.length === 0) return { picks, model: "" }; + + const pb = deps.pb ?? (await getAdminPocketBase()); + const decide = deps.decide ?? (await loadDecide()); + const shortlist = deps.shortlist ?? shortlistPosItems; + const candidateLimit = deps.candidateLimit ?? POS_PICK_CANDIDATES; + + let model = ""; + for (let i = 0; i < lines.length; i += POS_PICK_MAX_LINES) { + const batch = lines.slice(i, i + POS_PICK_MAX_LINES); + const decideLines = []; + for (const line of batch) { + const candidates = await shortlist( + pb, + { description: line.description, sku: line.sku, notes: line.notes }, + candidateLimit, + ); + decideLines.push({ + quote_item_id: line.id, + description: line.description, + quantity: line.quantity, + sku: line.sku, + notes: line.notes, + candidates, + }); + } + + const out = await decide({ + quoteNumber: "POS pick", + lines: decideLines, + lineLabel: "line_id", + }); + model = out.model || model; + for (const [lineId, decision] of out.decisions) { + picks.set(lineId, { + line_id: lineId, + stock_item_id: decision.stock_item_id, + // An old response with no `match` meant "exact". + match: decision.match ?? (decision.stock_item_id ? "exact" : "none"), + differs: decision.differs ?? [], + confidence: decision.confidence, + reason: decision.reason, + }); + } + } + + return { picks, model }; +} + +/** + * The quote assistant's AI call, loaded lazily so the intel path + * (which only needs the shortlist) does not pull the AI SDK in. + */ +async function loadDecide(): Promise> { + const mod = await import("./quote-assistant-ai"); + return mod.decideWithAi; +} diff --git a/src/lib/server/quote-assistant-ai.test.ts b/src/lib/server/quote-assistant-ai.test.ts index c215120..af9a53b 100644 --- a/src/lib/server/quote-assistant-ai.test.ts +++ b/src/lib/server/quote-assistant-ai.test.ts @@ -100,6 +100,55 @@ describe("buildDecideUserText", () => { it("never mentions 'price list'", () => { expect(buildDecideUserText(input).toLowerCase()).not.toContain("price list"); }); + + it("shows the size parsed out of the line", () => { + const text = buildDecideUserText(input); + expect(text).toContain("Line size: bolt, thread M10, length 50, finish zp"); + expect(text).toContain("Line size: not stated"); + }); + + it("shows each candidate's size and what differs from the line", () => { + const text = buildDecideUserText({ + ...input, + lines: [ + { + quote_item_id: "qi1", + description: "BOLT HEX M16X60 GR8.8 ZP", + quantity: 1, + sku: "", + notes: "", + candidates: [ + candidate({ + id: "stk1", + name: "HEX BOLT M22X65 GR8.8 ZP", + size: { + kind: "bolt", + variant: null, + thread: "M22", + length: 65, + grade: "8.8", + finish: "zp", + dims: [], + }, + sizeCompare: { + exact: false, + differs: ["thread M22 vs M16", "length 65 vs 60"], + rank: 2000, + }, + }), + ], + }, + ], + }); + expect(text).toContain("Line size: bolt, thread M16, length 60, grade 8.8, finish zp"); + expect(text).toContain("size: bolt, thread M22, length 65, grade 8.8, finish zp"); + expect(text).toContain("differs from line: thread M22 vs M16; length 65 vs 60"); + }); + + it("labels the line ids with the caller's label", () => { + const text = buildDecideUserText({ ...input, lineLabel: "order_line_id" }); + expect(text).toContain("Line order_line_id=qi1"); + }); }); describe("decideWithAi", () => { @@ -153,11 +202,39 @@ describe("decideWithAi", () => { expect(res.decisions.get("qi1")).toEqual({ quote_item_id: "qi1", stock_item_id: "stk1", + // No `match` in the response — the old tool had none, so it means + // "exact" when a candidate was named. + match: "exact", + differs: [], confidence: "high", reason: "Same bolt", doubts: ["pack size"], }); expect(res.decisions.get("qi2")?.stock_item_id).toBeNull(); + expect(res.decisions.get("qi2")?.match).toBe("none"); + }); + + it("reads the match kind and the differences out of the tool call", async () => { + const { client } = fakeClient([ + toolUse({ + matches: [ + { + quote_item_id: "qi1", + stock_item_id: "stk1", + match: "closest", + differs: ["length 65 vs 60"], + confidence: "low", + reason: "nearest bolt", + doubts: [], + }, + ], + }), + ]); + const res = await decideWithAi(input, { client }); + expect(res.decisions.get("qi1")).toMatchObject({ + match: "closest", + differs: ["length 65 vs 60"], + }); }); it("throws when the response was cut off by max_tokens, even with a partial tool call", async () => { diff --git a/src/lib/server/quote-assistant-ai.ts b/src/lib/server/quote-assistant-ai.ts index 44c3034..064208c 100644 --- a/src/lib/server/quote-assistant-ai.ts +++ b/src/lib/server/quote-assistant-ai.ts @@ -10,7 +10,8 @@ import Anthropic from "@anthropic-ai/sdk"; import { getActiveAiConfig, getAiSettings } from "./ai-settings-service"; import { DEFAULT_QUOTE_ASSISTANT_PROMPT } from "./quote-assistant-prompt"; import { parseDecisions, type AiLineDecision } from "./quote-assistant-core"; -import type { StockCandidate } from "./quote-assistant-shortlist"; +import type { StockCandidate } from "./pos-match"; +import { describeSize, parseItemSize } from "@/lib/item-size"; export interface DecideLine { quote_item_id: string; @@ -24,6 +25,9 @@ export interface DecideLine { export interface DecideInput { quoteNumber: string; lines: DecideLine[]; + /** How the line ids are labelled in the user text. The order fill + * passes "order_line_id" so the model reads "Line order_line_id=…". */ + lineLabel?: string; } export interface DecideResult { @@ -36,7 +40,7 @@ const TOOL_NAME = "submit_line_matches" as const; export const SUBMIT_LINE_MATCHES_TOOL = { name: TOOL_NAME, description: - "Submit your match decision for every quote line. Call this tool exactly once with one entry per quote line. Use stock_item_id null when no candidate is the same product.", + "Submit your match decision for every quote line. Call this tool exactly once with one entry per quote line. Use match 'exact' only when the candidate is the same product AND every size attribute the line states matches. Use match 'closest' with the nearest candidate and list what differs. Use stock_item_id null and match 'none' when nothing is close enough.", input_schema: { type: "object" as const, properties: { @@ -55,6 +59,18 @@ export const SUBMIT_LINE_MATCHES_TOOL = { description: "The stock_item_id of the chosen candidate for this line, or null when none is the same product.", }, + match: { + type: "string", + enum: ["exact", "closest", "none"], + description: + "exact = the same product and every size attribute the line states matches. closest = the nearest candidate, but something differs. none = nothing in the list is close enough.", + }, + differs: { + type: "array", + items: { type: "string" }, + description: + "What differs between the line and the chosen candidate, in plain words (e.g. 'length 65 vs 60'). Empty for an exact match.", + }, confidence: { type: "string", enum: ["high", "medium", "low"], @@ -70,7 +86,15 @@ export const SUBMIT_LINE_MATCHES_TOOL = { description: "Differences or uncertainties the salesperson should check.", }, }, - required: ["quote_item_id", "stock_item_id", "confidence", "reason", "doubts"], + required: [ + "quote_item_id", + "stock_item_id", + "match", + "differs", + "confidence", + "reason", + "doubts", + ], }, }, }, @@ -84,21 +108,35 @@ function formatMoney(n: number): string { /** Plain-text description of the lines and their candidates for the model. */ export function buildDecideUserText(input: DecideInput): string { + const label = input.lineLabel || "quote_item_id"; const out: string[] = [`Quote ${input.quoteNumber}`, ""]; for (const line of input.lines) { - out.push(`Line quote_item_id=${line.quote_item_id}`); + out.push(`Line ${label}=${line.quote_item_id}`); out.push(` Description: ${line.description}`); out.push(` Quantity: ${line.quantity}`); if (line.sku) out.push(` Customer SKU: ${line.sku}`); if (line.notes) out.push(` Notes: ${line.notes}`); + + // The size parsed out of the line, so the model compares sizes and + // not just words. + const lineSize = parseItemSize(line.description || ""); + const lineSizeText = describeSize(lineSize); + out.push(` Line size: ${lineSizeText || "not stated"}`); + if (line.candidates.length === 0) { out.push(" Candidates: none"); } else { out.push(" Candidates:"); for (const c of line.candidates) { + const sizeText = describeSize(c.size ?? parseItemSize(c.name || "")); + const differs = c.sizeCompare?.differs ?? []; out.push( ` - stock_item_id=${c.id} | ${c.name} | SKU ${c.sku || "-"} | unit ${c.unit || "-"} | cost ${formatMoney(c.cost_price)} | selling ${formatMoney(c.selling_price)} | supplier ${c.supplier_name || "-"}`, ); + out.push(` size: ${sizeText || "not stated"}`); + out.push( + ` differs from line: ${differs.length > 0 ? differs.join("; ") : "nothing stated by the line differs"}`, + ); } } out.push(""); diff --git a/src/lib/server/quote-assistant-core.test.ts b/src/lib/server/quote-assistant-core.test.ts index 00a5c12..2b71cef 100644 --- a/src/lib/server/quote-assistant-core.test.ts +++ b/src/lib/server/quote-assistant-core.test.ts @@ -153,6 +153,56 @@ describe("isLineStillWritable", () => { describe("parseDecisions", () => { const ids = new Set(["a", "b"]); + it("reads the match kind and the differences", () => { + const out = parseDecisions( + [ + { + quote_item_id: "a", + stock_item_id: "s", + match: "exact", + differs: [], + confidence: "high", + }, + { + quote_item_id: "b", + stock_item_id: "s2", + match: "closest", + differs: ["length 65 vs 60"], + confidence: "low", + }, + ], + ids, + ); + expect(out.get("a")?.match).toBe("exact"); + expect(out.get("a")?.differs).toEqual([]); + expect(out.get("b")?.match).toBe("closest"); + expect(out.get("b")?.differs).toEqual(["length 65 vs 60"]); + }); + + it("reads a missing match as exact when a candidate was named", () => { + const out = parseDecisions([{ quote_item_id: "a", stock_item_id: "s" }], ids); + expect(out.get("a")?.match).toBe("exact"); + expect(out.get("a")?.differs).toEqual([]); + }); + + it("reads a missing match as none when no candidate was named", () => { + const out = parseDecisions([{ quote_item_id: "a", stock_item_id: null }], ids); + expect(out.get("a")?.match).toBe("none"); + }); + + it("keeps an explicit none", () => { + const out = parseDecisions([{ quote_item_id: "a", stock_item_id: null, match: "none" }], ids); + expect(out.get("a")?.match).toBe("none"); + }); + + it("drops non-string differences", () => { + const out = parseDecisions( + [{ quote_item_id: "a", stock_item_id: "s", match: "closest", differs: ["ok", 5, null] }], + ids, + ); + expect(out.get("a")?.differs).toEqual(["ok"]); + }); + it("drops unknown ids and keeps the first of duplicates", () => { const out = parseDecisions( { diff --git a/src/lib/server/quote-assistant-core.ts b/src/lib/server/quote-assistant-core.ts index 7004b85..6c78cdb 100644 --- a/src/lib/server/quote-assistant-core.ts +++ b/src/lib/server/quote-assistant-core.ts @@ -19,7 +19,7 @@ import type { QuoteItem, QuoteItemAiFill, } from "@/lib/types"; -import type { StockCandidate } from "@/lib/server/quote-assistant-shortlist"; +import type { StockCandidate } from "@/lib/server/pos-match"; import { DEFAULT_MARKUP_MULTIPLIER, DEFAULT_MARKUP_PERCENT, @@ -32,9 +32,29 @@ export const ERROR_RETRY_MS = 60 * 60 * 1000; const MAX_LISTED_CANDIDATES = 5; const NO_MATCH_NOTE = "AI: no confident POS match — see closest items."; +/** + * How sure the AI is that the chosen item is the one the customer + * asked for. + * + * "exact" — the same product AND every size attribute the line + * states (thread, length, grade, finish) matches. + * "closest" — the nearest item, but something differs. The line + * keeps NO price; the closest item is recorded so the + * operator can see it. + * "none" — nothing in the catalogue is close enough. + */ +export type AiMatchKind = "exact" | "closest" | "none"; + export interface AiLineDecision { quote_item_id: string; stock_item_id: string | null; + /** "exact" | "closest" | "none". Absent on a response from the old + * tool, which had no `match` — read it as "exact" when a candidate + * was named (see `asMatchKind`). */ + match?: AiMatchKind; + /** What differs between the line and the chosen item, in plain words. + * Absent on an old response. */ + differs?: string[]; confidence: AiFillConfidence | null; reason: string; doubts: string[]; @@ -89,10 +109,21 @@ function asConfidence(v: unknown): AiFillConfidence | null { return v === "high" || v === "medium" || v === "low" ? v : null; } +function asMatchKind(v: unknown, stockItemId: string | null): AiMatchKind { + if (v === "exact" || v === "closest" || v === "none") return v; + // An older response without `match`: it picked a candidate, so it + // meant "exact". Keeps the pre-size-aware behaviour working. + return stockItemId ? "exact" : "none"; +} + /** * Validate the AI's JSON. Accepts `{ decisions: [...] }` or a bare array. * Unknown line ids are dropped, the first decision per id wins, and * anything malformed degrades to null/empty rather than throwing. + * + * A response with no `match` field is read as "exact" when it named a + * candidate — the old tool had no `match`, and those responses meant + * "this is the item". */ export function parseDecisions( raw: unknown, @@ -110,10 +141,15 @@ export function parseDecisions( const e = entry as Record; const id = typeof e.quote_item_id === "string" ? e.quote_item_id : ""; if (!id || !requestedIds.has(id) || out.has(id)) continue; + const stockItemId = + typeof e.stock_item_id === "string" && e.stock_item_id ? e.stock_item_id : null; out.set(id, { quote_item_id: id, - stock_item_id: - typeof e.stock_item_id === "string" && e.stock_item_id ? e.stock_item_id : null, + stock_item_id: stockItemId, + match: asMatchKind(e.match, stockItemId), + differs: Array.isArray(e.differs) + ? e.differs.filter((d): d is string => typeof d === "string") + : [], confidence: asConfidence(e.confidence), reason: typeof e.reason === "string" ? e.reason : "", doubts: Array.isArray(e.doubts) @@ -136,9 +172,13 @@ function listCandidates(candidates: StockCandidate[]): AiFillCandidate[] { /** * Build the PB patch for one line. Fills only when the decision picked a - * real candidate with a cost at/above the minimum confidence; otherwise - * records `no_match` (or the forced `no_candidates` / `error` status) and - * leaves every price field alone. + * real candidate with a cost at/above the minimum confidence AND the AI + * said the match is EXACT; otherwise records `no_match` (or the forced + * `no_candidates` / `error` status) and leaves every price field alone. + * + * A "closest" match writes NO price. The closest item is recorded in + * `ai_fill.closest` with what differs, so the operator can see it and + * decide — but the line is never priced from a near-miss. * * For non-fill statuses `ai_fill.stock_item_id` is always null and the * numeric fields are 0 — the closest options live in `ai_fill.candidates`. @@ -170,7 +210,11 @@ export function buildLinePatch(args: { : undefined; const cost = chosen ? round2(Number(chosen.cost_price) || 0) : 0; - if (chosen && cost > 0 && meetsConfidence(decision?.confidence ?? null, minConfidence)) { + // Only an EXACT match may write a price. A missing `match` on an old + // response reads as "exact" (see `asMatchKind`). + const isExact = (decision?.match ?? "exact") === "exact"; + + if (chosen && isExact && cost > 0 && meetsConfidence(decision?.confidence ?? null, minConfidence)) { const posSelling = round2(Number(chosen.selling_price) || 0); const fromDefault = posSelling <= 0; const selling = fromDefault ? round2(cost * DEFAULT_MARKUP_MULTIPLIER) : posSelling; @@ -209,6 +253,19 @@ export function buildLinePatch(args: { return patch; } + // The closest item — recorded, but never priced onto the line. + const closest = + !status && chosen && decision?.match === "closest" + ? { + stock_item_id: chosen.id, + name: chosen.name, + sku: chosen.sku, + cost_price: cost, + selling_price: round2(Number(chosen.selling_price) || 0), + differs: decision.differs ?? [], + } + : undefined; + const aiFill: QuoteItemAiFill = { ...common, status: status ?? "no_match", @@ -219,6 +276,7 @@ export function buildLinePatch(args: { selling_price: 0, markup: 0, supplier_id: "", + ...(closest ? { closest } : {}), }; const patch: Record = { ai_fill: aiFill }; // Don't overwrite a rep's note, and an error says nothing about the match. diff --git a/src/lib/server/quote-assistant-prompt.ts b/src/lib/server/quote-assistant-prompt.ts index 68d753b..cc5d691 100644 --- a/src/lib/server/quote-assistant-prompt.ts +++ b/src/lib/server/quote-assistant-prompt.ts @@ -7,13 +7,15 @@ * Keep it plain text (no markdown) — it is sent as the `system:` field * verbatim. The rules assume the `submit_line_matches` tool. */ -export const DEFAULT_QUOTE_ASSISTANT_PROMPT = `You match quote lines from customers of a South African industrial supplier to items in our own POS catalogue, so the sales team gets a draft price. For each quote line you get a list of candidate POS items. Return your decision for every line through the submit_line_matches tool. +export const DEFAULT_QUOTE_ASSISTANT_PROMPT = `You match quote lines from customers of a South African industrial supplier to items in our own POS catalogue, so the sales team gets a draft price. For each quote line you get a list of candidate POS items, each with the size we parsed out of it and the differences we found against the line. Return your decision for every line through the submit_line_matches tool. Rules: -1. Pick a candidate ONLY if it is the same product: same type, size, thread, length, grade/class, material and finish where the line states them. A near-miss is NOT a match. -2. If the closest candidate differs in anything the customer specified (e.g. line asks grade 12.9, candidate is grade 8), either return no match, or return it with confidence "low" and explain the difference in doubts. -3. Never invent or estimate prices. Prices come only from the chosen POS item. -4. Flag unit and pack-size differences in doubts (e.g. "POS item is sold per box of 100; customer asked for 50 each"). -5. confidence: "high" = clearly the same item; "medium" = very likely the same, minor detail unstated by the customer; "low" = uncertain. -6. reason: one or two plain sentences a salesperson understands, saying why it is the same item. doubts: short plain sentences, empty list if none. -7. Only use stock_item_id values from that line's own candidate list. Use null for no match.`; +1. For every line return a match of "exact", "closest" or "none". +2. "exact" means the candidate is the same product AND every size attribute the line states matches: thread, length, grade/class, material and finish where the line states them. A near-miss is NOT an exact match. +3. "closest" means no candidate is an exact match but one is clearly the nearest. Pick that one, set match to "closest", and list in differs what does not match (for example "length 65 vs 60" or "thread M22 vs M16"). Never write a price for a closest match. +4. "none" means nothing in that line's candidate list is close enough. Set stock_item_id to null. +5. Only use stock_item_id values from that line's own candidate list. Use null for no match. +6. Never invent or estimate prices. Prices come only from the chosen POS item. +7. Flag unit and pack-size differences in doubts (e.g. "POS item is sold per box of 100; customer asked for 50 each"). +8. confidence: "high" = clearly the same item; "medium" = very likely the same, minor detail unstated by the customer; "low" = uncertain. +9. reason: one or two plain sentences a salesperson understands, saying why it is the same item. doubts: short plain sentences, empty list if none. differs: only what really differs, empty list for an exact match.`; diff --git a/src/lib/types.ts b/src/lib/types.ts index aaac228..d1301fa 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -515,6 +515,21 @@ export interface AiFillCandidate { selling_price: number; } +/** + * The closest POS item the AI found when it could not make an exact + * match. Recorded on `ai_fill.closest` so the operator sees what the + * price WOULD be for the nearest item, and what differs. + */ +export interface AiFillClosest { + stock_item_id: string; + name: string; + sku: string; + cost_price: number; + selling_price: number; + /** What differs between the line and this item, in plain words. */ + differs: string[]; +} + /** * What the AI quote assistant recorded on a quote line. Lives in * `quote_items.ai_fill` (json). The POS SKU stays here only — it is @@ -537,6 +552,9 @@ export interface QuoteItemAiFill { model: string; attempted_at: string; price_from_default_markup?: boolean; + /** Set when the AI could only find a CLOSEST item — the line keeps no + * price, but the operator sees the nearest item and what differs. */ + closest?: AiFillClosest; reviewed_at?: string; reviewed_by?: string; accepted_unchanged?: boolean;