feat(quote-assistant): fill service, tick, counts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bd78f0b3c1
commit
bb06165318
3 changed files with 976 additions and 2 deletions
|
|
@ -305,8 +305,10 @@ pattern:
|
|||
- Overlap guard: the tick writes `last_run_at` at the **start** of a
|
||||
run, so a second tick arriving mid-run sees it isn't due. The
|
||||
re-read-before-write in step 4 covers any remaining race.
|
||||
- Bound each run: at most about 20 quotes or 60 lines, oldest quotes
|
||||
first. Leftovers carry over to the next run.
|
||||
- Bound each run: at most about 20 quotes or 60 lines, newest lines
|
||||
first. Leftovers carry over to the next run. (Not oldest first:
|
||||
lines that were tried and left unmatched stay in the candidate
|
||||
window, so oldest-first would let them starve new quotes.)
|
||||
|
||||
Manual button: `POST /api/v1/quotes/[id]/assistant/run`, authenticated
|
||||
with `getBrowserSessionUser(req)`. It runs synchronously for that one
|
||||
|
|
|
|||
656
src/lib/server/quote-assistant-service.test.ts
Normal file
656
src/lib/server/quote-assistant-service.test.ts
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Row = Record<string, unknown> & { id: string };
|
||||
|
||||
const store = {
|
||||
quotes: new Map<string, Row>(),
|
||||
items: new Map<string, Row>(),
|
||||
itemFilters: [] as string[],
|
||||
updates: [] as { id: string; patch: Record<string, unknown> }[],
|
||||
failUpdateFor: new Set<string>(),
|
||||
};
|
||||
const events: string[] = [];
|
||||
const actors: { name?: string; source?: string }[] = [];
|
||||
|
||||
function itemMatchesQuotes(filter: string | undefined): Set<string> | null {
|
||||
const ids = [...(filter ?? "").matchAll(/\bquote = "([^"]+)"/g)].map((m) => m[1]);
|
||||
return ids.length ? new Set(ids) : null;
|
||||
}
|
||||
|
||||
function listItems(opts: { filter?: string; sort?: string; expand?: string }) {
|
||||
store.itemFilters.push(opts.filter ?? "");
|
||||
const quotes = itemMatchesQuotes(opts.filter);
|
||||
let rows = [...store.items.values()].filter((r) => !quotes || quotes.has(r.quote as string));
|
||||
if (opts.sort === "-created") {
|
||||
rows = rows.sort((a, b) => String(b.created).localeCompare(String(a.created)));
|
||||
}
|
||||
return rows.map((r) => {
|
||||
const copy = structuredClone(r) as Row;
|
||||
if (opts.expand === "quote") {
|
||||
copy.expand = { quote: structuredClone(store.quotes.get(r.quote as string)) };
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
const pb = {
|
||||
collection(name: string) {
|
||||
if (name === "quotes") {
|
||||
return {
|
||||
getOne: async (id: string) => {
|
||||
const q = store.quotes.get(id);
|
||||
if (!q) throw new Error("quote not found");
|
||||
return structuredClone(q);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (name === "quote_items") {
|
||||
return {
|
||||
getOne: async (id: string) => structuredClone(store.items.get(id)),
|
||||
getFullList: async (opts: { filter?: string; sort?: string }) => listItems(opts),
|
||||
getList: async (_p: number, _n: number, opts: { filter?: string; sort?: string; expand?: string }) => ({
|
||||
items: listItems(opts),
|
||||
}),
|
||||
update: async (id: string, patch: Record<string, unknown>) => {
|
||||
if (store.failUpdateFor.has(id)) throw new Error("PB boom");
|
||||
store.updates.push({ id, patch });
|
||||
Object.assign(store.items.get(id)!, patch);
|
||||
return store.items.get(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected collection ${name}`);
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: async () => pb }));
|
||||
vi.mock("@/lib/server/audit-context", () => ({
|
||||
runWithAuditActor: (actor: { name?: string; source?: string }, fn: () => unknown) => {
|
||||
actors.push(actor);
|
||||
return fn();
|
||||
},
|
||||
}));
|
||||
|
||||
const settings = {
|
||||
enabled: true,
|
||||
quote_assistant_enabled: true,
|
||||
quote_assistant_interval_minutes: 10,
|
||||
quote_assistant_min_confidence: "medium" as "high" | "medium",
|
||||
quote_assistant_last_run_at: "",
|
||||
};
|
||||
const recordRun = vi.fn(async (fields: { last_run_at?: string; summary?: unknown }) => {
|
||||
events.push(`record:${Object.keys(fields).join(",")}`);
|
||||
});
|
||||
vi.mock("@/lib/server/ai-settings-service", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/server/ai-settings-service")>()),
|
||||
getAiSettings: async () => ({ ...settings }),
|
||||
clearAiSettingsCache: vi.fn(),
|
||||
recordQuoteAssistantRun: (f: { last_run_at?: string; summary?: unknown }) => recordRun(f),
|
||||
}));
|
||||
|
||||
import { AiDisabledError } from "@/lib/server/ai-settings-service";
|
||||
import type { AiLineDecision } from "./quote-assistant-core";
|
||||
import type { DecideInput, DecideResult } from "./quote-assistant-ai";
|
||||
import type { StockCandidate } from "./quote-assistant-shortlist";
|
||||
import {
|
||||
fillQuote,
|
||||
getAssistantCounts,
|
||||
QUOTE_MAX_LINES,
|
||||
runAssistantTick,
|
||||
TICK_MAX_LINES,
|
||||
TICK_MAX_QUOTES,
|
||||
type AssistantDeps,
|
||||
} from "./quote-assistant-service";
|
||||
|
||||
const NOW = new Date("2026-10-16T10:00:00.000Z");
|
||||
|
||||
function candidate(over: Partial<StockCandidate> = {}): StockCandidate {
|
||||
return {
|
||||
id: "stk1",
|
||||
name: "Hex bolt M10x50",
|
||||
sku: "HB-1050",
|
||||
description: "",
|
||||
notes: "",
|
||||
unit: "each",
|
||||
cost_price: 10,
|
||||
selling_price: 15,
|
||||
markup: 50,
|
||||
supplier_id: "sup1",
|
||||
supplier_name: "Bolt Co",
|
||||
score: 0.9,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
function addQuote(id: string, status = "new", quote_number = `Q-${id}`) {
|
||||
store.quotes.set(id, { id, status, quote_number });
|
||||
}
|
||||
function addItem(quote: string, over: Record<string, unknown> = {}): string {
|
||||
seq += 1;
|
||||
const id = String(over.id ?? `i${seq}`);
|
||||
store.items.set(id, {
|
||||
id,
|
||||
quote,
|
||||
description: `item ${id}`,
|
||||
quantity: 1,
|
||||
sku: "",
|
||||
notes: "",
|
||||
cost_price: 0,
|
||||
selling_price: 0,
|
||||
is_processed: false,
|
||||
user_edited: false,
|
||||
pricing_source: "",
|
||||
pricing_notes: "",
|
||||
moved_to_quote: "",
|
||||
ai_fill: null,
|
||||
// Zero-padded so the string sort matches creation order.
|
||||
created: `2026-10-01 00:00:${String(seq).padStart(2, "0")}.000Z`,
|
||||
...over,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
function decisionFor(id: string, over: Partial<AiLineDecision> = {}): AiLineDecision {
|
||||
return {
|
||||
quote_item_id: id,
|
||||
stock_item_id: "stk1",
|
||||
confidence: "high",
|
||||
reason: "same bolt",
|
||||
doubts: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(opts: {
|
||||
decisions?: (input: DecideInput) => AiLineDecision[];
|
||||
candidates?: (description: string) => StockCandidate[];
|
||||
decide?: AssistantDeps["decide"];
|
||||
} = {}) {
|
||||
const decide = vi.fn(
|
||||
opts.decide ??
|
||||
(async (input: DecideInput): Promise<DecideResult> => {
|
||||
const list = opts.decisions
|
||||
? opts.decisions(input)
|
||||
: input.lines.map((l) => decisionFor(l.quote_item_id));
|
||||
return { decisions: new Map(list.map((d) => [d.quote_item_id, d])), model: "MiniMax-M3" };
|
||||
}),
|
||||
);
|
||||
const shortlist = vi.fn(async (_pb: unknown, line: { description: string }) =>
|
||||
opts.candidates ? opts.candidates(line.description) : [candidate()],
|
||||
);
|
||||
const deps = { decide, shortlist: shortlist as unknown as AssistantDeps["shortlist"], now: () => NOW };
|
||||
return { deps, decide, shortlist };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store.quotes.clear();
|
||||
store.items.clear();
|
||||
store.itemFilters = [];
|
||||
store.updates = [];
|
||||
store.failUpdateFor.clear();
|
||||
events.length = 0;
|
||||
actors.length = 0;
|
||||
seq = 0;
|
||||
recordRun.mockClear();
|
||||
Object.assign(settings, {
|
||||
enabled: true,
|
||||
quote_assistant_enabled: true,
|
||||
quote_assistant_interval_minutes: 10,
|
||||
quote_assistant_min_confidence: "medium",
|
||||
quote_assistant_last_run_at: "",
|
||||
});
|
||||
});
|
||||
|
||||
describe("fillQuote", () => {
|
||||
it("fills a matched line and records no_match for an unmatched one", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const b = addItem("q1", { id: "b" });
|
||||
const { deps } = makeDeps({
|
||||
decisions: () => [decisionFor(a), decisionFor(b, { stock_item_id: null, confidence: null })],
|
||||
});
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
|
||||
expect(r).toEqual({ quoteId: "q1", filled: 1, unmatched: 1, skipped: 0, error: "" });
|
||||
const filled = store.items.get(a)!;
|
||||
expect(filled.cost_price).toBe(10);
|
||||
expect(filled.pricing_source).toBe("ai_pos");
|
||||
expect((filled.ai_fill as { status: string }).status).toBe("filled");
|
||||
const unmatched = store.items.get(b)!;
|
||||
expect(unmatched.cost_price).toBe(0);
|
||||
expect((unmatched.ai_fill as { status: string }).status).toBe("no_match");
|
||||
});
|
||||
|
||||
it("marks a line with no shortlist candidates as no_candidates and does not send it to the AI", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a", description: "matchable" });
|
||||
const b = addItem("q1", { id: "b", description: "nothing like it" });
|
||||
const { deps, decide } = makeDeps({
|
||||
candidates: (d) => (d === "matchable" ? [candidate()] : []),
|
||||
});
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
expect(decide.mock.calls[0][0].lines.map((l) => l.quote_item_id)).toEqual([a]);
|
||||
expect((store.items.get(b)!.ai_fill as { status: string }).status).toBe("no_candidates");
|
||||
expect(r).toMatchObject({ filled: 1, unmatched: 1 });
|
||||
});
|
||||
|
||||
it("calls the AI once per quote", async () => {
|
||||
addQuote("q1");
|
||||
addItem("q1");
|
||||
addItem("q1");
|
||||
addItem("q1");
|
||||
const { deps, decide } = makeDeps();
|
||||
await fillQuote("q1", { deps });
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
expect(decide.mock.calls[0][0]).toMatchObject({ quoteNumber: "Q-q1" });
|
||||
expect(decide.mock.calls[0][0].lines).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("refuses a quote that is not open and writes nothing", async () => {
|
||||
addQuote("q1", "approved");
|
||||
addItem("q1");
|
||||
const { deps, decide } = makeDeps();
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
|
||||
expect(r.error).toBe("quote is not open (status approved)");
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
expect(store.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips a line that got a price between the shortlist and the write (re-read guard)", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const b = addItem("q1", { id: "b" });
|
||||
const { deps, shortlist } = makeDeps();
|
||||
shortlist.mockImplementation(async () => {
|
||||
// A rep prices line b while we are still working.
|
||||
store.items.get(b)!.cost_price = 5;
|
||||
return [candidate()];
|
||||
});
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
|
||||
expect(r).toMatchObject({ filled: 1, skipped: 1, unmatched: 0 });
|
||||
expect(store.updates.map((u) => u.id)).toEqual([a]);
|
||||
expect(store.items.get(b)!.cost_price).toBe(5);
|
||||
expect(store.items.get(b)!.ai_fill).toBeNull();
|
||||
});
|
||||
|
||||
it("skips a line a rep edited the description of mid-run", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const { deps, decide } = makeDeps();
|
||||
decide.mockImplementation(async (input) => {
|
||||
store.items.get(a)!.description = "something else entirely";
|
||||
return {
|
||||
decisions: new Map(input.lines.map((l) => [l.quote_item_id, decisionFor(l.quote_item_id)])),
|
||||
model: "m",
|
||||
};
|
||||
});
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
expect(r).toMatchObject({ filled: 0, skipped: 1 });
|
||||
expect(store.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never touches a line that is no longer writable, even with force", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const { deps, shortlist } = makeDeps();
|
||||
shortlist.mockImplementation(async () => {
|
||||
store.items.get(a)!.user_edited = true;
|
||||
return [candidate()];
|
||||
});
|
||||
|
||||
const r = await fillQuote("q1", { force: true, deps });
|
||||
expect(r.skipped).toBe(1);
|
||||
expect(store.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("caps a quote at QUOTE_MAX_LINES (newest lines first)", async () => {
|
||||
addQuote("q1");
|
||||
for (let i = 0; i < 40; i++) addItem("q1", { id: `l${i}` });
|
||||
const { deps, decide } = makeDeps();
|
||||
|
||||
await fillQuote("q1", { deps });
|
||||
|
||||
const sent = decide.mock.calls[0][0].lines.map((l) => l.quote_item_id);
|
||||
expect(QUOTE_MAX_LINES).toBe(30);
|
||||
expect(sent).toHaveLength(30);
|
||||
expect(sent).toContain("l39");
|
||||
expect(sent).not.toContain("l0");
|
||||
});
|
||||
|
||||
it("respects an explicit maxLines", async () => {
|
||||
addQuote("q1");
|
||||
for (let i = 0; i < 5; i++) addItem("q1");
|
||||
const { deps, decide } = makeDeps();
|
||||
await fillQuote("q1", { maxLines: 2, deps });
|
||||
expect(decide.mock.calls[0][0].lines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("marks every requested line as error when the AI throws, and prices nothing", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const b = addItem("q1", { id: "b" });
|
||||
const { deps } = makeDeps({
|
||||
decide: async () => {
|
||||
throw new Error("MiniMax 529 overloaded");
|
||||
},
|
||||
});
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
|
||||
expect(r.error).toContain("MiniMax 529 overloaded");
|
||||
expect(r.filled).toBe(0);
|
||||
for (const id of [a, b]) {
|
||||
const row = store.items.get(id)!;
|
||||
expect((row.ai_fill as { status: string }).status).toBe("error");
|
||||
expect(row.cost_price).toBe(0);
|
||||
expect(row.selling_price).toBe(0);
|
||||
expect(row.pricing_source).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("still applies the re-read guard on the error path", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const { deps, shortlist } = makeDeps({
|
||||
decide: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
shortlist.mockImplementation(async () => {
|
||||
store.items.get(a)!.selling_price = 9;
|
||||
return [candidate()];
|
||||
});
|
||||
const r = await fillQuote("q1", { deps });
|
||||
expect(r.skipped).toBe(1);
|
||||
expect(store.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes nothing and marks no lines when the AI is disabled", async () => {
|
||||
addQuote("q1");
|
||||
addItem("q1", { id: "a" });
|
||||
const { deps } = makeDeps({
|
||||
decide: async () => {
|
||||
throw new AiDisabledError("off");
|
||||
},
|
||||
});
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
|
||||
expect(r.error).toBe("AI is disabled in settings");
|
||||
expect(r).toMatchObject({ filled: 0, unmatched: 0, skipped: 0 });
|
||||
expect(store.updates).toHaveLength(0);
|
||||
expect(store.items.get("a")!.ai_fill).toBeNull();
|
||||
});
|
||||
|
||||
it("writes no_match for a line the AI returned no decision for", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const { deps } = makeDeps({ decisions: () => [] });
|
||||
const r = await fillQuote("q1", { deps });
|
||||
expect(r).toMatchObject({ filled: 0, unmatched: 1 });
|
||||
expect((store.items.get(a)!.ai_fill as { status: string }).status).toBe("no_match");
|
||||
});
|
||||
|
||||
it("does not write a fill below the configured minimum confidence", async () => {
|
||||
settings.quote_assistant_min_confidence = "high";
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const { deps } = makeDeps({ decisions: () => [decisionFor(a, { confidence: "medium" })] });
|
||||
const r = await fillQuote("q1", { deps });
|
||||
expect(r).toMatchObject({ filled: 0, unmatched: 1 });
|
||||
expect(store.items.get(a)!.cost_price).toBe(0);
|
||||
});
|
||||
|
||||
it("runs every write inside the AI quote assistant audit actor", async () => {
|
||||
addQuote("q1");
|
||||
addItem("q1");
|
||||
const { deps } = makeDeps();
|
||||
await fillQuote("q1", { deps });
|
||||
expect(actors).toEqual([{ name: "AI quote assistant", source: "agent" }]);
|
||||
|
||||
actors.length = 0;
|
||||
await fillQuote("q1", { actorName: "Dave", force: true, deps });
|
||||
expect(actors).toEqual([{ name: "AI quote assistant (run by Dave)", source: "agent" }]);
|
||||
});
|
||||
|
||||
it("only re-sends an unchanged no_match line when forced", async () => {
|
||||
addQuote("q1");
|
||||
addItem("q1", {
|
||||
id: "a",
|
||||
description: "odd widget",
|
||||
ai_fill: { status: "no_match", description_snapshot: "odd widget", attempted_at: NOW.toISOString() },
|
||||
});
|
||||
const first = makeDeps();
|
||||
await fillQuote("q1", { deps: first.deps });
|
||||
expect(first.decide).not.toHaveBeenCalled();
|
||||
|
||||
const forced = makeDeps();
|
||||
await fillQuote("q1", { force: true, deps: forced.deps });
|
||||
expect(forced.decide).toHaveBeenCalledTimes(1);
|
||||
expect(forced.decide.mock.calls[0][0].lines.map((l) => l.quote_item_id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("does not throw when a single write fails; reports it and continues", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const b = addItem("q1", { id: "b" });
|
||||
store.failUpdateFor.add(a);
|
||||
const { deps } = makeDeps();
|
||||
const r = await fillQuote("q1", { deps });
|
||||
expect(r.error).toContain("PB boom");
|
||||
expect(r.filled).toBe(1);
|
||||
expect(store.updates.map((u) => u.id)).toEqual([b]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAssistantTick", () => {
|
||||
it("does nothing when the global AI is off", async () => {
|
||||
settings.enabled = false;
|
||||
const { deps, decide } = makeDeps();
|
||||
expect(await runAssistantTick(deps)).toEqual({ ran: false, reason: "disabled" });
|
||||
expect(recordRun).not.toHaveBeenCalled();
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the assistant is off", async () => {
|
||||
settings.quote_assistant_enabled = false;
|
||||
const { deps } = makeDeps();
|
||||
expect(await runAssistantTick(deps)).toEqual({ ran: false, reason: "disabled" });
|
||||
expect(recordRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is not due 9 minutes after the last run with a 10 minute interval", async () => {
|
||||
settings.quote_assistant_last_run_at = new Date(NOW.getTime() - 9 * 60_000).toISOString();
|
||||
const { deps } = makeDeps();
|
||||
expect(await runAssistantTick(deps)).toEqual({ ran: false, reason: "not_due" });
|
||||
expect(recordRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is due once the interval has elapsed", async () => {
|
||||
settings.quote_assistant_last_run_at = new Date(NOW.getTime() - 10 * 60_000).toISOString();
|
||||
const { deps } = makeDeps();
|
||||
expect((await runAssistantTick(deps)).ran).toBe(true);
|
||||
});
|
||||
|
||||
it("stamps last_run_at before loading lines and records the summary at the end", async () => {
|
||||
addQuote("q1");
|
||||
addItem("q1");
|
||||
addItem("q1", { cost_price: 3 }); // priced: not eligible
|
||||
const { deps } = makeDeps();
|
||||
const realCollection = pb.collection.bind(pb);
|
||||
vi.spyOn(pb, "collection").mockImplementation(((name: string) => {
|
||||
const c = realCollection(name) as Record<string, unknown>;
|
||||
if (name !== "quote_items") return c;
|
||||
return {
|
||||
...c,
|
||||
getList: async (...args: unknown[]) => {
|
||||
events.push("load-lines");
|
||||
return (c.getList as (...a: unknown[]) => unknown)(...args);
|
||||
},
|
||||
};
|
||||
}) as typeof pb.collection);
|
||||
|
||||
const out = await runAssistantTick(deps);
|
||||
|
||||
expect(events.slice(0, 2)).toEqual(["record:last_run_at", "load-lines"]);
|
||||
expect(events[events.length - 1]).toBe("record:summary");
|
||||
expect(recordRun.mock.calls[0][0]).toEqual({ last_run_at: NOW.toISOString() });
|
||||
expect(out.ran).toBe(true);
|
||||
expect(out.summary).toMatchObject({
|
||||
started_at: NOW.toISOString(),
|
||||
quotes_seen: 1,
|
||||
lines_filled: 1,
|
||||
lines_unmatched: 0,
|
||||
lines_skipped: 0,
|
||||
errors: [],
|
||||
});
|
||||
expect(recordRun.mock.calls[1][0]).toEqual({ summary: out.summary });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("queries the open-line window newest first with the quote expanded", async () => {
|
||||
const { deps } = makeDeps();
|
||||
await runAssistantTick(deps);
|
||||
const filter = store.itemFilters[0];
|
||||
expect(filter).toContain("cost_price = 0 && selling_price = 0");
|
||||
expect(filter).toContain("user_edited = false");
|
||||
expect(filter).toContain('(pricing_source = "" || pricing_source = "ai_email")');
|
||||
expect(filter).toContain('moved_to_quote = ""');
|
||||
});
|
||||
|
||||
it("skips lines on quotes that are not open", async () => {
|
||||
addQuote("q1", "approved");
|
||||
addQuote("q2", "claimed");
|
||||
addItem("q1");
|
||||
addItem("q2");
|
||||
const { deps, decide } = makeDeps();
|
||||
const out = await runAssistantTick(deps);
|
||||
expect(out.summary!.quotes_seen).toBe(1);
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
expect(decide.mock.calls[0][0].quoteNumber).toBe("Q-q2");
|
||||
});
|
||||
|
||||
it("processes at most TICK_MAX_QUOTES quotes", async () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
addQuote(`q${i}`);
|
||||
addItem(`q${i}`);
|
||||
}
|
||||
const { deps, decide } = makeDeps();
|
||||
const out = await runAssistantTick(deps);
|
||||
expect(TICK_MAX_QUOTES).toBe(20);
|
||||
expect(out.summary!.quotes_seen).toBe(20);
|
||||
expect(decide).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
|
||||
it("processes at most TICK_MAX_LINES lines, newest first", async () => {
|
||||
addQuote("q1");
|
||||
addQuote("q2");
|
||||
addQuote("q3");
|
||||
for (let i = 0; i < 30; i++) addItem("q1", { id: `a${i}` });
|
||||
for (let i = 0; i < 30; i++) addItem("q2", { id: `b${i}` });
|
||||
for (let i = 0; i < 30; i++) addItem("q3", { id: `c${i}` });
|
||||
const { deps, decide } = makeDeps();
|
||||
|
||||
const out = await runAssistantTick(deps);
|
||||
|
||||
expect(TICK_MAX_LINES).toBe(60);
|
||||
const sent = decide.mock.calls.flatMap((c) => c[0].lines.map((l) => l.quote_item_id));
|
||||
expect(sent).toHaveLength(60);
|
||||
// q3 and q2 are the newest; q1 (oldest) waits for the next tick.
|
||||
expect(sent.every((id) => id.startsWith("c") || id.startsWith("b"))).toBe(true);
|
||||
expect(out.summary!.lines_filled).toBe(60);
|
||||
});
|
||||
|
||||
it("does not resend an unmatched line whose description is unchanged", async () => {
|
||||
addQuote("q1");
|
||||
addItem("q1", {
|
||||
description: "odd widget",
|
||||
ai_fill: { status: "no_match", description_snapshot: "odd widget", attempted_at: NOW.toISOString() },
|
||||
});
|
||||
const { deps, decide } = makeDeps();
|
||||
const out = await runAssistantTick(deps);
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
expect(out.summary!.quotes_seen).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps going when one quote's fill throws and pushes the error to summary.errors", async () => {
|
||||
addQuote("q1");
|
||||
addQuote("q2");
|
||||
addItem("q1", { id: "a" });
|
||||
addItem("q2", { id: "b" });
|
||||
// Quote q2 vanishes between the line scan and the per-quote read.
|
||||
const { deps } = makeDeps();
|
||||
const realCollection = pb.collection.bind(pb);
|
||||
vi.spyOn(pb, "collection").mockImplementation(((name: string) => {
|
||||
const c = realCollection(name) as Record<string, unknown>;
|
||||
if (name !== "quotes") return c;
|
||||
return {
|
||||
getOne: async (id: string) => {
|
||||
if (id === "q2") throw new Error("PB down");
|
||||
return (c.getOne as (i: string) => unknown)(id);
|
||||
},
|
||||
};
|
||||
}) as typeof pb.collection);
|
||||
|
||||
const out = await runAssistantTick(deps);
|
||||
|
||||
expect(out.summary!.errors).toEqual(["Q-q2: PB down"]);
|
||||
expect(out.summary!.lines_filled).toBe(1);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantCounts", () => {
|
||||
it("counts pending AI fills and all non-moved lines per quote", async () => {
|
||||
addQuote("q1");
|
||||
addQuote("q2");
|
||||
const filled = { status: "filled", reviewed_at: "" };
|
||||
addItem("q1", { ai_fill: filled, pricing_source: "ai_pos" }); // pending
|
||||
addItem("q1", { ai_fill: { status: "filled", reviewed_at: "2026-10-16" }, pricing_source: "ai_pos" }); // reviewed
|
||||
addItem("q1", { ai_fill: filled, pricing_source: "manual" }); // rep overrode
|
||||
addItem("q1", { ai_fill: { status: "no_match" } });
|
||||
addItem("q2", { ai_fill: filled, pricing_source: "ai_pos" });
|
||||
|
||||
const counts = await getAssistantCounts(["q1", "q2"]);
|
||||
|
||||
expect(counts).toEqual({
|
||||
q1: { pending: 1, total: 4 },
|
||||
q2: { pending: 1, total: 1 },
|
||||
});
|
||||
expect(store.itemFilters[0]).toContain('moved_to_quote = ""');
|
||||
});
|
||||
|
||||
it("returns {} for no ids without querying", async () => {
|
||||
expect(await getAssistantCounts([])).toEqual({});
|
||||
expect(store.itemFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("chunks more than 50 ids per filter", async () => {
|
||||
const ids = Array.from({ length: 230 }, (_, i) => `q${i}`);
|
||||
addQuote("q0");
|
||||
addQuote("q229");
|
||||
addItem("q0", { ai_fill: { status: "filled" }, pricing_source: "ai_pos" });
|
||||
addItem("q229");
|
||||
|
||||
const counts = await getAssistantCounts(ids);
|
||||
|
||||
expect(store.itemFilters).toHaveLength(5);
|
||||
for (const f of store.itemFilters) {
|
||||
expect((f.match(/\bquote = "/g) ?? []).length).toBeLessThanOrEqual(50);
|
||||
}
|
||||
expect(counts).toEqual({
|
||||
q0: { pending: 1, total: 1 },
|
||||
q229: { pending: 0, total: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
316
src/lib/server/quote-assistant-service.ts
Normal file
316
src/lib/server/quote-assistant-service.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* quote-assistant-service — orchestration for the AI quote assistant.
|
||||
*
|
||||
* `fillQuote` prices the unpriced lines of ONE quote: shortlist POS
|
||||
* candidates per line, ask the AI once for the whole quote, re-read each
|
||||
* line right before writing, and write the patch from `buildLinePatch`.
|
||||
* `runAssistantTick` is the scheduled entry point that picks lines across
|
||||
* quotes (newest first) and calls `fillQuote` per quote.
|
||||
* `getAssistantCounts` feeds the Quotes board badge.
|
||||
*
|
||||
* Guarantees (see quote-assistant-core): the AI never touches a line a
|
||||
* person has priced/edited/processed, never sets `is_processed`, never
|
||||
* changes the quote status and never sends anything.
|
||||
*/
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { runWithAuditActor } from "@/lib/server/audit-context";
|
||||
import {
|
||||
AiDisabledError,
|
||||
clearAiSettingsCache,
|
||||
getAiSettings,
|
||||
recordQuoteAssistantRun,
|
||||
} from "@/lib/server/ai-settings-service";
|
||||
import {
|
||||
buildLinePatch,
|
||||
ELIGIBLE_QUOTE_STATUSES,
|
||||
isLineEligible,
|
||||
isLineStillWritable,
|
||||
type AiLineDecision,
|
||||
} from "@/lib/server/quote-assistant-core";
|
||||
import {
|
||||
decideWithAi,
|
||||
type DecideInput,
|
||||
type DecideLine,
|
||||
type DecideResult,
|
||||
} from "@/lib/server/quote-assistant-ai";
|
||||
import {
|
||||
shortlistStockItems,
|
||||
type StockCandidate,
|
||||
} from "@/lib/server/quote-assistant-shortlist";
|
||||
import { escapeForPbString } from "@/lib/server/price-list-match";
|
||||
import { isAiFillPending } from "@/lib/quote-assistant-review";
|
||||
import type { Quote, QuoteAssistantRunSummary, QuoteItem } from "@/lib/types";
|
||||
|
||||
export interface AssistantDeps {
|
||||
decide?: (input: DecideInput) => Promise<DecideResult>;
|
||||
shortlist?: typeof shortlistStockItems;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface FillQuoteResult {
|
||||
quoteId: string;
|
||||
filled: number;
|
||||
unmatched: number;
|
||||
skipped: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export const TICK_MAX_QUOTES = 20;
|
||||
export const TICK_MAX_LINES = 60;
|
||||
export const QUOTE_MAX_LINES = 30;
|
||||
|
||||
const ACTOR_NAME = "AI quote assistant";
|
||||
const COUNT_CHUNK = 50;
|
||||
|
||||
/** Lines the AI may ever look at: nothing priced, edited or processed. */
|
||||
const OPEN_LINE_FILTER =
|
||||
'is_processed = false && cost_price = 0 && selling_price = 0 && user_edited = false && (pricing_source = "" || pricing_source = "ai_email") && moved_to_quote = ""';
|
||||
|
||||
function errMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function isOpenQuote(status: string | undefined): boolean {
|
||||
return (ELIGIBLE_QUOTE_STATUSES as readonly string[]).includes(status ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the unpriced lines of one quote. Never throws for AI failures: they
|
||||
* are recorded on the lines (`status: "error"`) and in `result.error`.
|
||||
*/
|
||||
export async function fillQuote(
|
||||
quoteId: string,
|
||||
opts: {
|
||||
force?: boolean;
|
||||
maxLines?: number;
|
||||
actorName?: string;
|
||||
deps?: AssistantDeps;
|
||||
} = {},
|
||||
): Promise<FillQuoteResult> {
|
||||
const decide = opts.deps?.decide ?? decideWithAi;
|
||||
const shortlist = opts.deps?.shortlist ?? shortlistStockItems;
|
||||
const nowFn = opts.deps?.now ?? (() => new Date());
|
||||
const maxLines = opts.maxLines ?? QUOTE_MAX_LINES;
|
||||
const actorName = opts.actorName ? `${ACTOR_NAME} (run by ${opts.actorName})` : ACTOR_NAME;
|
||||
|
||||
const result: FillQuoteResult = { quoteId, filled: 0, unmatched: 0, skipped: 0, error: "" };
|
||||
|
||||
return runWithAuditActor({ name: actorName, source: "agent" }, async () => {
|
||||
const pb = await getAdminPocketBase();
|
||||
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
|
||||
if (!isOpenQuote(quote.status)) {
|
||||
result.error = `quote is not open (status ${quote.status})`;
|
||||
return result;
|
||||
}
|
||||
|
||||
const settings = await getAiSettings();
|
||||
const now = nowFn();
|
||||
|
||||
const rows = await pb.collection("quote_items").getFullList<QuoteItem>({
|
||||
filter: `quote = "${escapeForPbString(quoteId)}" && ${OPEN_LINE_FILTER}`,
|
||||
sort: "-created",
|
||||
});
|
||||
const lines = rows
|
||||
.filter((item) => isLineEligible(item, { force: opts.force, now }))
|
||||
.slice(0, maxLines);
|
||||
if (lines.length === 0) return result;
|
||||
|
||||
// 1. Shortlist POS candidates per line (deterministic, no AI).
|
||||
const candidatesById = new Map<string, StockCandidate[]>();
|
||||
try {
|
||||
for (const item of lines) {
|
||||
candidatesById.set(
|
||||
item.id,
|
||||
await shortlist(pb, { description: item.description, sku: item.sku }),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
result.error = `shortlist failed: ${errMessage(e)}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. One AI call for the lines that have candidates.
|
||||
const withCandidates = lines.filter((l) => (candidatesById.get(l.id) ?? []).length > 0);
|
||||
let decisions = new Map<string, AiLineDecision>();
|
||||
let model = "";
|
||||
let aiFailed = false;
|
||||
if (withCandidates.length > 0) {
|
||||
const decideLines: DecideLine[] = withCandidates.map((l) => ({
|
||||
quote_item_id: l.id,
|
||||
description: l.description,
|
||||
quantity: l.quantity,
|
||||
sku: l.sku,
|
||||
notes: l.notes,
|
||||
candidates: candidatesById.get(l.id) ?? [],
|
||||
}));
|
||||
try {
|
||||
const out = await decide({ quoteNumber: quote.quote_number, lines: decideLines });
|
||||
decisions = out.decisions;
|
||||
model = out.model;
|
||||
} catch (e) {
|
||||
if (e instanceof AiDisabledError) {
|
||||
// AI switched off: write nothing so lines aren't poisoned as errors.
|
||||
result.error = "AI is disabled in settings";
|
||||
return result;
|
||||
}
|
||||
aiFailed = true;
|
||||
result.error = `AI call failed: ${errMessage(e)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Re-read each line and write its patch.
|
||||
for (const line of lines) {
|
||||
const candidates = candidatesById.get(line.id) ?? [];
|
||||
try {
|
||||
const fresh = await pb.collection("quote_items").getOne<QuoteItem>(line.id);
|
||||
if (!isLineStillWritable(fresh) || fresh.description !== line.description) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const patch = buildLinePatch({
|
||||
item: fresh,
|
||||
decision: decisions.get(line.id) ?? null,
|
||||
candidates,
|
||||
minConfidence: settings.quote_assistant_min_confidence,
|
||||
model,
|
||||
now,
|
||||
status: candidates.length === 0 ? "no_candidates" : aiFailed ? "error" : undefined,
|
||||
});
|
||||
await pb.collection("quote_items").update(line.id, patch);
|
||||
const status = (patch.ai_fill as { status?: string } | undefined)?.status;
|
||||
if (status === "filled") result.filled += 1;
|
||||
else if (status === "no_match" || status === "no_candidates") result.unmatched += 1;
|
||||
} catch (e) {
|
||||
if (!result.error) result.error = `write failed for line ${line.id}: ${errMessage(e)}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
type TickLine = QuoteItem & { expand?: { quote?: Quote } };
|
||||
|
||||
/**
|
||||
* Scheduled entry point. Does nothing unless AI and the assistant are both
|
||||
* on and the configured interval has elapsed. Picks the newest eligible
|
||||
* lines (bounded by TICK_MAX_QUOTES / TICK_MAX_LINES) and fills them quote
|
||||
* by quote; one quote failing never stops the others.
|
||||
*/
|
||||
export async function runAssistantTick(
|
||||
deps: AssistantDeps = {},
|
||||
): Promise<{ ran: boolean; reason?: "disabled" | "not_due"; summary?: QuoteAssistantRunSummary }> {
|
||||
const nowFn = deps.now ?? (() => new Date());
|
||||
|
||||
clearAiSettingsCache();
|
||||
const settings = await getAiSettings();
|
||||
if (!settings.enabled || !settings.quote_assistant_enabled) {
|
||||
return { ran: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
const now = nowFn();
|
||||
const last = Date.parse(settings.quote_assistant_last_run_at || "");
|
||||
const intervalMs = settings.quote_assistant_interval_minutes * 60_000;
|
||||
if (!Number.isNaN(last) && now.getTime() - last < intervalMs) {
|
||||
return { ran: false, reason: "not_due" };
|
||||
}
|
||||
|
||||
// Overlap guard: stamp the run start before doing any work.
|
||||
const startedAt = now.toISOString();
|
||||
await recordQuoteAssistantRun({ last_run_at: startedAt });
|
||||
|
||||
const summary: QuoteAssistantRunSummary = {
|
||||
started_at: startedAt,
|
||||
finished_at: startedAt,
|
||||
quotes_seen: 0,
|
||||
lines_filled: 0,
|
||||
lines_unmatched: 0,
|
||||
lines_skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
const page = await pb.collection("quote_items").getList<TickLine>(1, 500, {
|
||||
filter: OPEN_LINE_FILTER,
|
||||
expand: "quote",
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
// Newest lines first; bound by quotes and lines per run.
|
||||
const perQuote = new Map<string, TickLine[]>();
|
||||
let taken = 0;
|
||||
for (const item of page.items) {
|
||||
if (taken >= TICK_MAX_LINES) break;
|
||||
if (!isOpenQuote(item.expand?.quote?.status)) continue;
|
||||
if (!isLineEligible(item, { now })) continue;
|
||||
const group = perQuote.get(item.quote);
|
||||
if (!group && perQuote.size >= TICK_MAX_QUOTES) continue;
|
||||
if (group) group.push(item);
|
||||
else perQuote.set(item.quote, [item]);
|
||||
taken += 1;
|
||||
}
|
||||
|
||||
for (const [quoteId, group] of perQuote) {
|
||||
const label = group[0].expand?.quote?.quote_number || quoteId;
|
||||
summary.quotes_seen += 1;
|
||||
try {
|
||||
const r = await fillQuote(quoteId, {
|
||||
maxLines: Math.min(group.length, QUOTE_MAX_LINES),
|
||||
deps,
|
||||
});
|
||||
summary.lines_filled += r.filled;
|
||||
summary.lines_unmatched += r.unmatched;
|
||||
summary.lines_skipped += r.skipped;
|
||||
if (r.error) summary.errors.push(`${label}: ${r.error}`);
|
||||
} catch (e) {
|
||||
summary.errors.push(`${label}: ${errMessage(e)}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
summary.errors.push(`tick failed: ${errMessage(e)}`);
|
||||
}
|
||||
|
||||
summary.finished_at = nowFn().toISOString();
|
||||
await recordQuoteAssistantRun({ summary });
|
||||
return { ran: true, summary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-quote counts for the Quotes board badge: `pending` AI fills awaiting
|
||||
* review and `total` lines (excluding lines moved to another quote).
|
||||
* Quotes with no lines are absent from the result.
|
||||
*/
|
||||
export async function getAssistantCounts(
|
||||
quoteIds: string[],
|
||||
): Promise<Record<string, { pending: number; total: number }>> {
|
||||
if (quoteIds.length === 0) return {};
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
const filters: string[] = [];
|
||||
for (let i = 0; i < quoteIds.length; i += COUNT_CHUNK) {
|
||||
const ors = quoteIds
|
||||
.slice(i, i + COUNT_CHUNK)
|
||||
.map((id) => `quote = "${escapeForPbString(id)}"`)
|
||||
.join(" || ");
|
||||
filters.push(`(${ors}) && moved_to_quote = ""`);
|
||||
}
|
||||
|
||||
type Slim = Pick<QuoteItem, "quote" | "ai_fill" | "pricing_source">;
|
||||
const perChunk = await Promise.all(
|
||||
filters.map((filter) =>
|
||||
pb.collection("quote_items").getFullList<Slim>({
|
||||
filter,
|
||||
fields: "id,quote,ai_fill,pricing_source",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const counts: Record<string, { pending: number; total: number }> = {};
|
||||
for (const item of perChunk.flat()) {
|
||||
const c = counts[item.quote] ?? { pending: 0, total: 0 };
|
||||
c.total += 1;
|
||||
if (isAiFillPending(item)) c.pending += 1;
|
||||
counts[item.quote] = c;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
Loading…
Reference in a new issue