feat(orders): order assistant eligibility and auto-link rule
Task 4 of the AI order assistant plan. Pure core, no I/O: - PLACEHOLDER_LINE_PATTERNS, isRealOrderLine - orderLinesHash (sha1 over sorted id|description|quantity) - isOrderEligible (no quote, >=1 real pending line, not already matched on the same lines unless forced, not in the AI-error cooldown) - decideOutcome — the auto-link rule: high confidence, every real line paired "sure" on the chosen quote, same company, auto-link on - confidenceToScore (0.9 / 0.7 / 0.5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4dbe28147d
commit
d5c677ad4e
2 changed files with 531 additions and 0 deletions
338
src/lib/server/order-assistant-core.test.ts
Normal file
338
src/lib/server/order-assistant-core.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
confidenceToScore,
|
||||
decideOutcome,
|
||||
isOrderEligible,
|
||||
isRealOrderLine,
|
||||
orderLinesHash,
|
||||
PLACEHOLDER_LINE_PATTERNS,
|
||||
type AiOrderMatchDecision,
|
||||
type OrderItemLite,
|
||||
} from "@/lib/server/order-assistant-core";
|
||||
import type { OrderAiQuoteMatch } from "@/lib/types";
|
||||
|
||||
const NOW = new Date("2026-10-24T12:00:00.000Z");
|
||||
|
||||
function line(over: Partial<OrderItemLite> = {}): OrderItemLite {
|
||||
return {
|
||||
id: "ol1",
|
||||
description: "M16 bolt 60mm",
|
||||
quantity: 10,
|
||||
item_status: "pending",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PLACEHOLDER_LINE_PATTERNS", () => {
|
||||
it("covers the two known placeholders", () => {
|
||||
expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("See attached"))).toBe(true);
|
||||
expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("Buyer to add items"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRealOrderLine", () => {
|
||||
it("is false for an empty description", () => {
|
||||
expect(isRealOrderLine({ description: "" })).toBe(false);
|
||||
expect(isRealOrderLine({ description: " " })).toBe(false);
|
||||
expect(isRealOrderLine({})).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a placeholder line", () => {
|
||||
expect(isRealOrderLine({ description: "See attached" })).toBe(false);
|
||||
expect(isRealOrderLine({ description: "buyer to add items" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a cancelled line", () => {
|
||||
expect(isRealOrderLine({ description: "M16 bolt", item_status: "cancelled" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a normal pending line", () => {
|
||||
expect(isRealOrderLine({ description: "M16 bolt 60mm", item_status: "pending" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderLinesHash", () => {
|
||||
it("is stable over line order", () => {
|
||||
const a = orderLinesHash([
|
||||
{ id: "ol1", description: "Bolt", quantity: 10 },
|
||||
{ id: "ol2", description: "Nut", quantity: 5 },
|
||||
]);
|
||||
const b = orderLinesHash([
|
||||
{ id: "ol2", description: "Nut", quantity: 5 },
|
||||
{ id: "ol1", description: "Bolt", quantity: 10 },
|
||||
]);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("changes when a quantity changes", () => {
|
||||
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
|
||||
const b = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 11 }]);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("changes when a line is added", () => {
|
||||
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
|
||||
const b = orderLinesHash([
|
||||
{ id: "ol1", description: "Bolt", quantity: 10 },
|
||||
{ id: "ol2", description: "Nut", quantity: 5 },
|
||||
]);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("changes when a description changes", () => {
|
||||
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
|
||||
const b = orderLinesHash([{ id: "ol1", description: "Nut", quantity: 10 }]);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOrderEligible", () => {
|
||||
it("is false when the order already has a quote", () => {
|
||||
expect(
|
||||
isOrderEligible({ quote: "q1" }, [line()], { now: NOW }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when there is no real line", () => {
|
||||
expect(
|
||||
isOrderEligible({}, [line({ description: "See attached" })], { now: NOW }),
|
||||
).toBe(false);
|
||||
expect(isOrderEligible({}, [], { now: NOW })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when no real line is pending", () => {
|
||||
expect(
|
||||
isOrderEligible({}, [line({ item_status: "po_generated" })], { now: NOW }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when a real line is pending", () => {
|
||||
expect(
|
||||
isOrderEligible({}, [line({ item_status: "pending" })], { now: NOW }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-pending lines when deciding", () => {
|
||||
expect(
|
||||
isOrderEligible(
|
||||
{},
|
||||
[line({ id: "ol1", item_status: "po_generated" }), line({ id: "ol2" })],
|
||||
{ now: NOW },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when the same lines were already matched", () => {
|
||||
const lines = [line()];
|
||||
const hash = orderLinesHash(lines);
|
||||
const ai_quote_match: OrderAiQuoteMatch = {
|
||||
status: "suggested",
|
||||
quote_id: "q1",
|
||||
quote_number: "Q-1",
|
||||
confidence: "medium",
|
||||
reason: "",
|
||||
doubts: [],
|
||||
lines: [],
|
||||
auto_linked: false,
|
||||
lines_hash: hash,
|
||||
model: "m",
|
||||
run_at: NOW.toISOString(),
|
||||
};
|
||||
expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW })).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for the same lines when forced", () => {
|
||||
const lines = [line()];
|
||||
const hash = orderLinesHash(lines);
|
||||
const ai_quote_match: OrderAiQuoteMatch = {
|
||||
status: "suggested",
|
||||
quote_id: "q1",
|
||||
quote_number: "Q-1",
|
||||
confidence: "medium",
|
||||
reason: "",
|
||||
doubts: [],
|
||||
lines: [],
|
||||
auto_linked: false,
|
||||
lines_hash: hash,
|
||||
model: "m",
|
||||
run_at: NOW.toISOString(),
|
||||
};
|
||||
expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW, force: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when the lines changed since the last run", () => {
|
||||
const ai_quote_match: OrderAiQuoteMatch = {
|
||||
status: "suggested",
|
||||
quote_id: "q1",
|
||||
quote_number: "Q-1",
|
||||
confidence: "medium",
|
||||
reason: "",
|
||||
doubts: [],
|
||||
lines: [],
|
||||
auto_linked: false,
|
||||
lines_hash: "stale-hash",
|
||||
model: "m",
|
||||
run_at: NOW.toISOString(),
|
||||
};
|
||||
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false within the error cooldown", () => {
|
||||
const ai_quote_match: OrderAiQuoteMatch = {
|
||||
status: "error",
|
||||
quote_id: null,
|
||||
quote_number: "",
|
||||
confidence: null,
|
||||
reason: "",
|
||||
doubts: [],
|
||||
lines: [],
|
||||
auto_linked: false,
|
||||
lines_hash: "stale-hash",
|
||||
model: "m",
|
||||
run_at: new Date(NOW.getTime() - 30 * 60 * 1000).toISOString(),
|
||||
error: "boom",
|
||||
};
|
||||
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(false);
|
||||
});
|
||||
|
||||
it("is true after the error cooldown", () => {
|
||||
const ai_quote_match: OrderAiQuoteMatch = {
|
||||
status: "error",
|
||||
quote_id: null,
|
||||
quote_number: "",
|
||||
confidence: null,
|
||||
reason: "",
|
||||
doubts: [],
|
||||
lines: [],
|
||||
auto_linked: false,
|
||||
lines_hash: "stale-hash",
|
||||
model: "m",
|
||||
run_at: new Date(NOW.getTime() - 61 * 60 * 1000).toISOString(),
|
||||
error: "boom",
|
||||
};
|
||||
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideOutcome", () => {
|
||||
const QUOTE_LINE_TO_QUOTE = new Map([
|
||||
["ql1", "q1"],
|
||||
["ql2", "q1"],
|
||||
]);
|
||||
|
||||
function decision(over: Partial<AiOrderMatchDecision> = {}): AiOrderMatchDecision {
|
||||
return {
|
||||
quote_id: "q1",
|
||||
confidence: "high",
|
||||
reason: "same items",
|
||||
doubts: [],
|
||||
lines: [
|
||||
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "same" },
|
||||
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "same" },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
realLineIds: ["ol1", "ol2"],
|
||||
quoteLineToQuote: QUOTE_LINE_TO_QUOTE,
|
||||
orderCompany: "c1",
|
||||
quoteCompany: "c1",
|
||||
autoLinkEnabled: true,
|
||||
};
|
||||
|
||||
it("auto-links when everything is sure on one quote", () => {
|
||||
expect(decideOutcome({ decision: decision(), ...BASE })).toBe("auto_link");
|
||||
});
|
||||
|
||||
it("suggests when one line is only likely", () => {
|
||||
const d = decision({
|
||||
lines: [
|
||||
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
||||
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "likely", reason: "" },
|
||||
],
|
||||
});
|
||||
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
|
||||
});
|
||||
|
||||
it("suggests when one line is unpaired", () => {
|
||||
const d = decision({
|
||||
lines: [
|
||||
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
||||
{ order_line_id: "ol2", quote_line_id: null, same_item: null, reason: "" },
|
||||
],
|
||||
});
|
||||
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
|
||||
});
|
||||
|
||||
it("suggests when a pair points at another quote's line", () => {
|
||||
const d = decision({
|
||||
lines: [
|
||||
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
||||
{ order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" },
|
||||
],
|
||||
});
|
||||
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
|
||||
});
|
||||
|
||||
it("suggests when the company differs", () => {
|
||||
expect(
|
||||
decideOutcome({ decision: decision(), ...BASE, quoteCompany: "c2" }),
|
||||
).toBe("suggest");
|
||||
});
|
||||
|
||||
it("suggests when the quote has no company", () => {
|
||||
expect(
|
||||
decideOutcome({ decision: decision(), ...BASE, quoteCompany: null }),
|
||||
).toBe("suggest");
|
||||
});
|
||||
|
||||
it("suggests when auto-link is switched off", () => {
|
||||
expect(
|
||||
decideOutcome({ decision: decision(), ...BASE, autoLinkEnabled: false }),
|
||||
).toBe("suggest");
|
||||
});
|
||||
|
||||
it("suggests when the confidence is medium", () => {
|
||||
expect(
|
||||
decideOutcome({ decision: decision({ confidence: "medium" }), ...BASE }),
|
||||
).toBe("suggest");
|
||||
});
|
||||
|
||||
it("is no_match when the AI chose no quote", () => {
|
||||
expect(
|
||||
decideOutcome({ decision: decision({ quote_id: null }), ...BASE }),
|
||||
).toBe("no_match");
|
||||
});
|
||||
|
||||
it("is no_match when no pair lands on the chosen quote", () => {
|
||||
const d = decision({
|
||||
lines: [
|
||||
{ order_line_id: "ol1", quote_line_id: "qlOther", same_item: "sure", reason: "" },
|
||||
{ order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" },
|
||||
],
|
||||
});
|
||||
expect(decideOutcome({ decision: d, ...BASE })).toBe("no_match");
|
||||
});
|
||||
|
||||
it("ignores pairs for lines that are not real order lines", () => {
|
||||
const d = decision({
|
||||
lines: [
|
||||
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
|
||||
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "" },
|
||||
{ order_line_id: "olPlaceholder", quote_line_id: "ql1", same_item: "unsure", reason: "" },
|
||||
],
|
||||
});
|
||||
expect(decideOutcome({ decision: d, ...BASE })).toBe("auto_link");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confidenceToScore", () => {
|
||||
it("maps the three levels", () => {
|
||||
expect(confidenceToScore("high")).toBe(0.9);
|
||||
expect(confidenceToScore("medium")).toBe(0.7);
|
||||
expect(confidenceToScore("low")).toBe(0.5);
|
||||
expect(confidenceToScore(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
193
src/lib/server/order-assistant-core.ts
Normal file
193
src/lib/server/order-assistant-core.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Pure core for the AI order assistant: eligibility, the auto-link rule
|
||||
* and the order-line fingerprint. No I/O — everything here is a
|
||||
* function of its arguments, so it is cheap to test exhaustively.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import type { OrderAiQuoteMatch, OrderItem } from "@/lib/types";
|
||||
import { ERROR_RETRY_MS } from "@/lib/server/quote-assistant-core";
|
||||
|
||||
/**
|
||||
* Line descriptions that mean "a real order line will be added later".
|
||||
* An order made up of these is not a real order, so the assistant
|
||||
* leaves it alone.
|
||||
*/
|
||||
export const PLACEHOLDER_LINE_PATTERNS: RegExp[] = [
|
||||
/see attached/i,
|
||||
/buyer to add items/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* The subset of an order line the core needs. Kept deliberately small
|
||||
* so callers can pass a light projection of `OrderItem`.
|
||||
*/
|
||||
export type OrderItemLite = Pick<OrderItem, "id" | "description" | "quantity" | "item_status">;
|
||||
|
||||
/**
|
||||
* The AI's answer to "which quote does this order come from?".
|
||||
*
|
||||
* Defined here (not in the AI module) because both the core and the AI
|
||||
* layer need it and the core must stay dependency-free.
|
||||
*/
|
||||
export interface AiOrderMatchDecision {
|
||||
quote_id: string | null;
|
||||
confidence: "high" | "medium" | "low";
|
||||
reason: string;
|
||||
doubts: string[];
|
||||
lines: Array<{
|
||||
order_line_id: string;
|
||||
quote_line_id: string | null;
|
||||
same_item: "sure" | "likely" | "unsure" | null;
|
||||
reason: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a line the customer actually wants? Placeholders ("see
|
||||
* attached") and cancelled lines are not.
|
||||
*/
|
||||
export function isRealOrderLine(line: {
|
||||
description?: string;
|
||||
item_status?: string;
|
||||
}): boolean {
|
||||
const description = (line.description ?? "").trim();
|
||||
if (!description) return false;
|
||||
if (PLACEHOLDER_LINE_PATTERNS.some((p) => p.test(description))) return false;
|
||||
if (line.item_status === "cancelled") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fingerprint of the order lines the assistant looked at.
|
||||
*
|
||||
* Stable over line order (the lines are sorted by id first) so a
|
||||
* re-run on the same order produces the same hash, but any change to
|
||||
* a description or a quantity produces a different one. Used to skip
|
||||
* an order the assistant already looked at.
|
||||
*/
|
||||
export function orderLinesHash(
|
||||
lines: Array<{ id: string; description?: string; quantity?: number | null }>,
|
||||
): string {
|
||||
const normalised = lines
|
||||
.map((l) => `${l.id}|${(l.description ?? "").trim()}|${l.quantity ?? ""}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
return createHash("sha1").update(normalised).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the assistant look at this order at all?
|
||||
*
|
||||
* An order is eligible when it has no quote yet, has at least one real
|
||||
* line, has at least one real line still in `pending`, has not been
|
||||
* looked at for the same set of lines already, and is not in a recent
|
||||
* AI-error cooldown.
|
||||
*/
|
||||
export function isOrderEligible(
|
||||
order: { quote?: string; ai_quote_match?: OrderAiQuoteMatch | null },
|
||||
lines: OrderItemLite[],
|
||||
opts: { now: Date; force?: boolean },
|
||||
): boolean {
|
||||
// Already linked to a quote — the assistant has nothing to decide.
|
||||
if (order.quote) return false;
|
||||
|
||||
const realLines = lines.filter((l) => isRealOrderLine(l));
|
||||
if (realLines.length === 0) return false;
|
||||
|
||||
// Nothing to price if no real line is still pending.
|
||||
if (!realLines.some((l) => l.item_status === "pending")) return false;
|
||||
|
||||
const match = order.ai_quote_match ?? null;
|
||||
|
||||
// Already matched these exact lines? Only look again when forced.
|
||||
if (!opts.force && match && match.lines_hash === orderLinesHash(realLines)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A recent AI error is retried after ERROR_RETRY_MS, not every tick.
|
||||
if (match && match.status === "error") {
|
||||
const ranAt = Date.parse(match.run_at);
|
||||
if (!Number.isNaN(ranAt)) {
|
||||
const age = opts.now.getTime() - ranAt;
|
||||
if (age >= 0 && age < ERROR_RETRY_MS) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The auto-link rule, in one place.
|
||||
*
|
||||
* The assistant may hard-link an order to a quote only when ALL of
|
||||
* these hold:
|
||||
* - the AI is confident ("high"),
|
||||
* - every real order line is paired to a quote line, and every one
|
||||
* of those pairs is "sure",
|
||||
* - every pair points at a line of the ONE chosen quote,
|
||||
* - the quote's company is the order's company,
|
||||
* - the operator left auto-link switched on.
|
||||
*
|
||||
* Anything else is a suggestion for a human to accept.
|
||||
*/
|
||||
export function decideOutcome(args: {
|
||||
decision: AiOrderMatchDecision;
|
||||
realLineIds: string[];
|
||||
quoteLineToQuote: Map<string, string>;
|
||||
orderCompany: string;
|
||||
quoteCompany: string | null;
|
||||
autoLinkEnabled: boolean;
|
||||
}): "auto_link" | "suggest" | "no_match" {
|
||||
const { decision, realLineIds, quoteLineToQuote, orderCompany, quoteCompany, autoLinkEnabled } =
|
||||
args;
|
||||
|
||||
if (!decision.quote_id) return "no_match";
|
||||
|
||||
const pairs = decision.lines.filter((l) => realLineIds.includes(l.order_line_id));
|
||||
|
||||
// The AI chose a quote but did not pair a single real line to it —
|
||||
// there is nothing to back the decision up.
|
||||
const pairsOnChosenQuote = pairs.filter(
|
||||
(l) => l.quote_line_id && quoteLineToQuote.get(l.quote_line_id) === decision.quote_id,
|
||||
);
|
||||
if (pairsOnChosenQuote.length === 0) return "no_match";
|
||||
|
||||
if (!autoLinkEnabled) return "suggest";
|
||||
if (decision.confidence !== "high") return "suggest";
|
||||
|
||||
// Every real line must be paired to a quote line of the chosen quote.
|
||||
const pairedOnChosen = new Set(
|
||||
pairs
|
||||
.filter(
|
||||
(l) =>
|
||||
l.quote_line_id &&
|
||||
quoteLineToQuote.get(l.quote_line_id) === decision.quote_id &&
|
||||
l.same_item === "sure",
|
||||
)
|
||||
.map((l) => l.order_line_id),
|
||||
);
|
||||
for (const id of realLineIds) {
|
||||
if (!pairedOnChosen.has(id)) return "suggest";
|
||||
}
|
||||
|
||||
// Same customer on both sides.
|
||||
if (!quoteCompany || quoteCompany !== orderCompany) return "suggest";
|
||||
|
||||
return "auto_link";
|
||||
}
|
||||
|
||||
/**
|
||||
* The `potential_quote_confidence` number stored on the order.
|
||||
*/
|
||||
export function confidenceToScore(c: "high" | "medium" | "low" | null): number {
|
||||
switch (c) {
|
||||
case "high":
|
||||
return 0.9;
|
||||
case "medium":
|
||||
return 0.7;
|
||||
case "low":
|
||||
return 0.5;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue