feat(orders): AI call that matches an order to a quote

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 13:00:03 +02:00
parent d5c677ad4e
commit 8eb64f2b2d
3 changed files with 638 additions and 0 deletions

View file

@ -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<string, unknown>) {
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");
});
});

View file

@ -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<string>; quoteIds: Set<string>; quoteLineIds: Set<string> },
): 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<string, unknown>;
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<string>();
const byOrderLine = new Map<string, AiOrderMatchDecision["lines"][number]>();
if (Array.isArray(r.lines)) {
for (const item of r.lines) {
if (!item || typeof item !== "object") continue;
const entry = item as Record<string, unknown>;
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<Anthropic, "messages">; 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<string, unknown> | 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,
};
}

View file

@ -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.`;