feat(quote-assistant): MiniMax match decision call
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
29144943d0
commit
bd78f0b3c1
2 changed files with 319 additions and 0 deletions
169
src/lib/server/quote-assistant-ai.test.ts
Normal file
169
src/lib/server/quote-assistant-ai.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
150
src/lib/server/quote-assistant-ai.ts
Normal file
150
src/lib/server/quote-assistant-ai.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue