diff --git a/src/lib/server/quote-assistant-ai.test.ts b/src/lib/server/quote-assistant-ai.test.ts new file mode 100644 index 0000000..73acbf9 --- /dev/null +++ b/src/lib/server/quote-assistant-ai.test.ts @@ -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 { + 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; + 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", + ); + }); +}); diff --git a/src/lib/server/quote-assistant-ai.ts b/src/lib/server/quote-assistant-ai.ts new file mode 100644 index 0000000..85f9a41 --- /dev/null +++ b/src/lib/server/quote-assistant-ai.ts @@ -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; + 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 } = {}, +): Promise { + 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, + }; +}