feat(dn): inbox 3 assistant edits lines by number, never posts

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 19:20:10 +02:00
parent 9db3b2fb6c
commit e7e8a2ff3d
7 changed files with 734 additions and 13 deletions

View file

@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const m = vi.hoisted(() => ({ admin: vi.fn(), user: vi.fn(), site: vi.fn(), assistant: vi.fn(), history: vi.fn() }));
vi.mock("@/lib/pb", () => ({ pbAdmin: m.admin }));
vi.mock("@/lib/auth/guard", () => ({ requireApiUser: m.user, requireSiteAccess: m.site }));
vi.mock("@/lib/inbox-three/assistant", () => ({ runLineAssistant: m.assistant, assistantHistory: m.history }));
import { GET, POST } from "./route";
const BATCH = "batchabcdefghij";
const NOTE = "noteabcdefghijk";
const params = { params: Promise.resolve({ batchId: BATCH, noteId: NOTE }) };
const body = { requestId: "62a067c3-5c19-420f-a46d-8bb5c656e6e7", message: "the kerb rolls are the kerb" };
const req = (origin = "http://localhost:6004", input: unknown = body) =>
new Request("http://localhost:6004/api/test", {
method: "POST",
headers: { origin, host: "localhost:6004", "content-type": "application/json" },
body: JSON.stringify(input),
});
beforeEach(() => {
vi.clearAllMocks();
m.user.mockResolvedValue({ id: "user", role: "office_user", modules: ["financial"] });
m.site.mockReturnValue(null);
m.admin.mockResolvedValue({
collection: (name: string) => ({
getOne: async () => (name === "dn_batches" ? { id: BATCH, site: "siteabcdefghijk" } : { id: NOTE, batch: BATCH }),
}),
});
m.assistant.mockResolvedValue({ reply: "The kerb rolls are on the kerb order line.", question: null, rows: [], readyNow: [0] });
m.history.mockResolvedValue([]);
});
describe("Inbox 3 assistant route", () => {
it("keeps the site manager out of the office's screen", async () => {
m.user.mockResolvedValue({ id: "site", role: "site_user" });
expect((await POST(req(), params)).status).toBe(403);
expect(m.assistant).not.toHaveBeenCalled();
});
it("refuses cross-origin writes", async () => {
expect((await POST(req("https://another.example"), params)).status).toBe(403);
expect(m.assistant).not.toHaveBeenCalled();
});
it("refuses a message it cannot read", async () => {
expect((await POST(req(undefined, { ...body, message: "" }), params)).status).toBe(400);
expect((await POST(req(undefined, { ...body, requestId: "not-a-uuid" }), params)).status).toBe(400);
expect(m.assistant).not.toHaveBeenCalled();
});
it("passes only the authenticated actor and the message to the assistant", async () => {
const res = await POST(req(), params);
expect(res.status).toBe(200);
expect(m.assistant).toHaveBeenCalledWith(
expect.anything(),
BATCH,
NOTE,
"user",
body.message,
undefined,
{ requestId: body.requestId },
);
expect(await res.json()).toMatchObject({ reply: "The kerb rolls are on the kerb order line.", readyNow: [0] });
});
it("hands the conversation back", async () => {
m.history.mockResolvedValue([{ role: "assistant", text: "The kerb rolls are the kerb order line." }]);
const res = await GET(new Request("http://localhost:6004/api/test"), params);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ messages: [{ text: "The kerb rolls are the kerb order line." }] });
});
it("never reaches for the posting function", async () => {
const source = await import("node:fs/promises").then((fs) => fs.readFile(new URL("./route.ts", import.meta.url), "utf8"));
expect(source).not.toContain("postLineWithinLease");
});
});

View file

