40 KiB
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.tsdoes) 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 changesquotes.status, and never sends email. - Never overwrite people. The AI never writes a line that has a price, has
user_edited = true, or hasis_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_PERCENTfromstock-default-markup-service.ts. With no cost, the line isno_match. - Don't copy the POS SKU onto the line. Keep it in
ai_fillonly. pricing_sourcevalue 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 extendsrc/lib/pb-helpers.ts, and don't migrate existing page calls (owner decision). - Auth. The tick uses
assertAgentAuth. Browser routes usegetBrowserSessionUser(req), with the client sendingAuthorization: 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_promptmax must be 50000.max: 0caps 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
- 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 setspricing_sourceto"manual". Tested in Task 3. - 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.
- 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.
- Bad AI output. The AI may return unknown or duplicate line ids, an invalid confidence, or a
stock_item_idthat isn't in that line's candidates. Those are ignored, and the line becomesno_match, never a wrong fill. Tested in Task 3. - 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.
errorlines retry whenattempted_atis 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.buildStockis left unchanged.
Task 1: Data model, settings fields and migration
Files:
- Modify:
src/lib/types.ts(add types; addai_filltoQuoteItem) - 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(scriptpb: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:AiSettingsgainsquote_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.AiSettingsUpdategains the first four, all optional.export async function recordQuoteAssistantRun(fields: { last_run_at?: string; summary?: QuoteAssistantRunSummary }): Promise<void>writes onlyquote_assistant_last_run_at/quote_assistant_last_run_summaryand refreshes the cache. It doesn't touchupdated_at.
-
Produces
export const DEFAULT_QUOTE_ASSISTANT_PROMPT: stringinquote-assistant-prompt.ts. -
Step 1: Write failing tests in
src/lib/server/ai-settings-service.test.ts. Mock@/lib/server/pocketbase-adminasquote-items-insights-service.test.tsdoes, and callclearAiSettingsCache()inbeforeEach.getAiSettingson a row without the new fields returnsquote_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", and1441rejects 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 PBupdatewith exactly{ quote_assistant_last_run_at: "2026-10-16T10:00:00.000Z" }, and the nextgetAiSettings()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,updateAiSettingsvalidation (interval rounded to an integer) andrecordQuoteAssistantRun. - Add the types to
types.ts. - Write
DEFAULT_QUOTE_ASSISTANT_PROMPTas plain text with no markdown, in the style ofai-system-prompt.ts, with this content:
- Extend
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(addexporttotokenizeIntel,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/escapeForPbStringfromprice-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/getFullListthat captures thefilter:- The PB filter starts with
is_active = true && (, and ORsname ~,description ~andsku ~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 atlimit. - A row whose
normaliseSku(sku)equalsnormaliseSku(line.sku)is ranked first withscore: 1, even with a weak description. - Rows scoring below 0.2 are dropped.
supplier_nameis resolved from suppliers.- An empty or stopword-only description with no SKU returns
[]and makes no PB call.
- The PB filter starts with
-
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.
- Fetch at most 500 rows with fields
-
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,round2fromstock-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, emptymoved_to_quote, and noai_fill. - It returns false when any one of these holds:
cost_price > 0user_editedpricing_source === "manual"- empty description
moved_to_quotesetai_fill.status === "no_match"withdescription_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 whenforce: true. - It returns true for
status: "error"whenattempted_atis 61 minutes beforenow, and false at 59 minutes.
parseDecisions:- Ids not in
requestedIdsare dropped. - For duplicate ids, the first wins.
confidence: "certain"becomesnull.- Non-array
doubtsbecomes[].
meetsConfidence:("medium","medium")is true,("low","medium")is false,("medium","high")is false, and(null, …)is false.
buildLinePatchfill, with the candidate{cost 1.89, selling 3.4, markup 80, supplier_id "s1"}, decisionhigh, minmedium:- The patch has
cost_price: 1.89,selling_price: 3.4,markup: 80,supplier: "s1"andpricing_source: "ai_pos". ai_fill.statusis"filled",ai_fill.description_snapshotequals the description, andpricing_notesstarts with"AI: matched POS ".- The patch has no
sku,is_processedoruser_editedkeys.
buildLinePatchdefault markup:- Candidate cost 10, selling 0 gives
selling_price: 18,markup: 80andai_fill.price_from_default_markup: true.
buildLinePatchno-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, andai_fill.candidateslists up to 5 candidates. - A decision whose
stock_item_idisn't among the candidates gives"no_match". - On
no_match,pricing_notesis set only when the item'spricing_noteswas empty. status: "error"givesai_fill.status: "error"with no price keys.
- It returns true for a line with
-
Step 2: Write failing review tests (
quote-assistant-review.test.ts).isAiFillPendingis true only forai_fill.status === "filled", noreviewed_atandpricing_source === "ai_pos". It is false for a filledai_fillwithpricing_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, andreviewed_at/reviewed_byset. - A changed selling price gives
accepted_unchanged: false. - A different supplier gives
supplier_changed: true. - A non-pending line gives
{}.
- On a pending line with the same prices, it gives
aiRowClassgives"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_notesfor a fill:AI: matched POS '<name>' (<confidence>), followed by. Selling price from default markup.when that applies. Keep it to one line.- For
no_matchandno_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.
buildDecideUserTextincludes each line'squote_item_id, description and quantity. For each candidate it includesstock_item_id=, name, SKU, unit, cost, selling price and supplier name. It never includes the words "price list".decideWithAi, given a fakeclient.messages.create:- It is called with
tool_choice: { type: "tool", name: "submit_line_matches" }. - Its
systemequalsDEFAULT_QUOTE_ASSISTANT_PROMPTwhenquote_assistant_promptis"", and the override when it is set. - It returns the parsed decisions keyed by id.
- It is called with
- A response with no
tool_useblock makes it throw"AI returned no submit_line_matches tool call". - Mock
ai-settings-servicewithgetActiveAiConfigreturning{ 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[] }] }, withrequired: ["matches"]. - When no client is injected, create the client like
newAnthropicinquotes-ai-processor.ts: same base-URL handling, and useaiConfig.maxTokens. - Pass the set of requested ids to
parseDecisions.
- The tool schema is
-
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_itemsstore, plus adecidefake and ashortlistfake.fillQuote:- It writes the fill patch for a matched line and a
no_matchpatch for an unmatched one, and returns{ filled: 1, unmatched: 1 }. - A line whose shortlist is empty gets
no_candidates, anddecideis not given that line. decideis called once per quote.- A quote with status
"approved"returns anerrorof"quote is not open (status approved)"and writes nothing. - Re-read guard: if the store's line gets
cost_price: 5between the shortlist and the write, that line is skipped (skipped: 1) and not written. - With 40 eligible lines and
maxLinesdefaulting toQUOTE_MAX_LINES(30), only 30 lines are sent todecide(Review Focus #3). - When
decidethrows, every requested line getsai_fill.status: "error", the result'serrorcontains the message, and nothing is priced (Review Focus #5). - Every write happens inside
runWithAuditActor. Mockaudit-contextand assert the actor name is"AI quote assistant", or"AI quote assistant (run by <actorName>)"whenactorNameis given. force: truere-sends ano_matchline with an unchanged description. Withoutforce, that line is not sent (Review Focus #2).
runAssistantTick:- It returns
{ ran: false, reason: "disabled" }whenenabledorquote_assistant_enabledis false, and does nothing else. - It returns
{ ran: false, reason: "not_due" }whenlast_run_atis 9 minutes beforenowwith interval 10. - When it runs, it calls
recordQuoteAssistantRun({ last_run_at })before loading lines, and callsrecordQuoteAssistantRun({ summary })at the end with the totals. - It processes at most
TICK_MAX_QUOTESquotes andTICK_MAX_LINESlines, taking the newest lines first. - If one quote's
fillQuotethrows, the other quotes are still processed and the error is pushed tosummary.errors.
getAssistantCounts:pendingcounts lines whereisAiFillPending, andtotalcounts all lines that aren't moved out.- It handles more than 200 ids by chunking, like
fetchQuoteItemCounts.
- It writes the fill patch for a matched line and a
-
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_itemsgetList(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
isLineEligibleand anELIGIBLE_QUOTE_STATUSEScheck on the expanded quote, and group by quote.
- filter:
- Settings read. Call
clearAiSettingsCache()before reading settings in the tick, so the interval gate sees fresh data. - Error handling.
AiDisabledErrorfromdecidecounts as an error.
- Tick line query. Use one
-
Step 4: Run the service tests and the full suite. Run:
npx vitest run src/lib/server/quote-assistant-service.test.ts, thennpx 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: BearerAGENT_API_KEY. Returns 200 with therunAssistantTickresult, or 401.POST /api/v1/quotes/{id}/assistant/run: browser session. Returns 200 withFillQuoteResult. Returns 409{ error }when the result'serroris"quote is not open…"or the AI is disabled. Returns 401.GET /api/v1/quotes/assistant/counts?ids=a,b: browser session. Returns 200Record<id,{pending,total}>. Returns 400 whenidsis missing or has more than 500 entries.
-
Produces MCP tool
quotes.assistant_fill, with args{ quote_id: string }. It callsfillQuote(quote_id, { force: true, actorName: "MCP" })in-process via dynamic import, the wayquotes.next_numberdoes. It is not added toREAD_TOOLS. -
Step 1: Write failing route tests. Mock the service and the auth helpers, following existing
route.test.tsfiles undersrc/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
idsgives 400. With ids, the service is called with the split ids. - Re-price: an item with
sku: "X1",pricing_source: "ai_pos"and a pendingai_fillgivesoutcome: "skipped", andfindFirstPriceListBySkuis not called for it. A reviewed AI line (withreviewed_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_fillandpricing_sourceto 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."
- Add
-
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-aiandborder-ai/30. -
Step 1: Write failing component tests (Testing Library, following
customer-form.test.tsx).AiFillMarkerrenders:nullwhen there's noai_fill.- A purple
Botbutton 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 forno_match/no_candidates.
Clicking the button shows:
- the POS item name and SKU, with a link to
/stock/<stock_item_id>(the route atsrc/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: 10renders"AI 6/10", andpending: 0renders nothing.
AiFillQuoteButton:- A click POSTs
/api/v1/quotes/q1/assistant/runwithAuthorization: Bearer <token>. MockgetPocketBase. - On a 200
{filled: 3, unmatched: 2}, it toasts"AI filled 3 lines · 2 without a match"and callsonDone. - On a 409, it toasts the
errortext. - 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
--infotokens insrc/styles/themes.cssif a theme overrides:root. The token must resolve in every theme. - Use the existing
Popover,BadgeandButtonUI components. The icon is lucideBot.
- In
-
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 reviewai_fill. - With
markProcessedWhenPriced: trueand both prices above 0, it addsis_processed: true. - On a non-AI line, it returns the patch unchanged apart from
pricing_sourcemapped throughnextPricingSourceOnSave. - It never removes keys from
patch.
- On a pending AI line, it keeps
-
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 rowclassNamein both the desktop and mobile layouts. - Put
<AiFillMarker item={item} />next to the existingAiItemHistoryTriggerin the description cell. - Add an
"AI"badge (border-ai/30 bg-ai/10 text-ai) to the row-badge strip whenisAiFillPending(item), and extend that strip's visibility condition to include it. - In
saveItem, replace the inlinepricing_sourceexpression withwithAiReview(item, persistedSnapshot, …, { markProcessedWhenPriced: false }).saveItemalready setsis_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".
- Add
-
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 withwithAiReview(item, { cost_price, markup, selling_price }, { …, supplier: item.supplier || null }, …, { markProcessedWhenPriced: true }).handlePriceUpdateneeds the item, so look it up fromitemsby id. - Put
<AiFillQuoteButton quoteId={id} onDone={fetchQuote} />beside the existing Process action. Show it only when the quote status is innew,follow_uporclaimed.
- Add the row class and
-
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 newaiCountsstate. LeavefetchQuoteItemCountsas 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=ongivesquote_assistant_enabled: true, and a missing value givesfalse.qa_interval_minutes=4gives the error"Quote assistant interval must be between 5 and 1440 minutes".qa_min_confidence=highpasses through, andlowgives the error'Quote assistant minimum confidence must be "high" or "medium"'.qa_promptpasses 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.
- It's a Card under the existing form, in the same
-
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/tickwith header auth using the same credential as the existingprocess-pendingworkflow. Find that credential withlist_credentials; don't create new secrets. SetretryOnFail: falseand 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.mdcovers:- what the assistant does
- the manual live steps: (1)
npm run pb:migrate:quote-assistantagainst 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_unchangedrate)
AGENTS.mdgets 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)
- the file map (
-
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.