feat(collection-ai): pair document lines with PO lines

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 01:54:42 +02:00
parent 8fc4930de7
commit 2af5d089f4
2 changed files with 313 additions and 0 deletions

View file

@ -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> = {}): 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> = {}): 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");
});
});

View file

@ -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<number>,
poLineIds: ReadonlySet<string>,
): Map<number, PairDecision> {
const out = new Map<number, PairDecision>();
if (!Array.isArray(raw)) return out;
const used = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const r = item as Record<string, unknown>;
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<Anthropic, "messages"> } = {},
): Promise<{ pairs: Map<number, PairDecision>; 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<number, PairDecision>();
for (const d of docLines) pairs.set(d.line_no, parsed.get(d.line_no) ?? unpaired(d.line_no));
return { pairs, model: aiConfig.modelId };
}