@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
import { canSeeInboxThree } from "@/lib/auth/access";
import { isSameOrigin } from "@/lib/agent/same-origin";
import { LineAssistantRequestSchema } from "@/lib/inbox-three/assistant-schema";
import { runLineAssistant, assistantHistory } from "@/lib/inbox-three/assistant";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export const maxDuration = 240;
type Context = { params: Promise<{ batchId: string; noteId: string }> };
/**
* The office's own screen: the financial module, and only while Inbox 3 is
* being tested.
*/
async function authorised(params: Context["params"]) {
const user = await requireApiUser("financial");
if (user instanceof Response) return user;
if (!canSeeInboxThree(user)) return NextResponse.json({ error: "not_allowed" }, { status: 403 });
const { batchId, noteId } = await params;
if (![batchId, noteId].every((value) => /^[a-z0-9]{15}$/i.test(value)))
return NextResponse.json({ error: "invalid_id" }, { status: 400 });
const pb = await pbAdmin();
const [batch, note] = await Promise.all([
pb.collection("dn_batches").getOne(batchId).catch(() => null),
pb.collection("dn_batch_notes").getOne(noteId).catch(() => null),
]);
if (!batch || !note || note.batch !== batchId) return NextResponse.json({ error: "not_found" }, { status: 404 });
const denied = requireSiteAccess(user, String(batch.site ?? ""));
return denied || { pb, user, batchId, noteId };
}
/** The conversation on this note. */
export async function GET(_request: Request, context: Context) {
const access = await authorised(context.params);
if (access instanceof Response) return access;
return NextResponse.json(
{ messages: await assistantHistory(access.pb, access.batchId, access.noteId) },
{ headers: { "Cache-Control": "no-store" } },
);
}
/**
* Ask the assistant about this note.
*
* The assistant proposes line edits and the server saves them through the
* same checks a hand-made edit takes. Nothing here posts a receipt: the
* office still presses Confirm.
*/
export async function POST(request: Request, context: Context) {
if (!isSameOrigin(request)) return NextResponse.json({ error: "Open this delivery in the app to send a message." }, { status: 403 });
const access = await authorised(context.params);
if (access instanceof Response) return access;
const body = await request.json().catch(() => null);
const parsed = LineAssistantRequestSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Enter a short question and try again." }, { status: 400 });
try {
return NextResponse.json(await runLineAssistant(
access.pb,
access.batchId,
access.noteId,
access.user.id,
parsed.data.message,
undefined,
{ requestId: parsed.data.requestId },
));
} catch (error) {
const detail = error instanceof Error ? error.message : "The assistant could not finish. Please try again.";
// Model and provider failures can carry response bodies or infrastructure
// details; the office only needs to know the delivery is untouched.
const safe = /API key|api_key|authorization|Bearer|<html|<\!doctype|https?:\/\//i.test(detail)
? "The assistant is unavailable just now. Your delivery has not been posted. Please try again."
: detail.slice(0, 700);
return NextResponse.json({ error: safe }, { status: 409 });
}
}

View file

