From 8eb64f2b2d64c879e68bc21236e833297e55017d Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 13:00:03 +0200 Subject: [PATCH] feat(orders): AI call that matches an order to a quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 of the AI order assistant plan. - DEFAULT_ORDER_ASSISTANT_PROMPT: a quote match needs matching items, not just the same customer; quantities may differ; prices may differ slightly; a line may have no quote line; answer only with given ids - SUBMIT_ORDER_QUOTE_MATCH_TOOL (forced tool) + MatchInput - buildMatchUserText — ids and line facts only, never the email body - parseOrderQuoteMatch — unknown ids dropped/nulled, a quote line claimed twice gives the second order line null, skipped order lines come back unpaired, bad confidence -> low - matchOrderWithAi — throws on a cut-off response or a missing tool call Co-Authored-By: Claude Opus 5.5 --- src/lib/server/order-assistant-ai.test.ts | 327 ++++++++++++++++++++++ src/lib/server/order-assistant-ai.ts | 289 +++++++++++++++++++ src/lib/server/order-assistant-prompt.ts | 22 ++ 3 files changed, 638 insertions(+) create mode 100644 src/lib/server/order-assistant-ai.test.ts create mode 100644 src/lib/server/order-assistant-ai.ts create mode 100644 src/lib/server/order-assistant-prompt.ts diff --git a/src/lib/server/order-assistant-ai.test.ts b/src/lib/server/order-assistant-ai.test.ts new file mode 100644 index 0000000..fcc09eb --- /dev/null +++ b/src/lib/server/order-assistant-ai.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server/ai-settings-service", () => ({ + getActiveAiConfig: async () => ({ + apiKey: "test-key", + baseUrl: "", + modelId: "test-model", + maxTokens: 4096, + }), +})); + +import { + buildMatchUserText, + matchOrderWithAi, + parseOrderQuoteMatch, + type MatchInput, +} from "@/lib/server/order-assistant-ai"; + +const IDS = { + orderLineIds: new Set(["ol1", "ol2", "ol3"]), + quoteIds: new Set(["q1", "q2"]), + quoteLineIds: new Set(["ql1", "ql2", "ql3"]), +}; + +function toolInput(input: Record) { + return { + content: [ + { + type: "tool_use" as const, + name: "submit_order_quote_match", + id: "toolu_1", + input, + }, + ], + stop_reason: "tool_use" as const, + }; +} + +describe("parseOrderQuoteMatch", () => { + it("keeps a known quote id and a known confidence", () => { + const d = parseOrderQuoteMatch( + { quote_id: "q1", confidence: "high", reason: "same items", doubts: ["qty differs"] }, + IDS, + ); + expect(d.quote_id).toBe("q1"); + expect(d.confidence).toBe("high"); + expect(d.reason).toBe("same items"); + expect(d.doubts).toEqual(["qty differs"]); + }); + + it("nulls an unknown quote id", () => { + const d = parseOrderQuoteMatch( + { quote_id: "q-nope", confidence: "high", reason: "", doubts: [] }, + IDS, + ); + expect(d.quote_id).toBeNull(); + }); + + it("falls back to low for a bad confidence", () => { + const d = parseOrderQuoteMatch( + { quote_id: "q1", confidence: "certain", reason: "", doubts: [] }, + IDS, + ); + expect(d.confidence).toBe("low"); + }); + + it("drops unknown order line ids", () => { + const d = parseOrderQuoteMatch( + { + quote_id: "q1", + confidence: "high", + reason: "", + doubts: [], + lines: [{ order_line_id: "ol-nope", quote_line_id: "ql1", same_item: "sure", reason: "" }], + }, + IDS, + ); + expect(d.lines.map((l) => l.order_line_id)).toEqual(["ol1", "ol2", "ol3"]); + expect(d.lines.every((l) => l.quote_line_id === null)).toBe(true); + }); + + it("keeps the pairing for a known order and quote line", () => { + const d = parseOrderQuoteMatch( + { + quote_id: "q1", + confidence: "high", + reason: "", + doubts: [], + lines: [{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "same" }], + }, + IDS, + ); + const first = d.lines.find((l) => l.order_line_id === "ol1"); + expect(first?.quote_line_id).toBe("ql1"); + expect(first?.same_item).toBe("sure"); + expect(first?.reason).toBe("same"); + }); + + it("nulls an unknown quote line id", () => { + const d = parseOrderQuoteMatch( + { + quote_id: "q1", + confidence: "high", + reason: "", + doubts: [], + lines: [{ order_line_id: "ol1", quote_line_id: "ql-nope", same_item: "sure", reason: "" }], + }, + IDS, + ); + expect(d.lines.find((l) => l.order_line_id === "ol1")?.quote_line_id).toBeNull(); + }); + + it("gives the second order line null when a quote line is claimed twice", () => { + const d = parseOrderQuoteMatch( + { + quote_id: "q1", + confidence: "high", + reason: "", + doubts: [], + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, + { order_line_id: "ol2", quote_line_id: "ql1", same_item: "sure", reason: "" }, + ], + }, + IDS, + ); + expect(d.lines.find((l) => l.order_line_id === "ol1")?.quote_line_id).toBe("ql1"); + expect(d.lines.find((l) => l.order_line_id === "ol2")?.quote_line_id).toBeNull(); + }); + + it("returns order lines the AI skipped as unpaired", () => { + const d = parseOrderQuoteMatch( + { + quote_id: "q1", + confidence: "high", + reason: "", + doubts: [], + lines: [{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }], + }, + IDS, + ); + expect(d.lines).toHaveLength(3); + expect(d.lines.find((l) => l.order_line_id === "ol2")?.quote_line_id).toBeNull(); + expect(d.lines.find((l) => l.order_line_id === "ol3")?.quote_line_id).toBeNull(); + }); + + it("nulls a bad same_item", () => { + const d = parseOrderQuoteMatch( + { + quote_id: "q1", + confidence: "high", + reason: "", + doubts: [], + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "different", reason: "" }, + ], + }, + IDS, + ); + expect(d.lines.find((l) => l.order_line_id === "ol1")?.same_item).toBeNull(); + }); + + it("returns an empty decision for junk input", () => { + const d = parseOrderQuoteMatch(null, IDS); + expect(d.quote_id).toBeNull(); + expect(d.confidence).toBe("low"); + expect(d.lines).toEqual([]); + }); +}); + +describe("buildMatchUserText", () => { + const input: MatchInput = { + order: { + id: "o1", + order_number: "ORD-2026-10-001", + customer_name: "Afrisam", + lines: [ + { + id: "ol1", + description: "M16 bolt 60mm", + po_description: "BOLT M16X60", + quantity: 100, + sku: "BOLT-006", + selling_price: 3.5, + }, + ], + }, + quotes: [ + { + id: "q1", + quote_number: "Q-2026-10-014", + created: "2026-10-01", + lines: [ + { + id: "ql1", + description: "M16 x 60 bolt", + quantity: 200, + selling_price: 3.4, + sku: "56020510", + supplier_name: "Afrisam", + }, + ], + }, + ], + }; + + it("contains every order line id", () => { + const text = buildMatchUserText(input); + expect(text).toContain("ol1"); + }); + + it("contains every quote line id", () => { + const text = buildMatchUserText(input); + expect(text).toContain("ql1"); + }); + + it("contains the quote id and quote number", () => { + const text = buildMatchUserText(input); + expect(text).toContain("q1"); + expect(text).toContain("Q-2026-10-014"); + }); + + it("never contains the quote's email body", () => { + const withEmail = { + ...input, + quotes: [{ ...input.quotes[0], email_body: "PLEASE SEE ATTACHED RFQ LETTER" }], + } as unknown as MatchInput; + const text = buildMatchUserText(withEmail); + expect(text).not.toContain("PLEASE SEE ATTACHED RFQ LETTER"); + }); +}); + +describe("matchOrderWithAi", () => { + const input: MatchInput = { + order: { + id: "o1", + order_number: "ORD-2026-10-001", + customer_name: "Afrisam", + lines: [ + { + id: "ol1", + description: "M16 bolt 60mm", + po_description: "", + quantity: 100, + sku: "", + selling_price: 0, + }, + ], + }, + quotes: [ + { + id: "q1", + quote_number: "Q-2026-10-014", + created: "2026-10-01", + lines: [ + { + id: "ql1", + description: "M16 x 60 bolt", + quantity: 200, + selling_price: 3.4, + sku: "", + supplier_name: "Afrisam", + }, + ], + }, + ], + }; + + it("returns the parsed decision on the happy path", async () => { + const client = { + messages: { + create: vi.fn().mockResolvedValue( + toolInput({ + quote_id: "q1", + confidence: "high", + reason: "same items", + doubts: [], + lines: [{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "same" }], + }), + ), + }, + }; + const res = await matchOrderWithAi(input, { client: client as never }); + expect(res.model).toBe("test-model"); + expect(res.decision.quote_id).toBe("q1"); + expect(res.decision.confidence).toBe("high"); + expect(res.decision.lines[0].quote_line_id).toBe("ql1"); + }); + + it("throws when the model did not call the tool", async () => { + const client = { + messages: { + create: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "I think it is Q-14" }], + stop_reason: "end_turn", + }), + }, + }; + await expect(matchOrderWithAi(input, { client: client as never })).rejects.toThrow( + /no submit_order_quote_match tool call/, + ); + }); + + it("throws when the response was cut off", async () => { + const client = { + messages: { + create: vi.fn().mockResolvedValue({ + content: [], + stop_reason: "max_tokens", + }), + }, + }; + await expect(matchOrderWithAi(input, { client: client as never })).rejects.toThrow( + /cut off/, + ); + }); + + it("uses the given prompt override", async () => { + const create = vi.fn().mockResolvedValue( + toolInput({ quote_id: "q1", confidence: "high", reason: "", doubts: [], lines: [] }), + ); + const client = { messages: { create } }; + await matchOrderWithAi(input, { client: client as never, prompt: "MY PROMPT" }); + const body = create.mock.calls[0][0] as { system: string }; + expect(body.system).toBe("MY PROMPT"); + }); +}); diff --git a/src/lib/server/order-assistant-ai.ts b/src/lib/server/order-assistant-ai.ts new file mode 100644 index 0000000..4852187 --- /dev/null +++ b/src/lib/server/order-assistant-ai.ts @@ -0,0 +1,289 @@ +/** + * order-assistant-ai — the AI decision call for the order assistant. + * + * Sends an order's lines plus a shortlist of candidate quotes to the + * built-in AI and returns its decision: which quote the order came + * from, and how each order line pairs to a quote line. + * + * The AI only *chooses among* the ids we hand it. It never invents an + * id, never touches a price, and never changes a line's status — the + * service layer decides what to do with the answer. + */ +import Anthropic from "@anthropic-ai/sdk"; +import { getActiveAiConfig } from "./ai-settings-service"; +import { DEFAULT_ORDER_ASSISTANT_PROMPT } from "./order-assistant-prompt"; +import type { AiOrderMatchDecision } from "./order-assistant-core"; + +export interface MatchOrderLine { + id: string; + description: string; + po_description: string; + quantity: number; + sku: string; + selling_price: number; +} + +export interface MatchQuoteLine { + id: string; + description: string; + quantity: number; + selling_price: number; + sku: string; + supplier_name: string; +} + +export interface MatchInput { + order: { + id: string; + order_number: string; + customer_name: string; + lines: MatchOrderLine[]; + }; + quotes: Array<{ + id: string; + quote_number: string; + created: string; + lines: MatchQuoteLine[]; + }>; +} + +const TOOL_NAME = "submit_order_quote_match" as const; + +export const SUBMIT_ORDER_QUOTE_MATCH_TOOL = { + name: TOOL_NAME, + description: + "Submit which quote this order came from, and how each order line pairs to a quote line. Call this tool exactly once.", + input_schema: { + type: "object" as const, + properties: { + quote_id: { + type: ["string", "null"], + description: "The id of the quote the order came from, or null when none of them does.", + }, + confidence: { + type: "string", + enum: ["high", "medium", "low"], + description: "How sure you are that the order came from the chosen quote.", + }, + reason: { + type: "string", + description: "One or two plain sentences a salesperson can read.", + }, + doubts: { + type: "array", + items: { type: "string" }, + description: "Differences or uncertainties the salesperson should check.", + }, + lines: { + type: "array", + description: "One entry per order line.", + items: { + type: "object", + properties: { + order_line_id: { + type: "string", + description: "The id of the order line, exactly as given.", + }, + quote_line_id: { + type: ["string", "null"], + description: "The id of the matching quote line, or null when the order line has no quote line.", + }, + same_item: { + type: ["string", "null"], + enum: ["sure", "likely", "unsure", null], + }, + reason: { type: "string" }, + }, + required: ["order_line_id", "quote_line_id", "same_item", "reason"], + }, + }, + }, + required: ["quote_id", "confidence", "reason", "doubts", "lines"], + }, +}; + +const CONFIDENCES: readonly string[] = ["high", "medium", "low"]; +const SAME_ITEMS: readonly string[] = ["sure", "likely", "unsure"]; + +function formatMoney(n: number | null | undefined): string { + return String(Number(n) || 0); +} + +/** + * Plain-text description of the order and the candidate quotes for the + * model. Only ids and line facts — never the quote's email body. + */ +export function buildMatchUserText(input: MatchInput): string { + const out: string[] = [ + `Order ${input.order.order_number} for ${input.order.customer_name || "unknown customer"}`, + "", + "Order lines:", + ]; + for (const line of input.order.lines) { + out.push(`- order_line_id=${line.id} | ${line.description} | qty ${line.quantity ?? "?"}`); + if (line.po_description && line.po_description !== line.description) { + out.push(` Customer PO description: ${line.po_description}`); + } + if (line.sku) out.push(` SKU: ${line.sku}`); + if (line.selling_price) out.push(` Selling price: ${formatMoney(line.selling_price)}`); + } + + out.push("", "Candidate quotes:"); + for (const quote of input.quotes) { + out.push(`- quote_id=${quote.id} | ${quote.quote_number} | created ${quote.created || "-"}`); + for (const line of quote.lines) { + out.push( + ` - quote_line_id=${line.id} | ${line.description} | qty ${line.quantity ?? "?"} | selling ${formatMoney(line.selling_price)} | SKU ${line.sku || "-"} | supplier ${line.supplier_name || "-"}`, + ); + } + } + return out.join("\n"); +} + +/** + * Defensive parse of the AI's answer. + * + * - an unknown quote id becomes `null` (never a guess), + * - unknown order line ids are dropped, + * - a quote line claimed twice gives the second order line + * `quote_line_id: null`, + * - order lines the AI skipped come back unpaired, + * - an unusable confidence becomes "low". + */ +export function parseOrderQuoteMatch( + raw: unknown, + ids: { orderLineIds: Set; quoteIds: Set; quoteLineIds: Set }, +): AiOrderMatchDecision { + const empty: AiOrderMatchDecision = { + quote_id: null, + confidence: "low", + reason: "", + doubts: [], + lines: [], + }; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return empty; + const r = raw as Record; + + const quoteId = + typeof r.quote_id === "string" && ids.quoteIds.has(r.quote_id) ? r.quote_id : null; + + const confidence = + typeof r.confidence === "string" && CONFIDENCES.includes(r.confidence) + ? (r.confidence as "high" | "medium" | "low") + : "low"; + + const reason = typeof r.reason === "string" ? r.reason.trim() : ""; + const doubts = Array.isArray(r.doubts) + ? r.doubts.filter((d): d is string => typeof d === "string" && d.trim().length > 0) + : []; + + const claimedQuoteLines = new Set(); + const byOrderLine = new Map(); + + if (Array.isArray(r.lines)) { + for (const item of r.lines) { + if (!item || typeof item !== "object") continue; + const entry = item as Record; + const orderLineId = typeof entry.order_line_id === "string" ? entry.order_line_id : ""; + if (!ids.orderLineIds.has(orderLineId)) continue; + // The AI should answer once per order line; the first answer wins. + if (byOrderLine.has(orderLineId)) continue; + + const rawQuoteLineId = typeof entry.quote_line_id === "string" ? entry.quote_line_id : null; + const quoteLineId = + rawQuoteLineId && ids.quoteLineIds.has(rawQuoteLineId) && !claimedQuoteLines.has(rawQuoteLineId) + ? rawQuoteLineId + : null; + if (quoteLineId) claimedQuoteLines.add(quoteLineId); + + const sameItemRaw = typeof entry.same_item === "string" ? entry.same_item : null; + const sameItem = + sameItemRaw && SAME_ITEMS.includes(sameItemRaw) + ? (sameItemRaw as "sure" | "likely" | "unsure") + : null; + + byOrderLine.set(orderLineId, { + order_line_id: orderLineId, + quote_line_id: quoteLineId, + same_item: sameItem, + reason: typeof entry.reason === "string" ? entry.reason.trim() : "", + }); + } + } + + // Every order line comes back — one the AI skipped is simply unpaired. + const lines: AiOrderMatchDecision["lines"] = []; + for (const orderLineId of ids.orderLineIds) { + lines.push( + byOrderLine.get(orderLineId) ?? { + order_line_id: orderLineId, + quote_line_id: null, + same_item: null, + reason: "", + }, + ); + } + + return { quote_id: quoteId, confidence, reason, doubts, lines }; +} + +function newAnthropic(aiConfig: { apiKey: string; baseUrl: string }): Anthropic { + return new Anthropic({ + apiKey: aiConfig.apiKey, + // Same base-URL handling as quotes-ai-processor: pass the provider's bare + // base URL through unchanged; the SDK appends its own path. + ...(aiConfig.baseUrl ? { baseURL: aiConfig.baseUrl } : {}), + }); +} + +/** + * Ask the AI which quote the order came from. + * + * Throws when the response was cut off (`max_tokens`) or the model did + * not call the tool — the caller records that as an AI error rather + * than treating it as "no match". + */ +export async function matchOrderWithAi( + input: MatchInput, + deps: { client?: Pick; prompt?: string } = {}, +): Promise<{ decision: AiOrderMatchDecision; model: string }> { + const aiConfig = await getActiveAiConfig(); + const client = deps.client ?? newAnthropic(aiConfig); + const system = deps.prompt || DEFAULT_ORDER_ASSISTANT_PROMPT; + + const response = await client.messages.create({ + model: aiConfig.modelId, + max_tokens: 4096, + tools: [SUBMIT_ORDER_QUOTE_MATCH_TOOL], + tool_choice: { type: "tool", name: TOOL_NAME }, + system, + messages: [{ role: "user", content: buildMatchUserText(input) }], + }); + + if (response.stop_reason === "max_tokens") { + throw new Error( + `AI response was cut off (max_tokens) at ${aiConfig.maxTokens} tokens before the match was complete`, + ); + } + + const toolBlock = response.content.find( + (b) => b.type === "tool_use" && b.name === TOOL_NAME, + ); + if (!toolBlock || toolBlock.type !== "tool_use") { + throw new Error("AI returned no submit_order_quote_match tool call"); + } + + const toolInput = toolBlock.input as Record | null; + const orderLineIds = new Set(input.order.lines.map((l) => l.id)); + const quoteIds = new Set(input.quotes.map((q) => q.id)); + const quoteLineIds = new Set(input.quotes.flatMap((q) => q.lines.map((l) => l.id))); + + return { + decision: parseOrderQuoteMatch(toolInput, { + orderLineIds, + quoteIds, + quoteLineIds, + }), + model: aiConfig.modelId, + }; +} diff --git a/src/lib/server/order-assistant-prompt.ts b/src/lib/server/order-assistant-prompt.ts new file mode 100644 index 0000000..b08878a --- /dev/null +++ b/src/lib/server/order-assistant-prompt.ts @@ -0,0 +1,22 @@ +/** + * Default system prompt for the AI order assistant (matches an order to + * the quote it came from). + * + * The operator can override it at runtime via + * `ai_settings.order_assistant_prompt`; an empty override means "use + * this". Keep it plain text (no markdown) — it is sent as the `system:` + * field verbatim. The rules assume the `submit_order_quote_match` tool. + */ +export const DEFAULT_ORDER_ASSISTANT_PROMPT = `You decide which of the given quotes an order came from, for a South African industrial supplier. + +Rules: +1. A quote only matches an order when the ITEMS match — the same products, in the same or similar wording. The same customer is NOT a match. Two different orders from one customer are common. +2. Quantities may differ: the customer may have taken part of the quoted quantity, or ordered more. A quantity difference does not break a match. +3. Prices may differ slightly between the quote and the order. A price difference is not by itself a reason to call it a different order. +4. An order line may have no quote line at all — the customer added something new. Say so with a null quote_line_id; that does not break a match on the other lines. +5. Match on the product: size, thread, length, grade/class, material and finish where the lines state them. A near-miss is NOT a match. +6. same_item: "sure" when you are certain it is the same product, "likely" when it probably is, "unsure" when you are not. +7. confidence is about the OVERALL match: "high" when the items line up clearly, "medium" when the match is probable but something is not stated, "low" when you are guessing. +8. Use only the quote_id, order_line_id and quote_line_id values you were given. Never invent an id. +9. reason: one or two plain sentences a salesperson understands. doubts: short plain sentences, empty list if none. +10. Call submit_order_quote_match exactly once.`;