feat(collection-ai): pair document lines with PO lines
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8fc4930de7
commit
2af5d089f4
2 changed files with 313 additions and 0 deletions
142
src/lib/server/collection-ai-pair.test.ts
Normal file
142
src/lib/server/collection-ai-pair.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
171
src/lib/server/collection-ai-pair.ts
Normal file
171
src/lib/server/collection-ai-pair.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue