From bb061653183885f920504275d5a4ed5048c5670e Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 19:29:16 +0200 Subject: [PATCH] feat(quote-assistant): fill service, tick, counts Co-Authored-By: Claude Opus 5.5 --- .../2026-10-16-ai-quote-assistant-design.md | 6 +- .../server/quote-assistant-service.test.ts | 656 ++++++++++++++++++ src/lib/server/quote-assistant-service.ts | 316 +++++++++ 3 files changed, 976 insertions(+), 2 deletions(-) create mode 100644 src/lib/server/quote-assistant-service.test.ts create mode 100644 src/lib/server/quote-assistant-service.ts diff --git a/docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md b/docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md index b5e0f09..4852fb2 100644 --- a/docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md +++ b/docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md @@ -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 diff --git a/src/lib/server/quote-assistant-service.test.ts b/src/lib/server/quote-assistant-service.test.ts new file mode 100644 index 0000000..e34b48a --- /dev/null +++ b/src/lib/server/quote-assistant-service.test.ts @@ -0,0 +1,656 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record & { id: string }; + +const store = { + quotes: new Map(), + items: new Map(), + itemFilters: [] as string[], + updates: [] as { id: string; patch: Record }[], + failUpdateFor: new Set(), +}; +const events: string[] = []; +const actors: { name?: string; source?: string }[] = []; + +function itemMatchesQuotes(filter: string | undefined): Set | 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) => { + 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()), + 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 { + 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 { + 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 { + 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 => { + 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; + 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; + 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 }, + }); + }); +}); diff --git a/src/lib/server/quote-assistant-service.ts b/src/lib/server/quote-assistant-service.ts new file mode 100644 index 0000000..bc0e730 --- /dev/null +++ b/src/lib/server/quote-assistant-service.ts @@ -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; + 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 { + 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(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({ + 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(); + 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(); + 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(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(1, 500, { + filter: OPEN_LINE_FILTER, + expand: "quote", + sort: "-created", + }); + + // Newest lines first; bound by quotes and lines per run. + const perQuote = new Map(); + 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> { + 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; + const perChunk = await Promise.all( + filters.map((filter) => + pb.collection("quote_items").getFullList({ + filter, + fields: "id,quote,ai_fill,pricing_source", + }), + ), + ); + + const counts: Record = {}; + 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; +}