150 lines
5.2 KiB
TypeScript
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,
|
|
};
|
|
}
|