feat(quotes): compute item intel right after the AI fills a line
A line the AI filled now gets its Item Intel immediately, so the blue price-check marker on the Process page has something to show instead of "No intel available" until someone opens the ⓘ popover. The intel is anchored to the POS item the AI actually picked (ai_fill.stock_item_id / ai_fill.stock_item_sku), so it points at that exact catalogue row rather than a description-fuzzy guess. The step is strictly additive: it runs inside the same audit actor as the fill, never throws (a failure is logged and the fill still counts as filled), and it is skipped once the run's TICK_TIME_BUDGET_MS is spent — the remaining lines keep their fills and get their intel on the next ⓘ / Refresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
110332402e
commit
bf4968875b
2 changed files with 109 additions and 1 deletions
|
|
@ -88,6 +88,14 @@ vi.mock("@/lib/server/audit-context", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
// The intel step runs for real against PB in production; here it is
|
||||
// stubbed so the test can assert the anchor it was handed.
|
||||
const populateIntel = vi.fn(async () => ({}) as never);
|
||||
vi.mock("@/lib/server/item-intel-service", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/server/item-intel-service")>()),
|
||||
populateItemIntel: (...args: unknown[]) => populateIntel(...(args as [])),
|
||||
}));
|
||||
|
||||
const settings = {
|
||||
enabled: true,
|
||||
quote_assistant_enabled: true,
|
||||
|
|
@ -220,6 +228,8 @@ beforeEach(() => {
|
|||
actors.length = 0;
|
||||
seq = 0;
|
||||
recordRun.mockClear();
|
||||
populateIntel.mockClear();
|
||||
populateIntel.mockResolvedValue({} as never);
|
||||
Object.assign(settings, {
|
||||
enabled: true,
|
||||
quote_assistant_enabled: true,
|
||||
|
|
@ -265,6 +275,50 @@ describe("fillQuote", () => {
|
|||
expect((unmatched.ai_fill as { status: string }).status).toBe("no_match");
|
||||
});
|
||||
|
||||
it("populates the item intel for a filled line, anchored to the POS item the AI picked", 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 })],
|
||||
});
|
||||
|
||||
await fillQuote("q1", { deps });
|
||||
|
||||
// Only the filled line gets intel.
|
||||
expect(populateIntel).toHaveBeenCalledTimes(1);
|
||||
expect(populateIntel).toHaveBeenCalledWith("a", "quote_items", {
|
||||
write: true,
|
||||
anchor: { stockItemId: "stk1", sku: "HB-1050" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the item intel once the run's time budget is spent", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const { deps } = makeDeps();
|
||||
|
||||
const r = await fillQuote("q1", { deps, deadlineMs: NOW.getTime() - 1 });
|
||||
|
||||
// The fill still happened — only the intel step was skipped.
|
||||
expect(r.filled).toBe(1);
|
||||
expect((store.items.get(a)!.ai_fill as { status: string }).status).toBe("filled");
|
||||
expect(populateIntel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never fails the fill when the item intel step fails", async () => {
|
||||
addQuote("q1");
|
||||
const a = addItem("q1", { id: "a" });
|
||||
const { deps } = makeDeps();
|
||||
populateIntel.mockRejectedValueOnce(new Error("intel boom"));
|
||||
|
||||
const r = await fillQuote("q1", { deps });
|
||||
|
||||
expect(r).toEqual({ quoteId: "q1", filled: 1, unmatched: 0, skipped: 0, error: "" });
|
||||
expect((store.items.get(a)!.ai_fill as { status: string }).status).toBe("filled");
|
||||
expect(store.items.get(a)!.cost_price).toBe(10);
|
||||
});
|
||||
|
||||
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" });
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import {
|
|||
type StockCandidate,
|
||||
} from "@/lib/server/quote-assistant-shortlist";
|
||||
import { escapeForPbString } from "@/lib/server/price-list-match";
|
||||
import { loadSuppliersById } from "@/lib/server/item-intel-service";
|
||||
import { loadSuppliersById, populateItemIntel } from "@/lib/server/item-intel-service";
|
||||
import { isAiFillPending } from "@/lib/quote-assistant-review";
|
||||
import type { Quote, QuoteAssistantRunSummary, QuoteItem } from "@/lib/types";
|
||||
import type { AdminPb } from "@/lib/server/quote-assistant-shortlist";
|
||||
|
|
@ -124,6 +124,46 @@ export function boundRunErrors(errors: string[]): string[] {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Item Intel for a line the AI just filled.
|
||||
*
|
||||
* Purely additive: the fill already succeeded by the time we get here,
|
||||
* and a failure here must never turn a good fill into an error. It
|
||||
* only means the ⓘ popover stays empty until someone hits Refresh.
|
||||
*
|
||||
* The anchor is the POS item the AI picked (`ai_fill.stock_item_id` /
|
||||
* `ai_fill.stock_item_sku`), so the intel points at that exact
|
||||
* catalogue row instead of a description-fuzzy guess.
|
||||
*
|
||||
* Skipped once the run's time budget is spent — the remaining lines
|
||||
* keep their fills, they just get their intel on the next ⓘ / Refresh.
|
||||
*/
|
||||
async function populateIntelAfterFill(
|
||||
lineId: string,
|
||||
patch: Record<string, unknown>,
|
||||
deadlineMs: number,
|
||||
nowFn: () => Date,
|
||||
): Promise<void> {
|
||||
const aiFill = patch.ai_fill as
|
||||
| { stock_item_id?: string | null; stock_item_sku?: string | null }
|
||||
| undefined;
|
||||
const anchor = {
|
||||
stockItemId: aiFill?.stock_item_id || undefined,
|
||||
sku: aiFill?.stock_item_sku || undefined,
|
||||
};
|
||||
if (nowFn().getTime() >= deadlineMs) {
|
||||
console.warn(
|
||||
`[quote-assistant] time budget spent — skipping item intel for line ${lineId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await populateItemIntel(lineId, "quote_items", { write: true, anchor });
|
||||
} catch (e) {
|
||||
console.error(`[quote-assistant] item intel failed for line ${lineId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the unpriced lines of one quote. Never throws for AI failures: they
|
||||
* are recorded on the lines (`status: "error"`) and in `result.error`.
|
||||
|
|
@ -135,6 +175,10 @@ export async function fillQuote(
|
|||
maxLines?: number;
|
||||
actorName?: string;
|
||||
deps?: AssistantDeps;
|
||||
/** Epoch-ms deadline for the optional Item Intel step. Defaults to
|
||||
* `now + TICK_TIME_BUDGET_MS`. Once passed, lines still get their
|
||||
* fills — they just skip the intel computation. */
|
||||
deadlineMs?: number;
|
||||
} = {},
|
||||
): Promise<FillQuoteResult> {
|
||||
const decide = opts.deps?.decide ?? decideWithAi;
|
||||
|
|
@ -142,6 +186,7 @@ export async function fillQuote(
|
|||
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 deadlineMs = opts.deadlineMs ?? nowFn().getTime() + TICK_TIME_BUDGET_MS;
|
||||
|
||||
const result: FillQuoteResult = { quoteId, filled: 0, unmatched: 0, skipped: 0, error: "" };
|
||||
|
||||
|
|
@ -257,6 +302,11 @@ export async function fillQuote(
|
|||
if (status === "filled") result.filled += 1;
|
||||
else if (status === "no_match" || status === "no_candidates") result.unmatched += 1;
|
||||
if (noAnswer) unanswered += 1;
|
||||
// Give the line its price-check intel straight away so the blue
|
||||
// marker on the Process page has something to show.
|
||||
if (status === "filled") {
|
||||
await populateIntelAfterFill(line.id, patch, deadlineMs, nowFn);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!result.error) result.error = `write failed for line ${line.id}: ${errMessage(e)}`;
|
||||
}
|
||||
|
|
@ -347,6 +397,9 @@ export async function runAssistantTick(
|
|||
}
|
||||
|
||||
const startMs = now.getTime();
|
||||
// One absolute deadline for the whole run: the per-quote intel step
|
||||
// stops once the tick's time budget is spent.
|
||||
const intelDeadlineMs = startMs + TICK_TIME_BUDGET_MS;
|
||||
for (const [quoteId, group] of perQuote) {
|
||||
// Time budget: never start another quote late in the run, so a run
|
||||
// finishes inside n8n's request timeout. Leftovers go next run.
|
||||
|
|
@ -357,6 +410,7 @@ export async function runAssistantTick(
|
|||
const r = await fillQuote(quoteId, {
|
||||
maxLines: Math.min(group.length, QUOTE_MAX_LINES),
|
||||
deps,
|
||||
deadlineMs: intelDeadlineMs,
|
||||
});
|
||||
summary.lines_filled += r.filled;
|
||||
summary.lines_unmatched += r.unmatched;
|
||||
|
|
|
|||
Loading…
Reference in a new issue