feat(quote-assistant): MiniMax match decision call

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 19:22:18 +02:00
parent 29144943d0
commit bd78f0b3c1
2 changed files with 319 additions and 0 deletions

View file

@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const getAiSettings = vi.fn();
vi.mock("@/lib/server/ai-settings-service", () => ({
getActiveAiConfig: vi.fn(async () => ({
modelId: "MiniMax-M3",
maxTokens: 4096,
apiKey: "k",
baseUrl: "u",
provider: "minimax",
})),
getAiSettings: (...a: unknown[]) => getAiSettings(...a),
}));
import {
buildDecideUserText,
decideWithAi,
SUBMIT_LINE_MATCHES_TOOL,
type DecideInput,
} from "./quote-assistant-ai";
import { DEFAULT_QUOTE_ASSISTANT_PROMPT } from "./quote-assistant-prompt";
import type { StockCandidate } from "./quote-assistant-shortlist";
function candidate(over: Partial<StockCandidate> = {}): StockCandidate {
return {
id: "stk1",
name: "Hex bolt M10x50 zinc",
sku: "HB-1050",
description: "",
notes: "",
unit: "each",
cost_price: 3.5,
selling_price: 5.25,
markup: 50,
supplier_id: "sup1",
supplier_name: "Bolt Co",
score: 0.8,
...over,
};
}
const input: DecideInput = {
quoteNumber: "Q-100",
lines: [
{
quote_item_id: "qi1",
description: "M10 x 50 hex bolt zinc",
quantity: 200,
sku: "",
notes: "",
candidates: [candidate()],
},
{
quote_item_id: "qi2",
description: "Mystery widget",
quantity: 3,
sku: "W-9",
notes: "urgent",
candidates: [],
},
],
};
function fakeClient(content: unknown[]) {
const create = vi.fn<(args: unknown) => Promise<{ content: unknown[]; stop_reason: string }>>(
async () => ({ content, stop_reason: "tool_use" }),
);
return { client: { messages: { create } } as never, create };
}
const toolUse = (inputObj: unknown) => ({
type: "tool_use",
id: "t1",
name: "submit_line_matches",
input: inputObj,
});
beforeEach(() => {
getAiSettings.mockReset();
getAiSettings.mockResolvedValue({ quote_assistant_prompt: "" });
});
describe("buildDecideUserText", () => {
it("includes line ids, descriptions, quantities and candidate details", () => {
const text = buildDecideUserText(input);
expect(text).toContain("Q-100");
expect(text).toContain("qi1");
expect(text).toContain("M10 x 50 hex bolt zinc");
expect(text).toContain("200");
expect(text).toContain("stock_item_id=stk1");
expect(text).toContain("Hex bolt M10x50 zinc");
expect(text).toContain("HB-1050");
expect(text).toContain("each");
expect(text).toContain("3.5");
expect(text).toContain("5.25");
expect(text).toContain("Bolt Co");
expect(text).toContain("qi2");
});
it("never mentions 'price list'", () => {
expect(buildDecideUserText(input).toLowerCase()).not.toContain("price list");
});
});
describe("decideWithAi", () => {
it("forces the submit_line_matches tool and uses the default prompt when override is empty", async () => {
const { client, create } = fakeClient([toolUse({ matches: [] })]);
const res = await decideWithAi(input, { client });
const args = create.mock.calls[0][0] as Record<string, unknown>;
expect(args.tool_choice).toEqual({ type: "tool", name: "submit_line_matches" });
expect(args.tools).toEqual([SUBMIT_LINE_MATCHES_TOOL]);
expect(args.system).toBe(DEFAULT_QUOTE_ASSISTANT_PROMPT);
expect(args.model).toBe("MiniMax-M3");
expect(args.max_tokens).toBe(4096);
expect(res.model).toBe("MiniMax-M3");
});
it("uses the override prompt when set", async () => {
getAiSettings.mockResolvedValue({ quote_assistant_prompt: "Custom rules" });
const { client, create } = fakeClient([toolUse({ matches: [] })]);
await decideWithAi(input, { client });
expect((create.mock.calls[0][0] as { system: string }).system).toBe("Custom rules");
});
it("treats a whitespace-only override as empty", async () => {
getAiSettings.mockResolvedValue({ quote_assistant_prompt: " \n " });
const { client, create } = fakeClient([toolUse({ matches: [] })]);
await decideWithAi(input, { client });
expect((create.mock.calls[0][0] as { system: string }).system).toBe(
DEFAULT_QUOTE_ASSISTANT_PROMPT,
);
});
it("parses tool matches end-to-end into decisions keyed by id", async () => {
const { client } = fakeClient([
{ type: "text", text: "thinking" },
toolUse({
matches: [
{
quote_item_id: "qi1",
stock_item_id: "stk1",
confidence: "high",
reason: "Same bolt",
doubts: ["pack size"],
},
{ quote_item_id: "qi2", stock_item_id: null, confidence: "low", reason: "none", doubts: [] },
{ quote_item_id: "ghost", stock_item_id: "x", confidence: "high", reason: "", doubts: [] },
],
}),
]);
const res = await decideWithAi(input, { client });
expect([...res.decisions.keys()].sort()).toEqual(["qi1", "qi2"]);
expect(res.decisions.get("qi1")).toEqual({
quote_item_id: "qi1",
stock_item_id: "stk1",
confidence: "high",
reason: "Same bolt",
doubts: ["pack size"],
});
expect(res.decisions.get("qi2")?.stock_item_id).toBeNull();
});
it("throws when the response has no submit_line_matches tool call", async () => {
const { client } = fakeClient([{ type: "text", text: "sorry" }]);
await expect(decideWithAi(input, { client })).rejects.toThrow(
"AI returned no submit_line_matches tool call",
);
});
});

