diff --git a/docs/superpowers/plans/2026-10-16-ai-quote-assistant.md b/docs/superpowers/plans/2026-10-16-ai-quote-assistant.md new file mode 100644 index 0000000..8619430 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-ai-quote-assistant.md @@ -0,0 +1,620 @@ +# 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 `. + +## 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`: +```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` 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: +```ts +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>; +export async function shortlistStockItems( + pb: AdminPb, line: { description: string; sku?: string }, limit?: number /* default 8 */, +): Promise; +``` + +- [ ] **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`: +```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): Map; +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; +``` +- Produces in `quote-assistant-review.ts`: +```ts +export function isAiFillPending(item: Pick): boolean; +export function nextPricingSourceOnSave(current: string | undefined): "catalogue" | "ai_pos" | "manual"; +export function buildAiReviewPatch( + item: Pick, + 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): 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 '' ()`, 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: +```ts +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; 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 }): Promise; +``` + +- [ ] **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: +```ts +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; +export async function fillQuote(quoteId: string, opts?: { force?: boolean; maxLines?: number; actorName?: string; deps?: AssistantDeps }): Promise; +export async function runAssistantTick(deps?: AssistantDeps): Promise<{ ran: boolean; reason?: "disabled" | "not_due"; summary?: QuoteAssistantRunSummary }>; +export async function getAssistantCounts(quoteIds: string[]): Promise>; +``` + +- [ ] **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 )"` 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`. 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: })`. 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: +```tsx +export function AiFillMarker(props: { item: Pick; 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/` (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 `. 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: +```ts +export function withAiReview( + item: QuoteItem, + patch: Record, // 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; +``` + +- [ ] **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 `` 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 `` in the toolbar next to "Check pricing". + +- [ ] **Step 4: Wire the quote detail page.** + - Add the row class and `` 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 `` 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 `` next to ``. 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 `
` 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`: `"