WFDASH-WEBAPP/docs/superpowers/plans/2026-10-16-ai-quote-assistant.md
Dave 7d5f4de7d8 docs(quotes): AI quote assistant implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:00:26 +02:00

40 KiB
Raw Permalink Blame History

AI Quote Assistant Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: On a schedule, the built-in AI pre-fills unpriced quote lines from POS items (stock_items). Those lines show purple with an agent logo and the AI's reasoning, until a rep checks and saves them.

Architecture:

  • Service layer. A pure core decides eligibility and builds the patches. A shortlist module finds candidate POS items without using AI. An AI module asks MiniMax (via the Anthropic SDK, as quotes-ai-processor.ts does) to pick between them. A service orchestrates a run per quote and per tick.
  • Routes. Thin routes expose a tick (n8n is the clock), a per-quote manual run, and board counts. A small MCP tool exposes the per-quote run.
  • UI. One shared marker component adds the purple rows and notes on the Process page, the quote detail page and the Quotes board. Settings → AI gains a "Quote assistant" section.

Tech Stack: Next.js (app router; read node_modules/next/dist/docs/ before touching route or page conventions), PocketBase JS SDK, @anthropic-ai/sdk, vitest + Testing Library, Tailwind tokens, lucide-react.

Spec: docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md

Global Constraints

  • No auto-sending. The AI never sets is_processed, never changes quotes.status, and never sends email.
  • Never overwrite people. The AI never writes a line that has a price, has user_edited = true, or has is_processed = true. It re-reads each line right before writing.
  • Prices come only from the POS. Use the POS item's cost, selling price and markup. When the item has a cost but no selling price, use DEFAULT_MARKUP_MULTIPLIER / DEFAULT_MARKUP_PERCENT from stock-default-markup-service.ts. With no cost, the line is no_match.
  • Don't copy the POS SKU onto the line. Keep it in ai_fill only.
  • pricing_source value for AI lines is exactly "ai_pos".
  • Audit actor. All AI writes run inside runWithAuditActor({ name: "AI quote assistant", source: "agent" }, …).
  • Data layer. New data access goes through src/lib/server/* and /api/v1/*. Don't extend src/lib/pb-helpers.ts, and don't migrate existing page calls (owner decision).
  • Auth. The tick uses assertAgentAuth. Browser routes use getBrowserSessionUser(req), with the client sending Authorization: Bearer ${pb.authStore.token}.
  • Settings defaults. Assistant off, interval 10 minutes (min 5, max 1440), minimum confidence medium (allowed: high | medium). Prompt empty means use the default.
  • PB text fields. quote_assistant_prompt max must be 50000. max: 0 caps at 5000.
  • Never test against live PB through MCP write tools. They hit the real quotes board. Tests stub PB and the AI.
  • Commits. Every commit message ends with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  1. Stale Process page. The AI fills a line while a rep has the page open, and the rep then types a price and saves that line. Expected: the rep's values win, and the line is not purple afterwards. A line is "pending" only when ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos", and the rep's save sets pricing_source to "manual". Tested in Task 3.
  2. Token burn on unmatched lines. An unmatched line whose description hasn't changed must not be sent to the AI again on the next tick. If someone edits the description, it is retried. Tested in Task 3 and Task 5.
  3. Big quotes. A quote with 80 open lines must not produce one huge AI call. Lines are capped per run and the rest go next tick. Tested in Task 5.
  4. Bad AI output. The AI may return unknown or duplicate line ids, an invalid confidence, or a stock_item_id that isn't in that line's candidates. Those are ignored, and the line becomes no_match, never a wrong fill. Tested in Task 3.
  5. AI down or switched off. If the AI is switched off, the tick does nothing. If an AI call throws, that quote's lines get status: "error" and are retried after 1 hour (or immediately via the manual button), and other quotes in the tick still run. Tested in Task 5.

Decisions this plan makes (not in the spec)

  • Tick order is newest quote lines first, not oldest. Lines that were tried and left unmatched stay in the candidate window, so oldest-first would starve new quotes. Update the spec's "oldest quotes first" line when committing Task 5.
  • error lines retry when attempted_at is more than 1 hour old, or when the manual button is pressed.
  • Live updates. No realtime in v1. The Process page already needs a manual refresh for out-of-band writes, and Review Focus #1 makes stale saves safe.
  • Shortlist. A new shortlist module reuses the scoring helpers exported from item-intel-service.ts. buildStock is left unchanged.

Task 1: Data model, settings fields and migration

Files:

  • Modify: src/lib/types.ts (add types; add ai_fill to QuoteItem)
  • Create: src/lib/server/quote-assistant-prompt.ts
  • Modify: src/lib/server/ai-settings-service.ts
  • Create: src/lib/server/ai-settings-service.test.ts
  • Create: scripts/migrations/2026-10-16-add-quote-assistant-fields.ts
  • Modify: package.json (script pb:migrate:quote-assistant)

Interfaces:

  • Produces in src/lib/types.ts:
export type AiFillStatus = "filled" | "no_match" | "no_candidates" | "error";
export type AiFillConfidence = "high" | "medium" | "low";
export interface AiFillCandidate { id: string; name: string; sku: string; cost_price: number; selling_price: number }
export interface QuoteItemAiFill {
  status: AiFillStatus;
  stock_item_id: string | null;
  stock_item_name: string;
  stock_item_sku: string;
  cost_price: number;
  selling_price: number;
  markup: number;
  supplier_id: string;
  confidence: AiFillConfidence | null;
  reason: string;
  doubts: string[];
  candidates: AiFillCandidate[];
  description_snapshot: string;
  model: string;
  attempted_at: string;
  price_from_default_markup?: boolean;
  reviewed_at?: string;
  reviewed_by?: string;
  accepted_unchanged?: boolean;
  supplier_changed?: boolean;
}
export interface QuoteAssistantRunSummary {
  started_at: string; finished_at: string;
  quotes_seen: number; lines_filled: number; lines_unmatched: number; lines_skipped: number;
  errors: string[];
}
// QuoteItem gains: ai_fill?: QuoteItemAiFill | null;
  • Produces in ai-settings-service.ts:

    • AiSettings gains quote_assistant_enabled: boolean, quote_assistant_interval_minutes: number, quote_assistant_min_confidence: "high" | "medium", quote_assistant_prompt: string, quote_assistant_last_run_at: string, quote_assistant_last_run_summary: QuoteAssistantRunSummary | null.
    • AiSettingsUpdate gains the first four, all optional.
    • export async function recordQuoteAssistantRun(fields: { last_run_at?: string; summary?: QuoteAssistantRunSummary }): Promise<void> writes only quote_assistant_last_run_at / quote_assistant_last_run_summary and refreshes the cache. It doesn't touch updated_at.
  • Produces export const DEFAULT_QUOTE_ASSISTANT_PROMPT: string in quote-assistant-prompt.ts.

  • Step 1: Write failing tests in src/lib/server/ai-settings-service.test.ts. Mock @/lib/server/pocketbase-admin as quote-items-insights-service.test.ts does, and call clearAiSettingsCache() in beforeEach.

    • getAiSettings on a row without the new fields returns quote_assistant_enabled: false, quote_assistant_interval_minutes: 10, quote_assistant_min_confidence: "medium", quote_assistant_prompt: "", quote_assistant_last_run_at: "", quote_assistant_last_run_summary: null.
    • updateAiSettings({ quote_assistant_interval_minutes: 4 }) rejects with "quote_assistant_interval_minutes must be between 5 and 1440", and 1441 rejects too.
    • updateAiSettings({ quote_assistant_min_confidence: "low" as never }) rejects with 'quote_assistant_min_confidence must be "high" or "medium"'.
    • updateAiSettings({ quote_assistant_prompt: "x".repeat(60_000) }) sends a 50 000-character value.
    • recordQuoteAssistantRun({ last_run_at: "2026-10-16T10:00:00.000Z" }) calls PB update with exactly { quote_assistant_last_run_at: "2026-10-16T10:00:00.000Z" }, and the next getAiSettings() returns that value without another PB read.
  • Step 2: Run them and confirm they fail. Run: npx vitest run src/lib/server/ai-settings-service.test.ts. Expected: FAIL.

  • Step 3: Implement.

    • Extend RawSettingsRow, rowToSettings, DEFAULT_SETTINGS, updateAiSettings validation (interval rounded to an integer) and recordQuoteAssistantRun.
    • Add the types to types.ts.
    • Write DEFAULT_QUOTE_ASSISTANT_PROMPT as plain text with no markdown, in the style of ai-system-prompt.ts, with this content:
You match quote lines from customers of a South African industrial supplier to items in our own POS catalogue, so the sales team gets a draft price. For each quote line you get a list of candidate POS items. Return your decision for every line through the submit_line_matches tool.

Rules:
1. Pick a candidate ONLY if it is the same product: same type, size, thread, length, grade/class, material and finish where the line states them. A near-miss is NOT a match.
2. If the closest candidate differs in anything the customer specified (e.g. line asks grade 12.9, candidate is grade 8), either return no match, or return it with confidence "low" and explain the difference in doubts.
3. Never invent or estimate prices. Prices come only from the chosen POS item.
4. Flag unit and pack-size differences in doubts (e.g. "POS item is sold per box of 100; customer asked for 50 each").
5. confidence: "high" = clearly the same item; "medium" = very likely the same, minor detail unstated by the customer; "low" = uncertain.
6. reason: one or two plain sentences a salesperson understands, saying why it is the same item. doubts: short plain sentences, empty list if none.
7. Only use stock_item_id values from that line's own candidate list. Use null for no match.
  • Write the migration script following scripts/migrations/add-quote-items-user-edited.ts (env vars, idempotent: skip fields that already exist). It adds:

    • quote_items.ai_fill (json, maxSize 200000)
    • ai_settings.quote_assistant_enabled (bool)
    • quote_assistant_interval_minutes (number)
    • quote_assistant_min_confidence (text, max 10)
    • quote_assistant_prompt (text, max 50000)
    • quote_assistant_last_run_at (text, max 40)
    • quote_assistant_last_run_summary (json, maxSize 20000)
  • Add the "pb:migrate:quote-assistant" npm script.

  • Step 4: Run the tests and the type check. Run: npx vitest run src/lib/server/ai-settings-service.test.ts && npx tsc --noEmit. Expected: PASS, no type errors.

  • Step 5: Commit with message feat(quote-assistant): data model, settings fields, migration.


Task 2: POS shortlist

Files:

  • Modify: src/lib/server/item-intel-service.ts (add export to tokenizeIntel, informativeTokens, combinedScore, loadSuppliersById; no behaviour change)
  • Create: src/lib/server/quote-assistant-shortlist.ts
  • Create: src/lib/server/quote-assistant-shortlist.test.ts

Interfaces:

  • Consumes: the exported helpers above, and normaliseSku / escapeForPbString from price-list-match.ts.
  • Produces:
export interface StockCandidate {
  id: string; name: string; sku: string; description: string; notes: string; unit: string;
  cost_price: number; selling_price: number; markup: number;
  supplier_id: string; supplier_name: string; score: number;
}
export type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
export async function shortlistStockItems(
  pb: AdminPb, line: { description: string; sku?: string }, limit?: number /* default 8 */,
): Promise<StockCandidate[]>;
  • Step 1: Write failing tests with a stubbed pb.collection(name).getList/getFullList that captures the filter:

    • The PB filter starts with is_active = true && (, and ORs name ~, description ~ and sku ~ for each informative token.
    • Given "M18 hex nut grade 8" and rows "M18 NUT 8 PLN" (description "M18 NUT 8 PLN") and "M6x30 SET SCR A2 PLN", only the first is returned.
    • Results are sorted by score, descending, and capped at limit.
    • A row whose normaliseSku(sku) equals normaliseSku(line.sku) is ranked first with score: 1, even with a weak description.
    • Rows scoring below 0.2 are dropped.
    • supplier_name is resolved from suppliers.
    • An empty or stopword-only description with no SKU returns [] and makes no PB call.
  • Step 2: Run and confirm they fail. Run: npx vitest run src/lib/server/quote-assistant-shortlist.test.ts.

  • Step 3: Implement shortlistStockItems.

    • Fetch at most 500 rows with fields id,sku,name,description,notes,unit,cost_price,selling_price,markup,supplier.
    • Score each row as max(combinedScore(lineTokens, tokenizeIntel(name)), combinedScore(lineTokens, tokenizeIntel(description))).
    • Escape filter values with escapeForPbString.
  • Step 4: Run the shortlist tests plus npx vitest run src/lib/server/item-intel-service (the existing intel tests must stay green). Expected: PASS.

  • Step 5: Commit with message feat(quote-assistant): POS shortlist from stock_items.


Task 3: Pure decision core and review helpers

Files:

  • Create: src/lib/server/quote-assistant-core.ts (server-only pure functions)
  • Create: src/lib/quote-assistant-review.ts (client-safe: imports only from @/lib/types)
  • Create: src/lib/server/quote-assistant-core.test.ts
  • Create: src/lib/quote-assistant-review.test.ts

Interfaces:

  • Consumes: StockCandidate (Task 2); QuoteItemAiFill, AiFillConfidence (Task 1); DEFAULT_MARKUP_MULTIPLIER, DEFAULT_MARKUP_PERCENT, round2 from stock-default-markup-service.ts.
  • Produces in quote-assistant-core.ts:
export const ELIGIBLE_QUOTE_STATUSES = ["new", "follow_up", "claimed"] as const;
export const ERROR_RETRY_MS = 60 * 60 * 1000;
export interface AiLineDecision {
  quote_item_id: string; stock_item_id: string | null;
  confidence: AiFillConfidence | null; reason: string; doubts: string[];
}
export function isLineEligible(item: QuoteItem, opts: { force?: boolean; now: Date }): boolean;
export function isLineStillWritable(item: QuoteItem): boolean; // no price, !user_edited, !is_processed
export function parseDecisions(raw: unknown, requestedIds: ReadonlySet<string>): Map<string, AiLineDecision>;
export function meetsConfidence(c: AiFillConfidence | null, min: "high" | "medium"): boolean;
export function buildLinePatch(args: {
  item: QuoteItem; decision: AiLineDecision | null; candidates: StockCandidate[];
  minConfidence: "high" | "medium"; model: string; now: Date;
  status?: "no_candidates" | "error";
}): Record<string, unknown>;
  • Produces in quote-assistant-review.ts:
export function isAiFillPending(item: Pick<QuoteItem, "ai_fill" | "pricing_source">): boolean;
export function nextPricingSourceOnSave(current: string | undefined): "catalogue" | "ai_pos" | "manual";
export function buildAiReviewPatch(
  item: Pick<QuoteItem, "ai_fill" | "pricing_source">,
  saved: { cost_price: number; selling_price: number; supplier: string | null },
  reviewer: { id?: string; name: string }, now: Date,
): { ai_fill?: QuoteItemAiFill };
export function aiRowClass(item: Pick<QuoteItem, "ai_fill" | "pricing_source">): string; // "bg-ai/10" when pending, else ""
  • Step 1: Write failing core tests (quote-assistant-core.test.ts).

    isLineEligible:

    • It returns true for a line with is_processed: false, cost/selling 0, user_edited: false, pricing_source: "ai_email", a description, empty moved_to_quote, and no ai_fill.
    • It returns false when any one of these holds:
      • cost_price > 0
      • user_edited
      • pricing_source === "manual"
      • empty description
      • moved_to_quote set
      • ai_fill.status === "no_match" with description_snapshot === description
    • It returns true when that snapshot differs from the description.
    • It returns true for ai_fill.status === "no_match" with the same snapshot when force: true.
    • It returns true for status: "error" when attempted_at is 61 minutes before now, and false at 59 minutes.

    parseDecisions:

    • Ids not in requestedIds are dropped.
    • For duplicate ids, the first wins.
    • confidence: "certain" becomes null.
    • Non-array doubts becomes [].

    meetsConfidence:

    • ("medium","medium") is true, ("low","medium") is false, ("medium","high") is false, and (null, …) is false.

    buildLinePatch fill, with the candidate {cost 1.89, selling 3.4, markup 80, supplier_id "s1"}, decision high, min medium:

    • The patch has cost_price: 1.89, selling_price: 3.4, markup: 80, supplier: "s1" and pricing_source: "ai_pos".
    • ai_fill.status is "filled", ai_fill.description_snapshot equals the description, and pricing_notes starts with "AI: matched POS ".
    • The patch has no sku, is_processed or user_edited keys.

    buildLinePatch default markup:

    • Candidate cost 10, selling 0 gives selling_price: 18, markup: 80 and ai_fill.price_from_default_markup: true.

    buildLinePatch no-fill cases:

    • Candidate cost 0 gives ai_fill.status: "no_match" with no price keys.
    • A decision with confidence: "low" gives "no_match" with no price keys, and ai_fill.candidates lists up to 5 candidates.
    • A decision whose stock_item_id isn't among the candidates gives "no_match".
    • On no_match, pricing_notes is set only when the item's pricing_notes was empty.
    • status: "error" gives ai_fill.status: "error" with no price keys.
  • Step 2: Write failing review tests (quote-assistant-review.test.ts).

    • isAiFillPending is true only for ai_fill.status === "filled", no reviewed_at and pricing_source === "ai_pos". It is false for a filled ai_fill with pricing_source: "manual" (Review Focus #1).
    • nextPricingSourceOnSave: "catalogue" gives "catalogue", "ai_pos" gives "ai_pos", and "ai_email" and "" give "manual".
    • buildAiReviewPatch:
      • On a pending line with the same prices, it gives accepted_unchanged: true, supplier_changed: false, and reviewed_at / reviewed_by set.
      • A changed selling price gives accepted_unchanged: false.
      • A different supplier gives supplier_changed: true.
      • A non-pending line gives {}.
    • aiRowClass gives "bg-ai/10" for pending lines and "" otherwise.
  • Step 3: Run both tests and confirm they fail. Run: npx vitest run src/lib/server/quote-assistant-core.test.ts src/lib/quote-assistant-review.test.ts.

  • Step 4: Implement both modules.

    • pricing_notes for a fill: AI: matched POS '<name>' (<confidence>), followed by . Selling price from default markup. when that applies. Keep it to one line.
    • For no_match and no_candidates: AI: no confident POS match — see closest items.
  • Step 5: Run the tests. Expected: PASS.

  • Step 6: Commit with message feat(quote-assistant): eligibility, patch building, review helpers.


Task 4: AI decision call

Files:

  • Create: src/lib/server/quote-assistant-ai.ts
  • Create: src/lib/server/quote-assistant-ai.test.ts

Interfaces:

  • Consumes: getActiveAiConfig, getAiSettings (ai-settings-service.ts); DEFAULT_QUOTE_ASSISTANT_PROMPT; StockCandidate; parseDecisions, AiLineDecision.
  • Produces:
export interface DecideLine { quote_item_id: string; description: string; quantity: number; sku: string; notes: string; candidates: StockCandidate[] }
export interface DecideInput { quoteNumber: string; lines: DecideLine[] }
export interface DecideResult { decisions: Map<string, AiLineDecision>; model: string }
export const SUBMIT_LINE_MATCHES_TOOL: { name: "submit_line_matches"; description: string; input_schema: object };
export function buildDecideUserText(input: DecideInput): string;
export async function decideWithAi(input: DecideInput, deps?: { client?: Pick<Anthropic, "messages"> }): Promise<DecideResult>;
  • Step 1: Write failing tests.

    • buildDecideUserText includes each line's quote_item_id, description and quantity. For each candidate it includes stock_item_id=, name, SKU, unit, cost, selling price and supplier name. It never includes the words "price list".
    • decideWithAi, given a fake client.messages.create:
      • It is called with tool_choice: { type: "tool", name: "submit_line_matches" }.
      • Its system equals DEFAULT_QUOTE_ASSISTANT_PROMPT when quote_assistant_prompt is "", and the override when it is set.
      • It returns the parsed decisions keyed by id.
    • A response with no tool_use block makes it throw "AI returned no submit_line_matches tool call".
    • Mock ai-settings-service with getActiveAiConfig returning { modelId: "MiniMax-M3", maxTokens: 4096, apiKey: "k", baseUrl: "u", provider: "minimax" }.
  • Step 2: Run and confirm they fail. Run: npx vitest run src/lib/server/quote-assistant-ai.test.ts.

  • Step 3: Implement.

    • The tool schema is { matches: [{ quote_item_id, stock_item_id (string|null), confidence (enum high|medium|low), reason, doubts: string[] }] }, with required: ["matches"].
    • When no client is injected, create the client like newAnthropic in quotes-ai-processor.ts: same base-URL handling, and use aiConfig.maxTokens.
    • Pass the set of requested ids to parseDecisions.
  • Step 4: Run the tests. Expected: PASS.

  • Step 5: Commit with message feat(quote-assistant): MiniMax match decision call.


Task 5: Assistant service (per quote, tick, counts)

Files:

  • Create: src/lib/server/quote-assistant-service.ts
  • Create: src/lib/server/quote-assistant-service.test.ts
  • Modify: docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md (change "oldest quotes first" to "newest lines first", with the reason)

Interfaces:

  • Consumes: Tasks 1–4; getAdminPocketBase; runWithAuditActor; clearAiSettingsCache, getAiSettings, recordQuoteAssistantRun, AiDisabledError.
  • Produces:
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;
export async function fillQuote(quoteId: string, opts?: { force?: boolean; maxLines?: number; actorName?: string; deps?: AssistantDeps }): Promise<FillQuoteResult>;
export async function runAssistantTick(deps?: AssistantDeps): Promise<{ ran: boolean; reason?: "disabled" | "not_due"; summary?: QuoteAssistantRunSummary }>;
export async function getAssistantCounts(quoteIds: string[]): Promise<Record<string, { pending: number; total: number }>>;
  • Step 1: Write failing tests. Stub PB with an in-memory quotes / quote_items store, plus a decide fake and a shortlist fake.

    fillQuote:

    • It writes the fill patch for a matched line and a no_match patch for an unmatched one, and returns { filled: 1, unmatched: 1 }.
    • A line whose shortlist is empty gets no_candidates, and decide is not given that line.
    • decide is called once per quote.
    • A quote with status "approved" returns an error of "quote is not open (status approved)" and writes nothing.
    • Re-read guard: if the store's line gets cost_price: 5 between the shortlist and the write, that line is skipped (skipped: 1) and not written.
    • With 40 eligible lines and maxLines defaulting to QUOTE_MAX_LINES (30), only 30 lines are sent to decide (Review Focus #3).
    • When decide throws, every requested line gets ai_fill.status: "error", the result's error contains the message, and nothing is priced (Review Focus #5).
    • Every write happens inside runWithAuditActor. Mock audit-context and assert the actor name is "AI quote assistant", or "AI quote assistant (run by <actorName>)" when actorName is given.
    • force: true re-sends a no_match line with an unchanged description. Without force, that line is not sent (Review Focus #2).

    runAssistantTick:

    • It returns { ran: false, reason: "disabled" } when enabled or quote_assistant_enabled is false, and does nothing else.
    • It returns { ran: false, reason: "not_due" } when last_run_at is 9 minutes before now with interval 10.
    • When it runs, it calls recordQuoteAssistantRun({ last_run_at }) before loading lines, and calls recordQuoteAssistantRun({ summary }) at the end with the totals.
    • It processes at most TICK_MAX_QUOTES quotes and TICK_MAX_LINES lines, taking the newest lines first.
    • If one quote's fillQuote throws, the other quotes are still processed and the error is pushed to summary.errors.

    getAssistantCounts:

    • pending counts lines where isAiFillPending, and total counts all lines that aren't moved out.
    • It handles more than 200 ids by chunking, like fetchQuoteItemCounts.
  • Step 2: Run and confirm they fail. Run: npx vitest run src/lib/server/quote-assistant-service.test.ts.

  • Step 3: Implement.

    • Tick line query. Use one quote_items getList(1, 500):
      • filter: is_processed = false && cost_price = 0 && selling_price = 0 && user_edited = false && (pricing_source = "" || pricing_source = "ai_email") && moved_to_quote = ""
      • expand: "quote", sort: "-created"
      • Then filter in the app with isLineEligible and an ELIGIBLE_QUOTE_STATUSES check on the expanded quote, and group by quote.
    • Settings read. Call clearAiSettingsCache() before reading settings in the tick, so the interval gate sees fresh data.
    • Error handling. AiDisabledError from decide counts as an error.
  • Step 4: Run the service tests and the full suite. Run: npx vitest run src/lib/server/quote-assistant-service.test.ts, then npx vitest run. Expected: PASS.

  • Step 5: Commit (service plus the spec line change) with message feat(quote-assistant): fill service, tick, counts.


Task 6: Routes, MCP tool, and the "Check pricing" guard

Files:

  • Create: src/app/api/v1/quotes/assistant/tick/route.ts (+ route.test.ts)
  • Create: src/app/api/v1/quotes/[id]/assistant/run/route.ts (+ route.test.ts)
  • Create: src/app/api/v1/quotes/assistant/counts/route.ts (+ route.test.ts)
  • Modify: src/app/api/v1/quotes/[id]/re-price/route.ts (skip pending AI lines)
  • Create: src/app/api/v1/quotes/[id]/re-price/route.test.ts
  • Modify: src/app/api/mcp/route.ts (tool list entry, dispatch, error hint)

Interfaces:

  • Consumes: fillQuote, runAssistantTick, getAssistantCounts (Task 5); isAiFillPending (Task 3).

  • Produces HTTP:

    • POST /api/v1/quotes/assistant/tick: Bearer AGENT_API_KEY. Returns 200 with the runAssistantTick result, or 401.
    • POST /api/v1/quotes/{id}/assistant/run: browser session. Returns 200 with FillQuoteResult. Returns 409 { error } when the result's error is "quote is not open…" or the AI is disabled. Returns 401.
    • GET /api/v1/quotes/assistant/counts?ids=a,b: browser session. Returns 200 Record<id,{pending,total}>. Returns 400 when ids is missing or has more than 500 entries.
  • Produces MCP tool quotes.assistant_fill, with args { quote_id: string }. It calls fillQuote(quote_id, { force: true, actorName: "MCP" }) in-process via dynamic import, the way quotes.next_number does. It is not added to READ_TOOLS.

  • Step 1: Write failing route tests. Mock the service and the auth helpers, following existing route.test.ts files under src/app/api/v1.

    • Tick: a missing or wrong key gives 401. A good key gives 200 with the mocked result.
    • Run: no token gives 401. A good token calls fillQuote(id, { force: true, actorName: <user.name> }). A "not open" result gives 409.
    • Counts: no ids gives 400. With ids, the service is called with the split ids.
    • Re-price: an item with sku: "X1", pricing_source: "ai_pos" and a pending ai_fill gives outcome: "skipped", and findFirstPriceListBySku is not called for it. A reviewed AI line (with reviewed_at) is still re-priced.
  • Step 2: Run and confirm they fail. Run: npx vitest run src/app/api/v1/quotes.

  • Step 3: Implement the routes, the re-price guard and the MCP entry.

    • Add ai_fill and pricing_source to the re-price item type.
    • MCP tool description: "Run the AI quote assistant on one quote now: fills unpriced lines from matching POS items (purple, unreviewed) and notes the closest items for unmatched lines. Never marks lines done or sends anything."
  • Step 4: Run the tests, including the Giellie policy catalogue. Run: npx vitest run src/app/api/v1/quotes src/lib/server/giellie-tool-policy.catalogue.test.ts src/lib/server/giellie-tool-policy.test.ts. Expected: PASS. The new tool classifies as a write, not a read.

  • Step 5: Commit with message feat(quote-assistant): tick/run/counts routes, MCP tool, re-price guard.


Task 7: Purple token and shared UI components

Files:

  • Modify: src/app/globals.css
  • Create: src/components/ai-fill-marker.tsx
  • Create: src/components/ai-fill-marker.test.tsx
  • Create: src/components/ai-fill-quote-button.tsx
  • Create: src/components/ai-fill-quote-button.test.tsx

Interfaces:

  • Consumes: isAiFillPending (Task 3), QuoteItemAiFill.
  • Produces:
export function AiFillMarker(props: { item: Pick<QuoteItem, "id" | "ai_fill" | "pricing_source">; className?: string }): JSX.Element | null;
export function AiBoardBadge(props: { pending: number; total: number }): JSX.Element | null; // null when pending === 0
export function AiFillQuoteButton(props: { quoteId: string; onDone: () => void; size?: "sm" | "default" }): JSX.Element;
  • Produces the Tailwind classes bg-ai/10, text-ai and border-ai/30.

  • Step 1: Write failing component tests (Testing Library, following customer-form.test.tsx).

    AiFillMarker renders:

    • null when there's no ai_fill.
    • A purple Bot button labelled "AI filled this line: view notes" when pending.
    • A muted button labelled "Filled by AI (checked): view notes" when reviewed.
    • A muted "AI: no match" badge for no_match / no_candidates.

    Clicking the button shows:

    • the POS item name and SKU, with a link to /stock/<stock_item_id> (the route at src/app/(app)/stock/[id]/page.tsx)
    • the cost, selling price and markup %
    • the confidence, the reason, and each doubt
    • the "Selling price from default markup" text when flagged

    For no_match, the popover lists the candidates.

    AiBoardBadge:

    • pending: 6, total: 10 renders "AI 6/10", and pending: 0 renders nothing.

    AiFillQuoteButton:

    • A click POSTs /api/v1/quotes/q1/assistant/run with Authorization: Bearer <token>. Mock getPocketBase.
    • On a 200 {filled: 3, unmatched: 2}, it toasts "AI filled 3 lines · 2 without a match" and calls onDone.
    • On a 409, it toasts the error text.
    • The button is disabled while the request runs.
  • Step 2: Run and confirm they fail. Run: npx vitest run src/components/ai-fill-marker.test.tsx src/components/ai-fill-quote-button.test.tsx.

  • Step 3: Implement.

    • In globals.css, add --color-ai: var(--ai); --color-ai-foreground: var(--ai-foreground); next to --color-info.
    • Define --ai: oklch(0.55 0.20 300); --ai-foreground: oklch(0.98 0 0); on :root, and --ai: oklch(0.72 0.17 300); --ai-foreground: oklch(0.15 0 0); under .dark.
    • Follow the --info tokens in src/styles/themes.css if a theme overrides :root. The token must resolve in every theme.
    • Use the existing Popover, Badge and Button UI components. The icon is lucide Bot.
  • Step 4: Run the tests. Expected: PASS.

  • Step 5: Commit with message feat(quote-assistant): ai colour token, marker, fill button.


Task 8: Process page and quote detail page

Files:

  • Modify: src/app/(app)/quotes/[id]/process/page.tsx
  • Modify: src/app/(app)/quotes/[id]/page.tsx
  • Create: src/lib/quote-assistant-save.ts + src/lib/quote-assistant-save.test.ts (the merge logic both pages use, so it can be tested outside the 2,000–4,800-line pages)

Interfaces:

  • Consumes: aiRowClass, nextPricingSourceOnSave, buildAiReviewPatch, isAiFillPending (Task 3); AiFillMarker, AiFillQuoteButton (Task 7).
  • Produces:
export function withAiReview(
  item: QuoteItem,
  patch: Record<string, unknown>,          // the page's existing save payload
  saved: { cost_price: number; selling_price: number; supplier: string | null },
  reviewer: { id?: string; name: string }, now: Date,
  opts: { markProcessedWhenPriced: boolean },
): Record<string, unknown>;
  • Step 1: Write failing tests for withAiReview.

    • On a pending AI line, it keeps pricing_source: "ai_pos" and adds the review ai_fill.
    • With markProcessedWhenPriced: true and both prices above 0, it adds is_processed: true.
    • On a non-AI line, it returns the patch unchanged apart from pricing_source mapped through nextPricingSourceOnSave.
    • It never removes keys from patch.
  • Step 2: Run and confirm they fail, then implement withAiReview. Run: npx vitest run src/lib/quote-assistant-save.test.ts. Expected: FAIL, then PASS after implementing.

  • Step 3: Wire the Process page.

    • Add aiRowClass(item) to the row className in both the desktop and mobile layouts.
    • Put <AiFillMarker item={item} /> next to the existing AiItemHistoryTrigger in the description cell.
    • Add an "AI" badge (border-ai/30 bg-ai/10 text-ai) to the row-badge strip when isAiFillPending(item), and extend that strip's visibility condition to include it.
    • In saveItem, replace the inline pricing_source expression with withAiReview(item, persistedSnapshot, …, { markProcessedWhenPriced: false }). saveItem already sets is_processed.
    • Apply the same to the catalogue-picker auto-apply path, keeping its "catalogue" source.
    • The reviewer is the current PB auth user (pb.authStore.record): { id, name: name || email }.
    • Put <AiFillQuoteButton quoteId={id} onDone={fetchData} /> in the toolbar next to "Check pricing".
  • Step 4: Wire the quote detail page.

    • Add the row class and <AiFillMarker> in the items table (desktop and mobile cards).
    • In handlePriceUpdate, build the payload with withAiReview(item, { cost_price, markup, selling_price }, { …, supplier: item.supplier || null }, …, { markProcessedWhenPriced: true }). handlePriceUpdate needs the item, so look it up from items by id.
    • Put <AiFillQuoteButton quoteId={id} onDone={fetchQuote} /> beside the existing Process action. Show it only when the quote status is in new, follow_up or claimed.
  • Step 5: Verify. Run: npx vitest run && npx tsc --noEmit && npm run lint. Expected: PASS.

  • Step 6: Check it in the browser.

    • Start the dev server with preview_start (port 4001, see memory).
    • On a local or test quote, seed one line through a local test fixture, never via MCP writes on live PB: ai_fill.status: "filled", pricing_source: "ai_pos", prices set.
    • Confirm the purple row, the marker popover, and that Save turns it grey-marked and not purple. Check both light and dark mode. Take a screenshot.
    • If no local PB is available, say so instead of claiming it's verified.
  • Step 7: Commit with message feat(quote-assistant): purple AI lines on process and quote pages.


Task 9: Quotes board badge

Files:

  • Modify: src/app/(app)/quotes/page.tsx

Interfaces:

  • Consumes: GET /api/v1/quotes/assistant/counts (Task 6), AiBoardBadge (Task 7).

  • Step 1: Wire the badge. Wherever fetchQuoteItemCounts(ids) runs (two call sites, around lines 290 and 348), also fetch /api/v1/quotes/assistant/counts?ids=… with the Bearer token into a new aiCounts state. Leave fetchQuoteItemCounts as it is. Render <AiBoardBadge {...aiCounts[item.id]} /> next to <ProcessedBadge>. A failed counts fetch should leave the badge hidden and not break the board.

  • Step 2: Verify. Run: npx tsc --noEmit && npm run lint, then check in the browser on :4001 that the board renders and the badge shows for the seeded quote.

  • Step 3: Commit with message feat(quote-assistant): AI x/y badge on quotes board.


Task 10: Settings section, n8n clock, docs

Files:

  • Modify: src/app/(app)/settings/ai/settings-form-helpers.ts
  • Create: src/app/(app)/settings/ai/settings-form-helpers.test.ts
  • Modify: src/app/(app)/settings/ai/ai-settings-form.tsx
  • Modify: src/app/(app)/settings/ai/page.tsx
  • Create: docs/quote-assistant.md (runbook)
  • Modify: AGENTS.md (short section)

Interfaces:

  • Consumes: the Task 1 settings fields; DEFAULT_QUOTE_ASSISTANT_PROMPT.

  • Produces form field names: qa_enabled (checkbox "on"), qa_interval_minutes, qa_min_confidence, qa_prompt.

  • Step 1: Write failing tests for parseSettingsForm.

    • qa_enabled=on gives quote_assistant_enabled: true, and a missing value gives false.
    • qa_interval_minutes=4 gives the error "Quote assistant interval must be between 5 and 1440 minutes".
    • qa_min_confidence=high passes through, and low gives the error 'Quote assistant minimum confidence must be "high" or "medium"'.
    • qa_prompt passes through untrimmed.
    • The existing fields still parse as before.
  • Step 2: Run and confirm they fail, then implement the parser. Run: npx vitest run "src/app/(app)/settings/ai/settings-form-helpers.test.ts". Expected: FAIL, then PASS after implementing.

  • Step 3: Build the "Quote assistant" form section.

    • It's a Card under the existing form, in the same <form> so it saves with the same button.
    • Controls: on/off switch; "Run every (minutes)" number input; "Minimum confidence to fill" select (High / Medium); prompt textarea with "Reset to default" and a read-only default preview, copied from the intake-prompt UI.
    • A read-only "Last run" line from quote_assistant_last_run_summary: "<time> · <quotes_seen> quotes · <lines_filled> filled · <lines_unmatched> no match", or "Never".
    • Helper text: "Needs the AI switch above to be on too. n8n pings every 5 minutes; this setting decides how often it actually runs."
    • Pass the new fields through page.tsx → AiSettingsFormInitial.
  • Step 4: Create the n8n workflow.

    • Load the n8n MCP tools and skills.
    • The workflow is "WFDASH Quote assistant tick": a Schedule trigger every 5 minutes, then an HTTP Request POST https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick with header auth using the same credential as the existing process-pending workflow. Find that credential with list_credentials; don't create new secrets. Set retryOnFail: false and timeout 120 s.
    • Use create_workflow_from_code, and leave it unpublished. The owner activates it after deploying.
    • If the n8n MCP fails, don't loop. Write the click-by-click steps into docs/quote-assistant.md.
  • Step 5: Write the runbook and AGENTS.md section.

    • docs/quote-assistant.md covers:
      • what the assistant does
      • the manual live steps: (1) npm run pb:migrate:quote-assistant against live PB, (2) deploy, (3) activate the n8n workflow, (4) turn the assistant on in Settings → AI
      • how to read ai_fill
      • how to judge accuracy (accepted_unchanged rate)
    • AGENTS.md gets a 6–10 line section with:
      • the file map (quote-assistant-*.ts)
      • the "pending" definition
      • the rule that AI writes never set is_processed
      • the rule that pricing_source "ai_pos" must survive human saves (nextPricingSourceOnSave)
  • Step 6: Run final verification. Run: npx vitest run && npx tsc --noEmit && npm run lint && npm run build. Expected: all PASS. Check the settings page in the browser on :4001: the section renders, saves, and survives a reload.

  • Step 7: Commit with message feat(quote-assistant): settings section, n8n tick, runbook.