View file

@ -0,0 +1,150 @@
/**
* quote-assistant-ai — the AI decision call for the quote assistant.
*
* Sends a quote's unpriced lines plus their shortlisted POS candidates to
* the built-in AI (MiniMax via the Anthropic SDK, same wiring as the intake
* in quotes-ai-processor) and returns its per-line match decisions. The AI
* only *chooses* among the candidates we hand it; prices never come from it.
*/
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";
export interface DecideLine {
quote_item_id: string;
description: string;
quantity: number;
sku: string;
notes: string;
candidates: StockCandidate[];
}
export interface DecideInput {
quoteNumber: string;
lines: DecideLine[];
}
export interface DecideResult {
decisions: Map<string, AiLineDecision>;
model: string;
}
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.",
input_schema: {
type: "object" as const,
properties: {
matches: {
type: "array",
description: "One decision per quote line.",
items: {
type: "object",
properties: {
quote_item_id: {
type: "string",
description: "The quote_item_id of the line, exactly as given.",
},
stock_item_id: {
type: ["string", "null"],
description:
"The stock_item_id of the chosen candidate for this line, or null when none is the same product.",
},
confidence: {
type: "string",
enum: ["high", "medium", "low"],
description: "How sure you are that the chosen candidate is the same product.",
},
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.",
},
},
required: ["quote_item_id", "stock_item_id", "confidence", "reason", "doubts"],
},
},
},
required: ["matches"],
},
};
function formatMoney(n: number): string {
return String(Number(n) || 0);
}
/** Plain-text description of the lines and their candidates for the model. */
export function buildDecideUserText(input: DecideInput): string {
const out: string[] = [`Quote ${input.quoteNumber}`, ""];
for (const line of input.lines) {
out.push(`Line quote_item_id=${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}`);
if (line.candidates.length === 0) {
out.push(" Candidates: none");
} else {
out.push(" Candidates:");
for (const c of line.candidates) {
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("");
}
return out.join("\n");
}
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 } : {}),
});
}
export async function decideWithAi(
input: DecideInput,
deps: { client?: Pick<Anthropic, "messages"> } = {},
): Promise<DecideResult> {
const aiConfig = await getActiveAiConfig();
const client = deps.client ?? newAnthropic(aiConfig);
const settings = await getAiSettings();
const override = (settings.quote_assistant_prompt || "").trim();
const system = override ? settings.quote_assistant_prompt : DEFAULT_QUOTE_ASSISTANT_PROMPT;
const response = await client.messages.create({
model: aiConfig.modelId,
max_tokens: aiConfig.maxTokens,
tools: [SUBMIT_LINE_MATCHES_TOOL],
tool_choice: { type: "tool", name: TOOL_NAME },
system,
messages: [{ role: "user", content: buildDecideUserText(input) }],
});
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_line_matches tool call");
}
const toolInput = toolBlock.input as { matches?: unknown } | null;
const requestedIds = new Set(input.lines.map((l) => l.quote_item_id));
return {
decisions: parseDecisions(toolInput?.matches, requestedIds),
model: aiConfig.modelId,
};
}