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:
parent
d5c677ad4e
commit
8eb64f2b2d
3 changed files with 638 additions and 0 deletions
327
src/lib/server/order-assistant-ai.test.ts
Normal file
327
src/lib/server/order-assistant-ai.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
289
src/lib/server/order-assistant-ai.ts
Normal file
289
src/lib/server/order-assistant-ai.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
22
src/lib/server/order-assistant-prompt.ts
Normal file
22
src/lib/server/order-assistant-prompt.ts
Normal 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.`;
|
||||
Loading…
Reference in a new issue