The rule-based matcher no longer writes or shows suggestions: on
2026-10-24 it had left 2,888 on unlinked orders, one quote suggested
to 309 of them.
- createOrder runs the AI order assistant (matchOrder) straight after
intake when it is switched on, instead of the rule-based suggestion
- order page: Re-match / Try again and the rule-based suggestion card
are gone; Match with AI is the action, and "Match by reference" saves
the reference then runs the AI (the run route takes { reference })
- the unauthenticated /orders/[id]/quote-match route is removed
- the sweep route and MCP orders.quote_match_sweep run the AI batch
(matchUnlinkedOrders, shared with the tick)
- scripts/2026-10-24-clear-rule-quote-suggestions.ts clears the old
suggestions (dry run unless --apply); replaces the rematch script,
which would have written rule-based ones again
- also fixes the setState-in-effect lint error on the order page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
12 KiB
Markdown
271 lines
12 KiB
Markdown
# AI order assistant runbook
|
|
|
|
Design: `docs/superpowers/specs/2026-10-24-ai-order-assistant-design.md`.
|
|
Plan: `docs/superpowers/plans/2026-10-24-ai-order-assistant.md`.
|
|
|
|
## What it does
|
|
|
|
An order that came from a quote should point at that quote. Today only an
|
|
order created *from* a quote carries the link; every other order — a
|
|
walk-in, an email intake, a counter sale — has to be linked by hand.
|
|
|
|
The order assistant works out which quote an order came from. On a
|
|
schedule it looks at orders that have no quote yet, asks the AI which of
|
|
the candidate quotes the order came from, and then either:
|
|
|
|
- **links** the order to the quote itself (when it is sure), or
|
|
- **suggests** the quote, and a person accepts it.
|
|
|
|
Once the order points at a quote, the assistant can also **fill the
|
|
order's lines** from that quote — the cost, the supplier, the SKU and the
|
|
part number — leaving them in **purple** until a person checks them.
|
|
|
|
The AI never changes a line's status, never creates a purchase order and
|
|
never sends anything. It decides which quote an order belongs to, and it
|
|
may pre-fill a line's prices from that quote.
|
|
|
|
## The auto-link rule, in plain words
|
|
|
|
The assistant links an order to a quote **only** when all of these are
|
|
true:
|
|
|
|
- The **"Auto-link when sure"** setting is on (it is on by default).
|
|
- The AI says it is **sure** about the match.
|
|
- **Every** real order line is paired to a quote line, and each of those
|
|
pairs is a **sure** match.
|
|
- **Every** pair points at a line of the **one** chosen quote (not a
|
|
mixture of two quotes).
|
|
- The chosen quote belongs to the **same company** as the order.
|
|
|
|
If any of those fail, the assistant only writes a **suggestion**. The
|
|
order page then shows "AI suggests Q…" with the reason, the doubts and the
|
|
line-by-line pairing, and the existing **Link** button is there to accept
|
|
it.
|
|
|
|
The assistant also refuses to link when PocketBase says no — for example
|
|
when a line on the order has already moved past `pending`, or when
|
|
another order already points at that quote. A refusal is **not** an error:
|
|
the assistant falls back to a suggestion.
|
|
|
|
## Filling the lines from the quote
|
|
|
|
With **"Fill lines from the quote"** on, the assistant prices the order's
|
|
lines from the quote they are paired with. This is a copy, not a new
|
|
price: no AI call is involved, the pairing is already on the lines.
|
|
|
|
A line may be filled only when **all** of these are true:
|
|
|
|
- It is still `pending`.
|
|
- It carries **no cost** of its own (`cost_price` is 0).
|
|
- Its `pricing_source` is not `manual` and not `quote` — a price a person
|
|
set is never overwritten.
|
|
- It has no **reviewed** AI fill on it.
|
|
- It is paired to a quote line (`quote_item` is set) that actually has a
|
|
cost.
|
|
|
|
What the fill writes:
|
|
|
|
| Field | Rule |
|
|
|---|---|
|
|
| `cost_price` | The quote line's cost |
|
|
| `selling_price` | Only when the order line has none of its own |
|
|
| `supplier`, `sku`, `matched_price_list_id` | Only when the order line has none of its own |
|
|
| `markup` + `markup_percent` | Written together, from cost and selling |
|
|
| `pricing_source` | `ai_quote` |
|
|
| `ai_fill` | The audit record of the fill (see below) |
|
|
|
|
Every line is **re-read immediately before** it is written, so a cost a
|
|
person typed in the meantime wins over the AI's.
|
|
|
|
If the customer's price and the quoted price both exist and differ by more
|
|
than **5%**, the fill records a doubt, e.g. `customer price R16.64, quoted
|
|
R18.20`. Doubts show on the line's marker.
|
|
|
|
### The purple review
|
|
|
|
A filled line is **pending review** while all three are true:
|
|
|
|
- `ai_fill.status` is `filled`,
|
|
- `ai_fill.reviewed_at` is empty, and
|
|
- `pricing_source` is still `ai_quote`.
|
|
|
|
While a line is pending it shows up **purple** on the order Process page,
|
|
with the bot marker next to it. Hover or focus the marker to see the quote
|
|
it was filled from, the cost, the selling price, the markup, the reason and
|
|
any doubts.
|
|
|
|
The purple ends the moment somebody touches the line:
|
|
|
|
- **Saving the line** records the review on `ai_fill` (`reviewed_at`,
|
|
`reviewed_by`, and whether the prices were accepted unchanged or
|
|
changed). The pricing source stays `ai_quote`.
|
|
- The one-click **"use this cost"** copy writes `pricing_source: "quote"`
|
|
— the operator chose the quote's cost themselves — so the AI's
|
|
provenance ends too.
|
|
- A line whose price the operator types by hand becomes `pricing_source:
|
|
"manual"`.
|
|
|
|
### The board badge
|
|
|
|
The Orders board shows an **"AI x/y"** badge on an order when the AI
|
|
filled some of its lines: `x` is how many lines still need a review, `y`
|
|
is how many the AI filled. The badge is hidden when nothing is pending.
|
|
|
|
### `order_items.ai_fill`
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `status` | `filled`, `skipped` or `error` |
|
|
| `source` | `quote` |
|
|
| `quote_id`, `quote_number`, `quote_item_id` | Where the numbers came from |
|
|
| `cost_price`, `selling_price`, `markup` | What was written |
|
|
| `supplier_id`, `sku` | What was written |
|
|
| `reason` | Why the line was paired with that quote line |
|
|
| `doubts` | Anything a person should check (e.g. the 5% price difference) |
|
|
| `model`, `attempted_at` | Which model, and when |
|
|
| `reviewed_at`, `reviewed_by` | Set when a person checked the line |
|
|
| `accepted_unchanged` | True when the person kept both prices |
|
|
| `supplier_changed` | True when the person changed the supplier |
|
|
|
|
## Going live (manual steps, in this order)
|
|
|
|
1. **Migrate PocketBase.** Run against the live PB:
|
|
`npm run pb:migrate:order-assistant`
|
|
(adds `orders.ai_quote_match`, `order_items.ai_fill` and the
|
|
`order_assistant_*` fields on `ai_settings`). If the app is deployed
|
|
before this, PocketBase drops the unknown fields silently and the
|
|
assistant's result lands nowhere.
|
|
2. **Publish the PB hook change.** `ai_quote_match` and `ai_fill` were
|
|
added to `IGNORED_FIELDS` in `pocketbase/pb_hooks/audit_log_utils.js`,
|
|
so the AI's own writes do not flood the audit log. Publish it the same
|
|
way the other hook changes are published
|
|
(`npm run pb:hooks:publish`).
|
|
3. **Deploy** the app. Pushing to main does not auto-deploy; it is a
|
|
manual deploy that takes 5 to 10 minutes.
|
|
4. **Clear the old rule-based suggestions.** Quote matching is AI-only:
|
|
the rule-based matcher no longer runs (not at intake, not on the order
|
|
page, not in the sweep). The suggestions it left behind are wrong far
|
|
too often — on 2026-10-24 there were 2,888 on unlinked orders, one
|
|
quote suggested to 309 of them. Clear them — **dry run first**:
|
|
```bash
|
|
npx tsx scripts/2026-10-24-clear-rule-quote-suggestions.ts
|
|
npx tsx scripts/2026-10-24-clear-rule-quote-suggestions.ts --apply
|
|
```
|
|
It only clears `potential_quote` on orders with no hard link whose
|
|
`quote_match_method` is not an AI one; links and AI suggestions stay.
|
|
5. **Activate the n8n clock** (below).
|
|
6. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
|
|
on, then switch on **"Order assistant enabled"** in the Order assistant
|
|
section and click Save. Defaults: every 10 minutes, auto-link on, fill
|
|
lines on.
|
|
|
|
The tick does nothing until both switches are on, so steps 5 and 6 are
|
|
safe in either order.
|
|
|
|
## n8n workflow: "WFDASH Order assistant tick"
|
|
|
|
n8n only acts as a clock. The app decides whether a run is due (AI and
|
|
the assistant on, and at least "Run every" minutes since the last run), so
|
|
the setting in the app controls the real frequency.
|
|
|
|
Spec:
|
|
|
|
| Node | Setting |
|
|
|---|---|
|
|
| Name | `WFDASH Order assistant tick` |
|
|
| Trigger | Schedule Trigger, every 5 minutes |
|
|
| Action | HTTP Request |
|
|
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick` |
|
|
| Authentication | Generic Credential Type, Header Auth, **the same credential** the quote assistant tick workflow uses (an `Authorization: Bearer <AGENT_API_KEY>` header). Do not create a new secret. |
|
|
| Body | none |
|
|
| Settings tab | Retry On Fail: **off**; Timeout: **120000 ms** (120 s) |
|
|
| Status | **inactive** until the app is deployed and migrated |
|
|
|
|
Click-by-click:
|
|
|
|
1. n8n, Workflows, Create workflow. Name it `WFDASH Order assistant tick`.
|
|
2. Add node, Schedule Trigger. Trigger Interval: Minutes, Minutes Between
|
|
Triggers: `5`.
|
|
3. Add node, HTTP Request, connect it after the trigger.
|
|
- Method: `POST`.
|
|
- URL: `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick`.
|
|
- Authentication: Generic Credential Type, Generic Auth Type: Header
|
|
Auth. Pick the credential used by the quote assistant tick workflow
|
|
(open that workflow and read its HTTP Request node to see which one).
|
|
- Options, Add option, Timeout: `120000`.
|
|
- Settings tab on the node: turn **Retry On Fail** off.
|
|
4. Save. Leave the workflow **inactive** until the steps above are done.
|
|
5. After deploy, click Execute workflow once. Expected response while the
|
|
assistant is off: `{"ran":false,"reason":"disabled"}`. When on and not
|
|
yet due: `{"ran":false,"reason":"not_due"}`. While an earlier run is
|
|
still going: `{"ran":false,"reason":"running"}`. When it ran:
|
|
`{"ran":true,"summary":{...}}`.
|
|
6. Toggle the workflow Active.
|
|
|
|
A 401 means the credential is wrong (it must send the app's
|
|
`AGENT_API_KEY` as a Bearer token). A tick that takes long is normal when
|
|
there is work. Each run is bounded: it matches **at most 10 orders** and
|
|
fills **at most 10 orders**, and it stops starting new orders once 90
|
|
seconds have passed, so a run normally answers inside the 120 s timeout;
|
|
leftovers carry to the next run.
|
|
|
|
An order the tick has nothing to do with does **not** use up that budget:
|
|
the run looks through up to 200 unlinked orders to find 10 it can work
|
|
on, and an order whose lines the fill could not write is skipped rather
|
|
than counted.
|
|
|
|
Runs never overlap. A run stamps its start time before it does any work and
|
|
writes its summary when it ends. While a start is newer than the last
|
|
summary and less than 10 minutes old, other ticks answer
|
|
`{"ran":false,"reason":"running"}`. If a run dies without writing its
|
|
summary, the next tick after those 10 minutes takes over.
|
|
|
|
## Running it by hand
|
|
|
|
- **Order page** — the **"Match with AI"** button on the quote match card
|
|
runs the assistant for that one order and reloads the order. It also
|
|
fills the lines when the order ends up linked.
|
|
- **MCP tool** — `orders.assistant_match` with `{"orderId": "..."}`.
|
|
|
|
Both force a run past the "already looked at these lines" rule.
|
|
|
|
## Reading `ai_quote_match`
|
|
|
|
`orders.ai_quote_match` (JSON) is the record of what the assistant decided
|
|
about an order. Read it in the PB admin.
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `status` | `matched` (the order was linked), `suggested`, `no_match` or `error` |
|
|
| `quote_id`, `quote_number` | The quote the assistant chose |
|
|
| `confidence` | `high`, `medium` or `low` |
|
|
| `reason`, `doubts` | The AI's plain-language reasoning and anything to check |
|
|
| `lines` | The pairing: order line id → quote line id, with `same_item` (`sure` / `likely` / `unsure`) |
|
|
| `auto_linked` | True only when the assistant itself created the link |
|
|
| `lines_hash` | Fingerprint of the order lines the run looked at — the same lines are not looked at twice |
|
|
| `model` | The model that answered |
|
|
| `run_at` | When the run happened |
|
|
| `error` | Set when `status` is `error` |
|
|
|
|
## Settings (Settings → AI → Order assistant)
|
|
|
|
| Setting | Default | Meaning |
|
|
|---|---|---|
|
|
| Order assistant enabled | off | The tick does nothing while this is off |
|
|
| Run every (minutes) | 10 | How often the tick may run |
|
|
| Auto-link when sure | on | May the assistant link the order itself? |
|
|
| Fill lines from the quote | on | May the assistant pre-fill the order's lines from the linked quote? |
|
|
| Prompt | empty | Overrides the built-in prompt when set |
|
|
|
|
`orders.ai_quote_match` and `order_items.ai_fill` are in `IGNORED_FIELDS`
|
|
of the audit hook, so the assistant's own writes do not flood the audit
|
|
log.
|
|
|
|
## Not in this phase
|
|
|
|
- The assistant does not price a line from scratch — it only copies the
|
|
cost, supplier, SKU and part number from the quote line the order line
|
|
is already paired with.
|
|
- It never creates a purchase order, never sends an email and never
|
|
changes a line's status.
|
|
- There is no POS fallback: the assistant fills only from a quote.
|