diff --git a/src/lib/server/collection-ai-pair.test.ts b/src/lib/server/collection-ai-pair.test.ts new file mode 100644 index 0000000..12ad723 --- /dev/null +++ b/src/lib/server/collection-ai-pair.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server/ai-settings-service", () => ({ + getActiveAiConfig: vi.fn(async () => ({ + modelId: "MiniMax-M3", + maxTokens: 1000, + apiKey: "k", + baseUrl: "u", + provider: "minimax", + })), + getAiSettings: vi.fn(async () => ({})), +})); + +import { pairDocumentLines, parsePairs, type PairPoLine } from "./collection-ai-pair"; +import type { CollectionDocLine } from "@/lib/types"; + +const docLine = (n: number, over: Partial = {}): CollectionDocLine => ({ + line_no: n, + code: `C${n}`, + description: `Doc item ${n}`, + quantity: 10, + unit: "ea", + unit_price: 1, + line_total: 10, + to_follow: null, + price_note: "", + ...over, +}); + +const poLine = (id: string, over: Partial = {}): PairPoLine => ({ + id, + po_number: "PO1", + description: `Our item ${id}`, + collected: true, + order_qty: 10, + quantity: 10, + qty_collected: 10, + sku: "SK-" + id, + po_description: `PO desc ${id}`, + cost_price: 1.5, + status: "ordered", + ...over, +}); + +const docNos = new Set([1, 2, 3]); +const poIds = new Set(["p1", "p2"]); + +describe("parsePairs", () => { + it("keeps valid pairs", () => { + const m = parsePairs( + [{ doc_line_no: 1, po_line_id: "p1", same_item: "sure", reason: " same bolt " }], + docNos, + poIds, + ); + expect(m.get(1)).toEqual({ doc_line_no: 1, po_line_id: "p1", same_item: "sure", reason: "same bolt" }); + }); + + it("nulls unknown po line ids", () => { + const m = parsePairs([{ doc_line_no: 1, po_line_id: "ghost", same_item: "sure", reason: "x" }], docNos, poIds); + expect(m.get(1)).toMatchObject({ po_line_id: null, same_item: null }); + }); + + it("drops unknown doc line numbers", () => { + const m = parsePairs([{ doc_line_no: 99, po_line_id: "p1", same_item: "sure", reason: "" }], docNos, poIds); + expect(m.size).toBe(0); + }); + + it("keeps the first pairing when a PO line is paired twice", () => { + const m = parsePairs( + [ + { doc_line_no: 1, po_line_id: "p1", same_item: "likely", reason: "a" }, + { doc_line_no: 2, po_line_id: "p1", same_item: "sure", reason: "b" }, + ], + docNos, + poIds, + ); + expect(m.get(1)?.po_line_id).toBe("p1"); + expect(m.get(2)).toMatchObject({ po_line_id: null, same_item: null }); + }); + + it("nulls an invalid same_item and tolerates junk input", () => { + const m = parsePairs([{ doc_line_no: 1, po_line_id: "p1", same_item: "maybe", reason: 5 }], docNos, poIds); + expect(m.get(1)).toMatchObject({ po_line_id: "p1", same_item: null, reason: "" }); + expect(parsePairs(undefined, docNos, poIds).size).toBe(0); + expect(parsePairs([null, "x", { doc_line_no: "1" }], docNos, poIds).size).toBe(0); + }); +}); + +describe("pairDocumentLines", () => { + const toolUse = (input: unknown) => ({ type: "tool_use", id: "t", name: "submit_line_pairs", input }); + function fake(content: unknown[], stop_reason = "tool_use") { + const create = vi.fn<(a: unknown) => Promise<{ content: unknown[]; stop_reason: string }>>(async () => ({ + content, + stop_reason, + })); + return { client: { messages: { create } } as never, create }; + } + + beforeEach(() => vi.clearAllMocks()); + + it("makes no AI call without PO lines and returns every line unpaired", async () => { + const { client, create } = fake([]); + const res = await pairDocumentLines([docLine(1), docLine(2)], [], { client }); + expect(create).not.toHaveBeenCalled(); + expect([...res.pairs.values()]).toEqual([ + { doc_line_no: 1, po_line_id: null, same_item: null, reason: "" }, + { doc_line_no: 2, po_line_id: null, same_item: null, reason: "" }, + ]); + }); + + it("sends PO line details, forces submit_line_pairs, and fills unanswered lines", async () => { + const { client, create } = fake([ + toolUse({ pairs: [{ doc_line_no: 1, po_line_id: "p1", same_item: "sure", reason: "ok" }] }), + ]); + const res = await pairDocumentLines([docLine(1), docLine(2)], [poLine("p1"), poLine("p2")], { client }); + const args = create.mock.calls[0][0] as { + system: string; + max_tokens: number; + tool_choice: unknown; + messages: Array<{ content: string }>; + }; + expect(args.tool_choice).toEqual({ type: "tool", name: "submit_line_pairs" }); + expect(args.max_tokens).toBe(4096); + expect(args.system).toContain( + "pair only when it is the same product; wording differs between supplier and customer — judge the item, not the words", + ); + const text = args.messages[0].content; + for (const s of ["p1", "Our item p1", "PO desc p1", "SK-p1", "1.5", "Doc item 1"]) expect(text).toContain(s); + expect(res.model).toBe("MiniMax-M3"); + expect(res.pairs.get(1)?.po_line_id).toBe("p1"); + expect(res.pairs.get(2)).toEqual({ doc_line_no: 2, po_line_id: null, same_item: null, reason: "" }); + }); + + it("throws on max_tokens and on a missing tool call", async () => { + await expect( + pairDocumentLines([docLine(1)], [poLine("p1")], { client: fake([toolUse({ pairs: [] })], "max_tokens").client }), + ).rejects.toThrow("AI response was cut off (max_tokens)"); + await expect( + pairDocumentLines([docLine(1)], [poLine("p1")], { client: fake([{ type: "text", text: "x" }]).client }), + ).rejects.toThrow("AI returned no submit_line_pairs tool call"); + }); +}); diff --git a/src/lib/server/collection-ai-pair.ts b/src/lib/server/collection-ai-pair.ts new file mode 100644 index 0000000..eee78f9 --- /dev/null +++ b/src/lib/server/collection-ai-pair.ts @@ -0,0 +1,171 @@ +/** + * collection-ai-pair — text-only AI call that pairs a supplier document's + * lines with our PO lines. The AI only *chooses* among the PO line ids we + * hand it; ticks are computed elsewhere, in code. + */ +import Anthropic from "@anthropic-ai/sdk"; +import type { CollectionDocLine, SameItem } from "@/lib/types"; +import { getActiveAiConfig } from "./ai-settings-service"; +import type { PoLineLite } from "./collection-ai-findings"; + +export type PairPoLine = PoLineLite & { + sku: string; + po_description: string; + cost_price: number | null; + status: string; +}; + +export type PairDecision = { + doc_line_no: number; + po_line_id: string | null; + same_item: SameItem | null; + reason: string; +}; + +const TOOL_NAME = "submit_line_pairs" as const; + +/** Not operator-editable: the pairing contract is fixed in code. */ +export const PAIRING_SYSTEM_PROMPT = `You pair the lines of a supplier's invoice or delivery note with the lines of our purchase orders. + +Rules: +1. For every document line, return the po_line_id of the PO line it supplies, or null when none of the supplied PO lines is the same product. +2. Be strict: pair only when it is the same product; wording differs between supplier and customer — judge the item, not the words. Compare size, grade, finish, material and pack size. +3. Use only po_line_id values from the list you are given. Never invent an id. +4. Pair each PO line to at most one document line. +5. same_item: "sure" when you are certain it is the same product, "likely" when it probably is, "different" when it is a different product (then still say which PO line you compared it with, or use null if none fits). +6. reason: one short plain sentence. Call submit_line_pairs exactly once, with one entry per document line.`; + +export const SUBMIT_LINE_PAIRS_TOOL = { + name: TOOL_NAME, + description: + "Submit the pairing of every document line to a PO line. Call exactly once with one entry per document line.", + input_schema: { + type: "object" as const, + properties: { + pairs: { + type: "array", + items: { + type: "object", + properties: { + doc_line_no: { type: "number", description: "The document line number, as given." }, + po_line_id: { + type: ["string", "null"], + description: "The id of the matching PO line from the list, or null.", + }, + same_item: { type: ["string", "null"], enum: ["sure", "likely", "different", null] }, + reason: { type: "string" }, + }, + required: ["doc_line_no", "po_line_id", "same_item", "reason"], + }, + }, + }, + required: ["pairs"], + }, +}; + +const SAME_ITEMS: readonly string[] = ["sure", "likely", "different"]; + +/** + * Defensive parse. Unknown doc line numbers are dropped; unknown PO line ids + * become unpaired; a PO line already paired keeps its first pairing. + */ +export function parsePairs( + raw: unknown, + docLineNos: ReadonlySet, + poLineIds: ReadonlySet, +): Map { + const out = new Map(); + if (!Array.isArray(raw)) return out; + const used = new Set(); + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const r = item as Record; + const no = r.doc_line_no; + if (typeof no !== "number" || !docLineNos.has(no) || out.has(no)) continue; + const reason = typeof r.reason === "string" ? r.reason.trim() : ""; + const id = typeof r.po_line_id === "string" ? r.po_line_id : null; + if (id === null || !poLineIds.has(id) || used.has(id)) { + out.set(no, { doc_line_no: no, po_line_id: null, same_item: null, reason }); + continue; + } + used.add(id); + const same = + typeof r.same_item === "string" && SAME_ITEMS.includes(r.same_item) ? (r.same_item as SameItem) : null; + out.set(no, { doc_line_no: no, po_line_id: id, same_item: same, reason }); + } + return out; +} + +function ordered(l: PairPoLine): number { + return l.order_qty ? l.order_qty : l.quantity; +} + +function buildUserText(docLines: CollectionDocLine[], poLines: PairPoLine[]): string { + const out: string[] = ["Document lines:"]; + for (const d of docLines) { + out.push( + `- doc_line_no=${d.line_no} | code ${d.code || "-"} | ${d.description} | qty ${d.quantity ?? "?"} ${d.unit}`.trimEnd(), + ); + } + out.push("", "Our PO lines:"); + for (const p of poLines) { + out.push( + `- po_line_id=${p.id} | ${p.po_number} | description: ${p.description} | PO description: ${p.po_description || "-"} | SKU ${p.sku || "-"} | ordered ${ordered(p)} | collected ${p.qty_collected} | cost ${p.cost_price ?? "-"} | status ${p.status || "-"}`, + ); + } + return out.join("\n"); +} + +function newAnthropic(aiConfig: { apiKey: string; baseUrl: string }): Anthropic { + return new Anthropic({ + apiKey: aiConfig.apiKey, + ...(aiConfig.baseUrl ? { baseURL: aiConfig.baseUrl } : {}), + }); +} + +export async function pairDocumentLines( + docLines: CollectionDocLine[], + poLines: PairPoLine[], + deps: { client?: Pick } = {}, +): Promise<{ pairs: Map; model: string }> { + const unpaired = (no: number): PairDecision => ({ + doc_line_no: no, + po_line_id: null, + same_item: null, + reason: "", + }); + + if (poLines.length === 0 || docLines.length === 0) { + return { pairs: new Map(docLines.map((d) => [d.line_no, unpaired(d.line_no)])), model: "" }; + } + + const aiConfig = await getActiveAiConfig(); + const client = deps.client ?? newAnthropic(aiConfig); + const response = await client.messages.create({ + model: aiConfig.modelId, + max_tokens: 4096, + tools: [SUBMIT_LINE_PAIRS_TOOL], + tool_choice: { type: "tool", name: TOOL_NAME }, + system: PAIRING_SYSTEM_PROMPT, + messages: [{ role: "user", content: buildUserText(docLines, poLines) }], + }); + + if (response.stop_reason === "max_tokens") { + throw new Error("AI response was cut off (max_tokens)"); + } + 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_pairs tool call"); + } + + const toolInput = toolBlock.input as { pairs?: unknown } | null; + const parsed = parsePairs( + toolInput?.pairs, + new Set(docLines.map((d) => d.line_no)), + new Set(poLines.map((p) => p.id)), + ); + // Lines the AI skipped are explicitly unpaired, so callers see every line. + const pairs = new Map(); + for (const d of docLines) pairs.set(d.line_no, parsed.get(d.line_no) ?? unpaired(d.line_no)); + return { pairs, model: aiConfig.modelId }; +}