WFDASH-WEBAPP/src/lib/server/quote-assistant-ai.ts
Dave bd78f0b3c1 feat(quote-assistant): MiniMax match decision call
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:22:18 +02:00

150 lines
5.2 KiB
TypeScript

/**
* 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,
};
}