feat(quote-assistant): fill service, tick, counts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 19:29:16 +02:00
parent bd78f0b3c1
commit bb06165318
3 changed files with 976 additions and 2 deletions

View file

@ -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

View 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 },
});
});
});

View 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;
}