@ -0,0 +1,62 @@
/**
* The MiniMax provider, in one place.
*
* Every assistant in the delivery pipeline talks to the same
* OpenAI-compatible endpoint with the same key, model and timeout. Only the
* prompt and the shape of the answer differ, so the client setup lives here
* and is shared — a second copy of it would drift from the first.
*/
import OpenAI from "openai";
import type { ChatImage } from "@/lib/chat/types";
/** The key, the endpoint and the model, read from the environment. */
export function minimaxProvider(): { client: OpenAI; model: string } {
if (!process.env.MINIMAX_API_KEY) throw Error("The delivery assistant is not configured. You can still use the delivery controls.");
const model = process.env.DN_INBOX_REVIEW_MODEL || process.env.DN_RECONCILIATION_MODEL || process.env.MINIMAX_MODEL || "MiniMax-M3";
const client = new OpenAI({
apiKey: process.env.MINIMAX_API_KEY,
baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1",
timeout: 180000,
maxRetries: 0,
});
return { client, model };
}
/**
* Ask the model and hand back its answer, without the thinking and without
* the code fences.
*
* The images go in first, then the text: the slip is the evidence and the
* words are the question about it.
*/
export async function askMinimax(
system: string,
input: { text: string; images?: ChatImage[] },
options: { maxTokens?: number; lengthError?: string } = {},
): Promise<{ raw: string; model: string }> {
const { client, model } = minimaxProvider();
const images = input.images ?? [];
const response = await client.chat.completions.create({
model,
max_completion_tokens: options.maxTokens ?? 16000,
messages: [
{ role: "system", content: system },
{
role: "user",
content: [
...images.map((image) => ({ type: "image_url" as const, image_url: { url: `data:${image.mediaType};base64,${image.data}` } })),
{ type: "text", text: input.text },
],
},
],
});
const choice = response.choices[0];
if (!choice?.message.content || choice.finish_reason === "length") {
throw Error(options.lengthError ?? "The assistant could not finish this review. Please try again.");
}
const raw = choice.message.content
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/```(?:json)?\s*/gi, "")
.trim();
return { raw, model };
}

View file

@ -0,0 +1,33 @@
import { z } from "zod";
/**
* What the Inbox 3 assistant may propose.
*
* Line edits only. There is no posting here, and no field that could ask
* for one: the office confirms a delivery, never the assistant.
*
* Line numbers are the ones the office sees, so they start at one.
*/
export const LineAssistantProposalSchema = z.object({
reply: z.string().trim().min(1).max(3000),
question: z.string().trim().min(1).max(500).nullable(),
edits: z.array(z.object({
lines: z.array(z.number().int().min(1).max(200)).min(1),
poItemId: z.string().nullable(),
slipQty: z.number().finite().min(0),
slipUnit: z.string().trim().min(1).max(40),
factor: z.number().finite().positive().max(10000).nullable(),
price: z.number().finite().min(0).nullable(),
reason: z.string().trim().min(1).max(500),
}).strict()).max(50).default([]),
}).strict();
export type LineAssistantProposal = z.infer<typeof LineAssistantProposalSchema>;
/** What the office sends with a message. */
export const LineAssistantRequestSchema = z.object({
requestId: z.string().uuid(),
message: z.string().trim().min(1).max(2000),
}).strict();
export type LineAssistantRequest = z.infer<typeof LineAssistantRequestSchema>;

View file

@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readFile } from "node:fs/promises";
import { buildPreview, type PreviewContext, type ProcessingPreview } from "@/lib/dn-pipeline/ai-preview";
import { runLineAssistant, lineAssistantPrompt } from "./assistant";
import { saveLineEdits } from "./save-lines";
import { LineAssistantProposalSchema } from "./assistant-schema";
const state = vi.hoisted(() => ({ mutations: [] as Record<string, unknown>[] }));
vi.mock("@/lib/dn-pipeline/lock", () => ({
withBatchLease: async (_pb: unknown, _batchId: string, _worker: string, fn: (lease: unknown) => Promise<unknown>) => ({
acquired: true,
value: await fn({
batch: async (mutations: Record<string, unknown>[]) => { state.mutations.push(...mutations); return mutations.map(() => null); },
create: async () => null,
update: async () => null,
delete: async () => undefined,
}),
}),
}));
vi.mock("@/lib/dn-pipeline/ai-preview-context", () => ({
loadPreviewContext: async () => fixture,
terminalPreviewNote: () => false,
}));
vi.mock("@/lib/inbox-two/review-model", () => ({
loadReviewImages: async () => [{ mediaType: "image/jpeg", data: "" }],
}));
vi.mock("@/lib/inbox-three/save-lines", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/inbox-three/save-lines")>();
return { ...actual, saveLineEdits: vi.fn(actual.saveLineEdits) };
});
const BATCH = "batchabcdefghij";
const NOTE = "noteabcdefghijk";
const MESH_ITEM = "meshitem1234567";
const KERB_ITEM = "kerbitem12345678";
const FOREIGN_ITEM = "foreignitem12345";
/** The slip: a roll of mesh and three rolls of kerb. */
const context = (): PreviewContext => ({
note: { id: NOTE, batch: BATCH, pages: [1], doc_number: "0007075669", items: [] },
pages: [],
supplier: { id: "supplierabcdefg", name: "Vula" },
po: { id: "poabcdefghijklm", purchase_order_number: "MEGCPO9448", total_value: 11748.7 },
poItems: [
{ id: MESH_ITEM, description: "MESH DIAMOND 600 x 2.5 ROLL", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 },
{ id: KERB_ITEM, description: "KERB 150", unit: "m", qty: 100, rate: 150, disc_percent: 0, vat_percent: 15 },
],
lines: [
{ sourceLineId: "mesh", description: "MESH DIAMOND 600 x 2.5 ROLL", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null },
{ sourceLineId: "kerb-a", description: "KERB 150", qty: 2, unit: "roll", unit_price: 2250, line_amount: 4500, product_code: null },
{ sourceLineId: "kerb-b", description: "KERB 150", qty: 2, unit: "roll", unit_price: 2250, line_amount: 4500, product_code: null },
{ sourceLineId: "kerb-c", description: "KERB 150", qty: 2, unit: "roll", unit_price: 2250, line_amount: 4500, product_code: null },
],
priorDeliveries: [],
priorItems: [],
});
const preview = (c: PreviewContext): ProcessingPreview => buildPreview(c, { allocations: [
{ sourceLineIds: ["mesh"], purchaseOrderItemId: MESH_ITEM, quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Mesh diamond roll", reviewQuestion: null },
{ sourceLineIds: ["kerb-a"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
{ sourceLineIds: ["kerb-b"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
{ sourceLineIds: ["kerb-c"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
] });
let fixture: { context: PreviewContext; stored: ProcessingPreview } | null = null;
const note = {
id: NOTE, batch: BATCH, status: "blocked", stage: "needs_review",
doc_number: "0007075669", date: "2026-10-20", doc_type: "delivery_note",
items: [], checks: [], manual_overrides: {},
po_number_raw: "MEGCPO9448", supplier_name_raw: "Vula",
};
const pb = {
filter: (template: string) => template,
collection: (name: string) => {
if (name === "dn_batch_notes") return { getOne: async () => note };
if (name === "dn_review_events") return {
getList: async () => ({ items: [] }),
getOne: async (id: string) => {
const found = state.mutations
.filter((mutation) => mutation.collection === "dn_review_events" && mutation.method === "create")
.map((mutation) => mutation.data as { id?: string })
.find((row) => row.id === id);
if (!found) { const error = Object.assign(new Error("missing"), { status: 404 }); throw error; }
return found;
},
};
return {};
},
};
/** The model proposes; the office is never asked to approve a posting. */
const proposal = (edits: unknown[]) => ({
reply: "The kerb rolls are on the kerb order line.",
question: null,
edits,
});
describe("runLineAssistant", () => {
beforeEach(() => {
state.mutations.length = 0;
vi.mocked(saveLineEdits).mockClear();
const c = context();
fixture = { context: c, stored: preview(c) };
});
it("turns the assistant's line numbers into the lines it edits", async () => {
const model = vi.fn(async () => ({ proposal: proposal([
{ lines: [3, 4], poItemId: KERB_ITEM, slipQty: 4, slipUnit: "roll", factor: 15, price: null, reason: "One roll of kerb is 15 m." },
]), model: "test-model" }));
const result = await runLineAssistant(pb as never, BATCH, NOTE, "operator", "the kerb rolls are the kerb", { model, images: async () => [{ mediaType: "image/jpeg", data: "" }] });
expect(saveLineEdits).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
[expect.objectContaining({ lines: [2, 3], poItemId: KERB_ITEM, slipQty: 4, slipUnit: "roll", factor: 15, usePoRate: true })],
"operator",
);
// The rows the office sees now carry the combined line.
expect(result.rows.some((row) => row.numbers.join("+") === "3+4")).toBe(true);
});
it("refuses an order line that is not on the purchase order and changes nothing", async () => {
const model = vi.fn(async () => ({ proposal: proposal([
{ lines: [3], poItemId: FOREIGN_ITEM, slipQty: 4, slipUnit: "roll", factor: 15, price: null, reason: "a guess" },
]), model: "test-model" }));
const result = await runLineAssistant(pb as never, BATCH, NOTE, "operator", "put it on the kerb", { model, images: async () => [{ mediaType: "image/jpeg", data: "" }] });
expect(result.reply).toContain("not on this purchase order");
expect(state.mutations.filter((mutation) => mutation.collection === "dn_batch_notes")).toHaveLength(0);
});
it("writes one review event for the exchange", async () => {
const model = vi.fn(async () => ({ proposal: proposal([]), model: "test-model" }));
await runLineAssistant(pb as never, BATCH, NOTE, "operator", "what is still open?", { model, images: async () => [{ mediaType: "image/jpeg", data: "" }] });
const events = state.mutations.filter((mutation) => mutation.collection === "dn_review_events");
expect(events).toHaveLength(1);
const data = events[0].data as { actor?: string; action?: string; after?: { edits?: unknown } };
expect(data.actor).toBe("operator");
expect(data.action).toBe("inbox_three_assistant");
expect(data.after?.edits).toEqual([]);
});
it("shows the assistant the lines it may change and the order lines it may use", async () => {
let seen: { rows?: unknown; poItems?: unknown } = {};
const model = vi.fn(async (input: { rows?: unknown; poItems?: unknown }) => {
seen = input;
return { proposal: proposal([]), model: "test-model" };
});
await runLineAssistant(pb as never, BATCH, NOTE, "operator", "help me", { model, images: async () => [{ mediaType: "image/jpeg", data: "" }] });
expect(seen.poItems).toEqual(expect.arrayContaining([expect.objectContaining({ id: KERB_ITEM })]));
const rows = seen.rows as { numbers: number[] }[];
expect(rows[0].numbers).toEqual([1]);
});
it("never reaches for the posting function", async () => {
const source = await readFile(new URL("./assistant.ts", import.meta.url), "utf8");
expect(source).not.toContain("postLineWithinLease");
});
it("tells the assistant what it may use and that it never posts", () => {
const prompt = lineAssistantPrompt([
{ id: KERB_ITEM, description: "KERB 150", unit: "m", qty: 100, rate: 150 },
]);
expect(prompt).toContain(KERB_ITEM);
expect(prompt).toMatch(/numbered from 1/i);
expect(prompt).toMatch(/never post/i);
});
it("answers the same request the same way instead of applying it twice", async () => {
const model = vi.fn(async () => ({ proposal: proposal([]), model: "test-model" }));
const options = { requestId: "62a067c3-5c19-420f-a46d-8bb5c656e6e7" };
const first = await runLineAssistant(pb as never, BATCH, NOTE, "operator", "what is open?", { model, images: async () => [{ mediaType: "image/jpeg", data: "" }] }, options);
const second = await runLineAssistant(pb as never, BATCH, NOTE, "operator", "what is open?", { model, images: async () => [{ mediaType: "image/jpeg", data: "" }] }, options);
expect(second.reply).toBe(first.reply);
expect(model).toHaveBeenCalledTimes(1);
expect(state.mutations.filter((mutation) => mutation.collection === "dn_review_events")).toHaveLength(1);
});
});
describe("LineAssistantProposalSchema", () => {
it("numbers lines from one", () => {
expect(LineAssistantProposalSchema.safeParse({ reply: "ok", question: null, edits: [
{ lines: [0], poItemId: null, slipQty: 1, slipUnit: "ea", factor: 1, price: null, reason: "because" },
] }).success).toBe(false);
});
it("keeps a proposal with no edits at all", () => {
expect(LineAssistantProposalSchema.safeParse({ reply: "ok", question: null }).success).toBe(true);
});
});

View file

@ -0,0 +1,276 @@
/**
* The Inbox 3 line assistant.
*
* The office asks in plain words — "the kerb rolls are the kerb" — and the
* assistant proposes the line edits that follow from the answer: which
* order line a slip line belongs to, how many of the order's unit one of the
* slip's is, and what it cost.
*
* The assistant never posts. It has no posting function to call, its
* proposals go through the same checks as a hand-made edit, and the office
* still presses Confirm.
*/
import { createHash } from "node:crypto";
import type PocketBase from "pocketbase";
import type { ChatImage } from "@/lib/chat/types";
import { withBatchLease } from "@/lib/dn-pipeline/lock";
import { loadPreviewContext, terminalPreviewNote } from "@/lib/dn-pipeline/ai-preview-context";
import { askMinimax } from "@/lib/dn-pipeline/minimax-model";
import { finiteNumber, type PreviewContext } from "@/lib/dn-pipeline/ai-preview";
import { readyIndexes } from "@/lib/inbox-three/readiness";
import { loadReviewImages } from "@/lib/inbox-two/review-model";
import { buildLineRows, type LineEdit, type LineRow } from "./line-rows";
import { saveLineEdits } from "./save-lines";
import { LineAssistantProposalSchema } from "./assistant-schema";
const ACTION = "inbox_three_assistant";
const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
const object = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
export interface LineAssistantPoLine {
id: string;
description: string | null;
unit: string | null;
qty: number | null;
rate: number | null;
}
export interface LineAssistantTurn { role: "user" | "assistant"; text: string }
export interface LineAssistantInput {
message: string;
/** The lines as the office sees them, numbered from one. */
rows: LineRow[];
poItems: LineAssistantPoLine[];
note: Record<string, unknown>;
history: LineAssistantTurn[];
images: ChatImage[];
}
export interface LineAssistantDependencies {
model: (input: LineAssistantInput) => Promise<{ proposal: unknown; model: string }>;
images: (pb: PocketBase, batchId: string, note: Record<string, unknown>) => Promise<ChatImage[]>;
}
export interface LineAssistantResult {
reply: string;
question: string | null;
rows: LineRow[];
readyNow: number[];
}
/**
* The rules the assistant works by.
*
* The order lines it may use are named here, so it cannot invent an id; the
* line numbers are the ones on the office's screen; and it is told plainly
* that it does not post.
*/
export function lineAssistantPrompt(poItems: LineAssistantPoLine[]): string {
const allowed = poItems.length
? poItems.map((item) => `${item.id} — ${item.description ?? "no description"}, ${item.qty ?? "?"} ${item.unit ?? "unit"} at R${item.rate ?? "?"}`).join("\n")
: "This purchase order has no lines yet.";
return `You help the site office read ONE delivery note and change the lines it cannot place. Speak plainly and briefly.
The lines are numbered from 1, in the order the slip printed them. A row that covers several lines is written "3+4", and its numbers are the lines it covers.
You may only use these purchase order item ids:
${allowed}
Never invent an id. If none of them is right for a line, say so and ask.
You never post a delivery, never confirm a receipt, never approve an excess and never change the site or the supplier. You only propose edits; the office confirms them and the server checks them.
An edit says which lines it covers, which purchase order item it goes to, the quantity and unit the slip printed, how many order units one slip unit is (the factor), and the price when it differs from the order rate.
The factor is a real conversion: 1 when the units are the same, otherwise above zero and no more than 10000. Leave it null when you do not know it.
Give every edit a reason that says where the number came from.
Return JSON only:
{"reply":"short explanation","question":null,"edits":[{"lines":[3,4],"poItemId":"supplied id or null","slipQty":4,"slipUnit":"roll","factor":15,"price":null,"reason":"one roll of kerb is 15 m"}]}`;
}
/** The order lines the assistant may choose from. */
function poLines(context: PreviewContext): LineAssistantPoLine[] {
return context.poItems
.filter((item) => typeof item.id === "string" && item.id.trim())
.map((item) => ({
id: String(item.id),
description: typeof item.description === "string" ? item.description : null,
unit: typeof item.unit === "string" ? item.unit : null,
qty: finiteNumber(item.qty),
rate: finiteNumber(item.rate),
}));
}
/** The model runner, on the shared MiniMax provider. */
export async function runLineAssistantModel(input: LineAssistantInput) {
const { raw, model } = await askMinimax(lineAssistantPrompt(input.poItems), {
text: JSON.stringify({
note: input.note,
message: input.message,
lines: input.rows,
purchase_order_items: input.poItems,
conversation: input.history,
}),
images: input.images,
});
try { return { proposal: LineAssistantProposalSchema.parse(JSON.parse(raw)), model }; }
catch { throw Error("The assistant returned an incomplete answer. Your delivery was not changed. Please try again."); }
}
/** The last thirty exchanges on this note, oldest first. */
export async function assistantHistory(pb: PocketBase, batchId: string, noteId: string): Promise<LineAssistantTurn[]> {
const rows = await pb.collection("dn_review_events").getList(1, 30, {
filter: pb.filter("batch={:batch} && note={:note} && action={:action}", { batch: batchId, note: noteId, action: ACTION }),
sort: "-created",
});
return rows.items
.filter((row) => row.batch === batchId && row.note === noteId && row.action === ACTION)
.map((row) => ({ user: String(row.reason ?? ""), assistant: String(object(object(row.after).reply).reply ?? "") }))
.filter((exchange) => exchange.user || exchange.assistant)
.reverse()
.flatMap((exchange) => [
...(exchange.user ? [{ role: "user" as const, text: exchange.user }] : []),
...(exchange.assistant ? [{ role: "assistant" as const, text: exchange.assistant }] : []),
]);
}
/** The office reads the reason, not the internal wording. */
function plainReason(error: unknown): string {
const detail = error instanceof Error ? error.message : "The lines could not be changed.";
if (/does not belong to this purchase order/i.test(detail)) return "That order line is not on this purchase order.";
return detail;
}
/**
* Ask the assistant about one note and save the edits it proposes.
*
* The proposal goes through `saveLineEdits`, the same path a hand-made edit
* takes: the checks, the guards and the saved preview are the same either
* way. When the edit does not pass, the reason is written into the reply and
* nothing is saved.
*/
export async function runLineAssistant(
pb: PocketBase,
batchId: string,
noteId: string,
actor: string,
message: string,
deps: LineAssistantDependencies = { model: runLineAssistantModel, images: loadReviewImages },
options: { requestId?: string } = {},
): Promise<LineAssistantResult> {
const text = message.trim();
if (!text) throw Error("Write what you want changed on this delivery.");
const requestId = options.requestId ?? null;
// A request id makes the exchange repeatable: the same message asked twice
// is answered twice the same way, not applied twice.
const eventId = requestId ? hash([ACTION, actor, batchId, noteId, requestId]).slice(0, 15) : null;
const requestHash = hash([text, requestId]);
const result = await withBatchLease(pb, batchId, `inbox-three:${actor}`, async (lease) => {
const note = await pb.collection("dn_batch_notes").getOne(noteId);
if (note.batch !== batchId) throw Error("This note is not in the selected upload.");
if (terminalPreviewNote(note)) throw Error("This delivery is closed. Open its record to see the result.");
const { context, stored } = await loadPreviewContext(pb, batchId, noteId);
if (!stored) throw Error("There is no saved match to edit. Run matching first.");
if (eventId) {
const prior = await pb.collection("dn_review_events").getOne(eventId).catch((error: { status?: number }) => {
if (error.status === 404) return null;
throw error;
});
if (prior) {
const after = object(prior.after);
if (after.requestHash !== requestHash) throw Error("This request has already been used for another message. Send a new message.");
const saved = object(after.reply);
return {
reply: String(saved.reply ?? ""),
question: (saved.question as string | null) ?? null,
rows: buildLineRows(context, stored),
readyNow: readyIndexes(context, stored),
};
}
}
const rows = buildLineRows(context, stored);
const images = await deps.images(pb, batchId, note);
if (!images.length) throw Error("The assistant could not read the photo. Please try again.");
const history = await assistantHistory(pb, batchId, noteId);
const { proposal: rawProposal } = await deps.model({
message: text,
rows,
poItems: poLines(context),
note: {
doc_number: note.doc_number,
date: note.date,
doc_type: note.doc_type,
items: note.items,
checks: note.checks,
manual_overrides: note.manual_overrides,
po_number_raw: note.po_number_raw,
supplier_name_raw: note.supplier_name_raw,
},
history,
images,
});
const proposal = LineAssistantProposalSchema.parse(rawProposal);
// The assistant numbers lines the way the office sees them. The server
// counts them from zero.
const edits: LineEdit[] = proposal.edits.map((edit) => ({
lines: edit.lines.map((line) => line - 1),
poItemId: edit.poItemId,
slipQty: edit.slipQty,
slipUnit: edit.slipUnit,
factor: edit.factor,
price: edit.price,
// No edited price means the order rate is used.
usePoRate: edit.price === null,
reason: edit.reason,
}));
let reply = proposal.reply;
const question = proposal.question;
let preview = stored;
let readyNow = readyIndexes(context, stored);
let remembered: Record<string, unknown> | null = null;
let saved = false;
if (edits.length) {
try {
const outcome = saveLineEdits(context, stored, edits, actor);
preview = outcome.preview;
remembered = outcome.remembered;
readyNow = outcome.readyNow;
saved = true;
} catch (error) {
// The edit did not pass. Say why, and change nothing.
reply = `${proposal.reply}\n\n${plainReason(error)} Nothing was changed.`;
}
}
await lease.batch([
...(remembered
? [{ collection: "suppliers", method: "update" as const, id: String(context.supplier.id), data: { reconciliation_rules: remembered } }]
: []),
...(saved
? [{ collection: "dn_batch_notes", method: "update" as const, id: noteId, data: {
reconciliation_preview: preview,
status: readyNow.length ? "candidate" : "blocked",
stage: readyNow.length ? "ready_to_post" : "needs_review",
} }]
: []),
{ collection: "dn_review_events", method: "create", data: {
...(eventId ? { id: eventId } : {}),
event_key: `${ACTION}:${actor}:${requestId ?? hash(text)}`,
batch: batchId,
note: noteId,
action: ACTION,
actor,
reason: text,
before: { stage: note.stage, status: note.status },
after: { requestHash, reply: { reply, question }, edits },
} },
]);
return { reply, question, rows: buildLineRows(context, preview), readyNow };
});
if (!result.acquired) throw Error("This delivery is being checked by someone else. Please try again shortly.");
return result.value;
}

View file

@ -1,8 +1,8 @@
import OpenAI from 'openai';
import sharp from 'sharp'; import sharp from 'sharp';
import type PocketBase from 'pocketbase'; import type PocketBase from 'pocketbase';
import type { ChatImage } from '@/lib/chat/types'; import type { ChatImage } from '@/lib/chat/types';
import { loadDocument } from '@/lib/purchasing/documents'; import { loadDocument } from '@/lib/purchasing/documents';
import { askMinimax } from '@/lib/dn-pipeline/minimax-model';
import { ReviewProposalSchema } from './review-schema'; import { ReviewProposalSchema } from './review-schema';
import type { ReviewModelInput } from './review'; import type { ReviewModelInput } from './review';
@ -16,19 +16,8 @@ Return JSON only:
{"reply":"short explanation","question":null,"suggestions":[],"edits":[],"allocations":[{"lines":[0],"purchaseOrderItemId":"supplied id or null","quantityBasis":"direct|converted|kit|none","factor":1,"amountBasis":"po_rate|source|included|unknown|not_delivered","sourceAmountBasis":"net|gross|unknown","ruleKeys":[],"explanation":"evidence and formula","reviewQuestion":null}]}`; {"reply":"short explanation","question":null,"suggestions":[],"edits":[],"allocations":[{"lines":[0],"purchaseOrderItemId":"supplied id or null","quantityBasis":"direct|converted|kit|none","factor":1,"amountBasis":"po_rate|source|included|unknown|not_delivered","sourceAmountBasis":"net|gross|unknown","ruleKeys":[],"explanation":"evidence and formula","reviewQuestion":null}]}`;
export async function runInboxReviewModel(input: ReviewModelInput) { export async function runInboxReviewModel(input: ReviewModelInput) {
if (!process.env.MINIMAX_API_KEY) throw Error('The delivery assistant is not configured. You can still use the delivery controls.');
const model = process.env.DN_INBOX_REVIEW_MODEL || process.env.DN_RECONCILIATION_MODEL || process.env.MINIMAX_MODEL || 'MiniMax-M3';
const client = new OpenAI({ apiKey: process.env.MINIMAX_API_KEY, baseURL: process.env.MINIMAX_BASE_URL || 'https://api.minimax.io/v1', timeout: 180000, maxRetries: 0 });
const { images, ...evidence } = input; const { images, ...evidence } = input;
const response = await client.chat.completions.create({ model, max_completion_tokens: 16000, const { raw, model } = await askMinimax(PROMPT, { text: JSON.stringify(evidence), images });
messages: [{ role: 'system', content: PROMPT }, { role: 'user', content: [
...images.map(img => ({ type: 'image_url' as const, image_url: { url: `data:${img.mediaType};base64,${img.data}` } })),
{ type: 'text', text: JSON.stringify(evidence) },
] }],
});
const choice = response.choices[0];
if (!choice?.message.content || choice.finish_reason === 'length') throw Error('The assistant could not finish this review. Please try again.');
const raw = choice.message.content.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/```(?:json)?\s*/gi, '').trim();
try { return { proposal: ReviewProposalSchema.parse(JSON.parse(raw)), model }; } try { return { proposal: ReviewProposalSchema.parse(JSON.parse(raw)), model }; }
catch { throw Error('The assistant returned an incomplete review. Your delivery was not changed. Please try again.'); } catch { throw Error('The assistant returned an incomplete review. Your delivery was not changed. Please try again.'); }
} }