From d5c677ad4e7d12a8a0c60c7fabceb93315622110 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 12:58:58 +0200 Subject: [PATCH] feat(orders): order assistant eligibility and auto-link rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 of the AI order assistant plan. Pure core, no I/O: - PLACEHOLDER_LINE_PATTERNS, isRealOrderLine - orderLinesHash (sha1 over sorted id|description|quantity) - isOrderEligible (no quote, >=1 real pending line, not already matched on the same lines unless forced, not in the AI-error cooldown) - decideOutcome — the auto-link rule: high confidence, every real line paired "sure" on the chosen quote, same company, auto-link on - confidenceToScore (0.9 / 0.7 / 0.5) Co-Authored-By: Claude Opus 5.5 --- src/lib/server/order-assistant-core.test.ts | 338 ++++++++++++++++++++ src/lib/server/order-assistant-core.ts | 193 +++++++++++ 2 files changed, 531 insertions(+) create mode 100644 src/lib/server/order-assistant-core.test.ts create mode 100644 src/lib/server/order-assistant-core.ts diff --git a/src/lib/server/order-assistant-core.test.ts b/src/lib/server/order-assistant-core.test.ts new file mode 100644 index 0000000..b8b4ffd --- /dev/null +++ b/src/lib/server/order-assistant-core.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; +import { + confidenceToScore, + decideOutcome, + isOrderEligible, + isRealOrderLine, + orderLinesHash, + PLACEHOLDER_LINE_PATTERNS, + type AiOrderMatchDecision, + type OrderItemLite, +} from "@/lib/server/order-assistant-core"; +import type { OrderAiQuoteMatch } from "@/lib/types"; + +const NOW = new Date("2026-10-24T12:00:00.000Z"); + +function line(over: Partial = {}): OrderItemLite { + return { + id: "ol1", + description: "M16 bolt 60mm", + quantity: 10, + item_status: "pending", + ...over, + }; +} + +describe("PLACEHOLDER_LINE_PATTERNS", () => { + it("covers the two known placeholders", () => { + expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("See attached"))).toBe(true); + expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("Buyer to add items"))).toBe(true); + }); +}); + +describe("isRealOrderLine", () => { + it("is false for an empty description", () => { + expect(isRealOrderLine({ description: "" })).toBe(false); + expect(isRealOrderLine({ description: " " })).toBe(false); + expect(isRealOrderLine({})).toBe(false); + }); + + it("is false for a placeholder line", () => { + expect(isRealOrderLine({ description: "See attached" })).toBe(false); + expect(isRealOrderLine({ description: "buyer to add items" })).toBe(false); + }); + + it("is false for a cancelled line", () => { + expect(isRealOrderLine({ description: "M16 bolt", item_status: "cancelled" })).toBe(false); + }); + + it("is true for a normal pending line", () => { + expect(isRealOrderLine({ description: "M16 bolt 60mm", item_status: "pending" })).toBe(true); + }); +}); + +describe("orderLinesHash", () => { + it("is stable over line order", () => { + const a = orderLinesHash([ + { id: "ol1", description: "Bolt", quantity: 10 }, + { id: "ol2", description: "Nut", quantity: 5 }, + ]); + const b = orderLinesHash([ + { id: "ol2", description: "Nut", quantity: 5 }, + { id: "ol1", description: "Bolt", quantity: 10 }, + ]); + expect(a).toBe(b); + }); + + it("changes when a quantity changes", () => { + const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]); + const b = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 11 }]); + expect(a).not.toBe(b); + }); + + it("changes when a line is added", () => { + const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]); + const b = orderLinesHash([ + { id: "ol1", description: "Bolt", quantity: 10 }, + { id: "ol2", description: "Nut", quantity: 5 }, + ]); + expect(a).not.toBe(b); + }); + + it("changes when a description changes", () => { + const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]); + const b = orderLinesHash([{ id: "ol1", description: "Nut", quantity: 10 }]); + expect(a).not.toBe(b); + }); +}); + +describe("isOrderEligible", () => { + it("is false when the order already has a quote", () => { + expect( + isOrderEligible({ quote: "q1" }, [line()], { now: NOW }), + ).toBe(false); + }); + + it("is false when there is no real line", () => { + expect( + isOrderEligible({}, [line({ description: "See attached" })], { now: NOW }), + ).toBe(false); + expect(isOrderEligible({}, [], { now: NOW })).toBe(false); + }); + + it("is false when no real line is pending", () => { + expect( + isOrderEligible({}, [line({ item_status: "po_generated" })], { now: NOW }), + ).toBe(false); + }); + + it("is true when a real line is pending", () => { + expect( + isOrderEligible({}, [line({ item_status: "pending" })], { now: NOW }), + ).toBe(true); + }); + + it("ignores non-pending lines when deciding", () => { + expect( + isOrderEligible( + {}, + [line({ id: "ol1", item_status: "po_generated" }), line({ id: "ol2" })], + { now: NOW }, + ), + ).toBe(true); + }); + + it("is false when the same lines were already matched", () => { + const lines = [line()]; + const hash = orderLinesHash(lines); + const ai_quote_match: OrderAiQuoteMatch = { + status: "suggested", + quote_id: "q1", + quote_number: "Q-1", + confidence: "medium", + reason: "", + doubts: [], + lines: [], + auto_linked: false, + lines_hash: hash, + model: "m", + run_at: NOW.toISOString(), + }; + expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW })).toBe(false); + }); + + it("is true for the same lines when forced", () => { + const lines = [line()]; + const hash = orderLinesHash(lines); + const ai_quote_match: OrderAiQuoteMatch = { + status: "suggested", + quote_id: "q1", + quote_number: "Q-1", + confidence: "medium", + reason: "", + doubts: [], + lines: [], + auto_linked: false, + lines_hash: hash, + model: "m", + run_at: NOW.toISOString(), + }; + expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW, force: true })).toBe(true); + }); + + it("is true when the lines changed since the last run", () => { + const ai_quote_match: OrderAiQuoteMatch = { + status: "suggested", + quote_id: "q1", + quote_number: "Q-1", + confidence: "medium", + reason: "", + doubts: [], + lines: [], + auto_linked: false, + lines_hash: "stale-hash", + model: "m", + run_at: NOW.toISOString(), + }; + expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true); + }); + + it("is false within the error cooldown", () => { + const ai_quote_match: OrderAiQuoteMatch = { + status: "error", + quote_id: null, + quote_number: "", + confidence: null, + reason: "", + doubts: [], + lines: [], + auto_linked: false, + lines_hash: "stale-hash", + model: "m", + run_at: new Date(NOW.getTime() - 30 * 60 * 1000).toISOString(), + error: "boom", + }; + expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(false); + }); + + it("is true after the error cooldown", () => { + const ai_quote_match: OrderAiQuoteMatch = { + status: "error", + quote_id: null, + quote_number: "", + confidence: null, + reason: "", + doubts: [], + lines: [], + auto_linked: false, + lines_hash: "stale-hash", + model: "m", + run_at: new Date(NOW.getTime() - 61 * 60 * 1000).toISOString(), + error: "boom", + }; + expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true); + }); +}); + +describe("decideOutcome", () => { + const QUOTE_LINE_TO_QUOTE = new Map([ + ["ql1", "q1"], + ["ql2", "q1"], + ]); + + function decision(over: Partial = {}): AiOrderMatchDecision { + return { + quote_id: "q1", + confidence: "high", + reason: "same items", + doubts: [], + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "same" }, + { order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "same" }, + ], + ...over, + }; + } + + const BASE = { + realLineIds: ["ol1", "ol2"], + quoteLineToQuote: QUOTE_LINE_TO_QUOTE, + orderCompany: "c1", + quoteCompany: "c1", + autoLinkEnabled: true, + }; + + it("auto-links when everything is sure on one quote", () => { + expect(decideOutcome({ decision: decision(), ...BASE })).toBe("auto_link"); + }); + + it("suggests when one line is only likely", () => { + const d = decision({ + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, + { order_line_id: "ol2", quote_line_id: "ql2", same_item: "likely", reason: "" }, + ], + }); + expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest"); + }); + + it("suggests when one line is unpaired", () => { + const d = decision({ + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, + { order_line_id: "ol2", quote_line_id: null, same_item: null, reason: "" }, + ], + }); + expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest"); + }); + + it("suggests when a pair points at another quote's line", () => { + const d = decision({ + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, + { order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" }, + ], + }); + expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest"); + }); + + it("suggests when the company differs", () => { + expect( + decideOutcome({ decision: decision(), ...BASE, quoteCompany: "c2" }), + ).toBe("suggest"); + }); + + it("suggests when the quote has no company", () => { + expect( + decideOutcome({ decision: decision(), ...BASE, quoteCompany: null }), + ).toBe("suggest"); + }); + + it("suggests when auto-link is switched off", () => { + expect( + decideOutcome({ decision: decision(), ...BASE, autoLinkEnabled: false }), + ).toBe("suggest"); + }); + + it("suggests when the confidence is medium", () => { + expect( + decideOutcome({ decision: decision({ confidence: "medium" }), ...BASE }), + ).toBe("suggest"); + }); + + it("is no_match when the AI chose no quote", () => { + expect( + decideOutcome({ decision: decision({ quote_id: null }), ...BASE }), + ).toBe("no_match"); + }); + + it("is no_match when no pair lands on the chosen quote", () => { + const d = decision({ + lines: [ + { order_line_id: "ol1", quote_line_id: "qlOther", same_item: "sure", reason: "" }, + { order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" }, + ], + }); + expect(decideOutcome({ decision: d, ...BASE })).toBe("no_match"); + }); + + it("ignores pairs for lines that are not real order lines", () => { + const d = decision({ + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, + { order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "" }, + { order_line_id: "olPlaceholder", quote_line_id: "ql1", same_item: "unsure", reason: "" }, + ], + }); + expect(decideOutcome({ decision: d, ...BASE })).toBe("auto_link"); + }); +}); + +describe("confidenceToScore", () => { + it("maps the three levels", () => { + expect(confidenceToScore("high")).toBe(0.9); + expect(confidenceToScore("medium")).toBe(0.7); + expect(confidenceToScore("low")).toBe(0.5); + expect(confidenceToScore(null)).toBe(0); + }); +}); diff --git a/src/lib/server/order-assistant-core.ts b/src/lib/server/order-assistant-core.ts new file mode 100644 index 0000000..bb2c27f --- /dev/null +++ b/src/lib/server/order-assistant-core.ts @@ -0,0 +1,193 @@ +/** + * Pure core for the AI order assistant: eligibility, the auto-link rule + * and the order-line fingerprint. No I/O — everything here is a + * function of its arguments, so it is cheap to test exhaustively. + */ +import { createHash } from "node:crypto"; +import type { OrderAiQuoteMatch, OrderItem } from "@/lib/types"; +import { ERROR_RETRY_MS } from "@/lib/server/quote-assistant-core"; + +/** + * Line descriptions that mean "a real order line will be added later". + * An order made up of these is not a real order, so the assistant + * leaves it alone. + */ +export const PLACEHOLDER_LINE_PATTERNS: RegExp[] = [ + /see attached/i, + /buyer to add items/i, +]; + +/** + * The subset of an order line the core needs. Kept deliberately small + * so callers can pass a light projection of `OrderItem`. + */ +export type OrderItemLite = Pick; + +/** + * The AI's answer to "which quote does this order come from?". + * + * Defined here (not in the AI module) because both the core and the AI + * layer need it and the core must stay dependency-free. + */ +export interface AiOrderMatchDecision { + quote_id: string | null; + confidence: "high" | "medium" | "low"; + reason: string; + doubts: string[]; + lines: Array<{ + order_line_id: string; + quote_line_id: string | null; + same_item: "sure" | "likely" | "unsure" | null; + reason: string; + }>; +} + +/** + * Is this a line the customer actually wants? Placeholders ("see + * attached") and cancelled lines are not. + */ +export function isRealOrderLine(line: { + description?: string; + item_status?: string; +}): boolean { + const description = (line.description ?? "").trim(); + if (!description) return false; + if (PLACEHOLDER_LINE_PATTERNS.some((p) => p.test(description))) return false; + if (line.item_status === "cancelled") return false; + return true; +} + +/** + * A fingerprint of the order lines the assistant looked at. + * + * Stable over line order (the lines are sorted by id first) so a + * re-run on the same order produces the same hash, but any change to + * a description or a quantity produces a different one. Used to skip + * an order the assistant already looked at. + */ +export function orderLinesHash( + lines: Array<{ id: string; description?: string; quantity?: number | null }>, +): string { + const normalised = lines + .map((l) => `${l.id}|${(l.description ?? "").trim()}|${l.quantity ?? ""}`) + .sort() + .join("\n"); + return createHash("sha1").update(normalised).digest("hex"); +} + +/** + * Should the assistant look at this order at all? + * + * An order is eligible when it has no quote yet, has at least one real + * line, has at least one real line still in `pending`, has not been + * looked at for the same set of lines already, and is not in a recent + * AI-error cooldown. + */ +export function isOrderEligible( + order: { quote?: string; ai_quote_match?: OrderAiQuoteMatch | null }, + lines: OrderItemLite[], + opts: { now: Date; force?: boolean }, +): boolean { + // Already linked to a quote — the assistant has nothing to decide. + if (order.quote) return false; + + const realLines = lines.filter((l) => isRealOrderLine(l)); + if (realLines.length === 0) return false; + + // Nothing to price if no real line is still pending. + if (!realLines.some((l) => l.item_status === "pending")) return false; + + const match = order.ai_quote_match ?? null; + + // Already matched these exact lines? Only look again when forced. + if (!opts.force && match && match.lines_hash === orderLinesHash(realLines)) { + return false; + } + + // A recent AI error is retried after ERROR_RETRY_MS, not every tick. + if (match && match.status === "error") { + const ranAt = Date.parse(match.run_at); + if (!Number.isNaN(ranAt)) { + const age = opts.now.getTime() - ranAt; + if (age >= 0 && age < ERROR_RETRY_MS) return false; + } + } + + return true; +} + +/** + * The auto-link rule, in one place. + * + * The assistant may hard-link an order to a quote only when ALL of + * these hold: + * - the AI is confident ("high"), + * - every real order line is paired to a quote line, and every one + * of those pairs is "sure", + * - every pair points at a line of the ONE chosen quote, + * - the quote's company is the order's company, + * - the operator left auto-link switched on. + * + * Anything else is a suggestion for a human to accept. + */ +export function decideOutcome(args: { + decision: AiOrderMatchDecision; + realLineIds: string[]; + quoteLineToQuote: Map; + orderCompany: string; + quoteCompany: string | null; + autoLinkEnabled: boolean; +}): "auto_link" | "suggest" | "no_match" { + const { decision, realLineIds, quoteLineToQuote, orderCompany, quoteCompany, autoLinkEnabled } = + args; + + if (!decision.quote_id) return "no_match"; + + const pairs = decision.lines.filter((l) => realLineIds.includes(l.order_line_id)); + + // The AI chose a quote but did not pair a single real line to it — + // there is nothing to back the decision up. + const pairsOnChosenQuote = pairs.filter( + (l) => l.quote_line_id && quoteLineToQuote.get(l.quote_line_id) === decision.quote_id, + ); + if (pairsOnChosenQuote.length === 0) return "no_match"; + + if (!autoLinkEnabled) return "suggest"; + if (decision.confidence !== "high") return "suggest"; + + // Every real line must be paired to a quote line of the chosen quote. + const pairedOnChosen = new Set( + pairs + .filter( + (l) => + l.quote_line_id && + quoteLineToQuote.get(l.quote_line_id) === decision.quote_id && + l.same_item === "sure", + ) + .map((l) => l.order_line_id), + ); + for (const id of realLineIds) { + if (!pairedOnChosen.has(id)) return "suggest"; + } + + // Same customer on both sides. + if (!quoteCompany || quoteCompany !== orderCompany) return "suggest"; + + return "auto_link"; +} + +/** + * The `potential_quote_confidence` number stored on the order. + */ +export function confidenceToScore(c: "high" | "medium" | "low" | null): number { + switch (c) { + case "high": + return 0.9; + case "medium": + return 0.7; + case "low": + return 0.5; + default: + return 0; + } +}