WFDASH-WEBAPP/AGENT_BRIEF_AUTOFILL_QUOTES.md

251 lines
15 KiB
Markdown
Raw Normal View History

# Agent Brief — Fill Prices on New Quotes (v3, 2026-07-02)
**Your job:** Find quotes with `status="new"`, look at every line item that has no pricing yet, match it against the price list, fill it if you can, leave a note with the 3 closest catalogue candidates if you can't. Move to the next one.
**This is v3.** v1 had the security/auth smell of using a raw admin JWT. v2 inherited that — and the agent ended up running 26 separate PATCH round-trips per batch. v3 switches the writes to the new `bulk_update_quote_items` MCP tool — one JSON-RPC call covers the whole batch. v2's matcher logic, schema corrections and traps are unchanged and carried forward below as §3, §7, §8. **Read §7 and §8 BEFORE writing anything.**
---
## 1. Access
**Use the MCP, not PB REST.** The MCP server is on the same Next.js app — hit it with the agent bearer token (the one from your environment as `WFDASH_AGENT_API_KEY` or equivalent). Never mint a `users/auth-with-password` token; you don't need it and you should never see the admin JWT.
```bash
MCP="https://wfdash.example.com/api/mcp" # adjust to your env
TOKEN="${WFDASH_AGENT_API_KEY}" # bearer token in env
AUTH="Authorization: Bearer $TOKEN"
```
> **Token gotcha (unchanged from v2):** if you assign the token in Python (`t = json.loads(out)['token']`), Hermes's secret-redaction truncates it to ~13 chars and every subsequent request returns 0 results. Read it from an env var, not from a curl/Python pipe.
To verify auth + discovery, do a `tools/list` first:
```bash
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
"$MCP" | python3 -m json.tool
```
You should see `bulk_update_quote_items` in the list. If you don't, the operator hasn't deployed the latest MCP yet — stop and ask, don't fall back to PB REST.
---
## 2. The matcher (unchanged from v2)
### Tier 1 — exact SKU match
Filter: `sku="<sku>" || sku_normalised="<NORM>"` where NORM = sku uppercased with non-alnum stripped. Sort `-updated`, perPage 5. If ≥ 1 hit → auto-fill.
### Tier 2 — substring on SKU
Filter: `sku_normalised~"<norm>" || sku~"<sku>"`. Sort `-updated`, perPage 5. If ≥ 1 hit → auto-fill.
> **Skip Tier 1 + 2 when SKU is empty** (`sku==""`). With `~""` the filter matches every catalogue row. Implementing requires an explicit `if sku: ... else Tier 3` branch in code, not just relying on the filter to be useless.
### Tier 3 — fuzzy on description
Query = cleaned description (noise chars stripped: `" ( ) * ~ \``). Use this 4-step cascade:
**A. Full cleaned query.** Filter: `search_text~"<cleaned>"`. perPage 10. From the candidates, pick the row whose `description` normalises equal to the quote line's description (modulo noise chars + whitespace + case). If exact match → auto-fill.
**B. Dimension-AND filter.** Extract every `Nmm` value AND every fractional inch (`1/2`, `2-1/4`, `3/8`) from the ORIGINAL (uncleaned) description. AND them together. perPage 30. Pick the row whose `description` normalises equal to the quote line's. If exact match → auto-fill.
**C. Dimension-pair fallback.** If B returns no exact match, try each pair of extracted dimensions AND-ed together. Pick exact match if found → auto-fill.
**D. If no exact match anywhere → flag.** Don't auto-fill a partial match. Set `pricing_notes = "needs human review — closest catalogue candidates: …"` with the top 3 by description similarity.
### Tier 3 with 0 catalogue hits at all → flag with "no match"
`pricing_notes = "needs human review — no catalogue match for '<query>'"`. Set `user_edited = true`. Do NOT leave the row silently unpriced.
### Selling-price fallback
```
selling = catalogue.selling_price
|| round(catalogue.cost_price * 1.25 * 100) / 100
markup = round(((selling / cost) - 1) * 100) # %
```
---
## 3. The writes — one MCP call, no PB REST
Cataloguing the per-row decision in §2 produces a list of patches. Don't issue 26 PATCHes. Batch them into a single `tools/call` for `bulk_update_quote_items`.
### The call
```bash
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"tools/call",
"params":{
"name":"bulk_update_quote_items",
"arguments":{
"dry_run": false,
"items": [
{ "id": "m9z3ubxog1jz7ye", "patch": {
"cost_price": 249.51, "selling_price": 311.89, "markup": 25,
"description": "HSS Annular Cutter 1/2\" (12.7mm) 25mm DOC",
"supplier": "q6hpwbxsicyoiuu", "unit": "each",
"pricing_source": "catalogue",
"matched_price_list_id": "wklkwmbkmstf53m",
"user_edited": true,
"pricing_notes": "Auto-filled by <your-agent-name> on <YYYY-MM-DD> from JEISA - WESTSCOTT GROUP (Tier 3-exact)"
}},
{ "id": "2zcarxk7p6a8lvg", "patch": {
"user_edited": true,
"pricing_notes": "needs human review — no catalogue match for \"circular saw blade 184mm\"\nClosest candidates:\n - <sku1> R<cost1> <desc1>\n - <sku2> R<cost2> <desc2>\n - <sku3> R<cost3> <desc3>"
}}
]
}
}
}' \
"$MCP"
```
For a probe-then-confirm workflow (recommended before a 20+ batch — see §6), pass `"dry_run": true`. The response shape is identical except `results[].status` is `"dry_run"` and `update` was not called. Zero side effects, real per-row feedback.
### What the tool does for you
- **Field whitelist** — only `cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited` are patchable. Any other key (`id`, `quote`, `created`, `updated`, `quantity`, …) is rejected with `reason: forbidden_field: <list>`.
- **Hand-edit guard** — if a row already has `cost_price > 0 && selling_price > 0`, the tool skips it with `reason: already_priced`. Pass `force_overwrite: true` on that specific row if you really mean to overwrite the operator's number.
- **Per-row independence** — one row's failure does not abort the batch. Returned per-row reasons: `forbidden_field`, `validation_error`, `not_found`, `already_priced`, `pb_error`. For non-array `items` or batches > 100 the whole call fast-fails with a JSON-RPC error.
- **Caller stamps `pricing_notes`** — the agent knows who it is and the date. Include it in the patch when you change pricing fields (the existing `/match` style assumes you will). The tool does NOT auto-inject an audit string.
### Response shape (one row)
```jsonc
{
"ok": true,
"updated": 14,
"skipped": 0,
"errors": [],
"results": [
{
"id": "m9z3ubxog1jz7ye",
"status": "ok",
"previous_cost_price": 0,
"previous_selling_price": 0,
"new_cost_price": 249.51,
"new_selling_price": 311.89,
"updated_fields": ["cost_price","selling_price","markup", ...]
},
{ "id": "2zcarxk7p6a8lvg", "status": "ok", "...": "..." },
{ "id": "abc...", "status": "skipped", "reason": "already_priced",
"previous_cost_price": 100, "previous_selling_price": 125 }
],
"dry_run": false
}
```
`ok: false` means errors.length > 0 (some rows failed but the rest were applied). `ok: true, updated: 0` is a no-op batch (everything skipped). Inspect `errors[].reason` for per-row diagnostics.
---
## 4. Decisions (unchanged from v2)
- **"Confident match"** = the candidate row's `description` normalises equal to the quote line's `description` (modulo noise chars, whitespace, case). Partial matches don't count — leave candidates.
- **`pricing_source`** = `"catalogue"` only on a successful fill. Leave unset (or set to `"manual"`) when you only left candidates.
- **Supplier resolution**: just copy `price_list.supplier` (the relation id) straight into `quote_items.supplier`. If you need the human-readable name for the audit line, look it up via `GET /api/collections/suppliers/records/<id>` and cache it. Don't try to resolve name → id — the catalogue already stores the id.
- **Quantity** stays as-is — never modify it. (The MCP tool rejects `quantity` in the whitelist anyway, so even if you pass it by accident it won't write.)
---
## 5. Don'ts
- **Read from `price_list` freely — that's the whole point.** Just don't write to it or delete from it. It's the source of truth for pricing.
- **Don't issue raw PB REST writes.** All quote_item patches go through the MCP. Don't fall back to a `curl -X PATCH` loop because the auth header looks shorter than you expected.
- Don't delete anything from any collection.
- Don't overwrite a quote_item row that already has both `cost_price > 0` AND `selling_price > 0` (the operator may have hand-set it). Pass `force_overwrite: true` if you really mean it.
- Don't auto-write across > 20 rows without surfacing the histogram first and getting explicit confirmation (see §6).
- Don't trust `sort=-updated` as a tiebreaker — see §7.2.
- Don't use bare `search_text~"<full description>"` if the description contains `"`, `(`, `)`, or `*` — see §7.1.
- Don't leave Tier 3 rows silently unpriced — always write a `pricing_notes` flag.
- Don't pass `id`, `quote`, `created`, `updated`, `quantity`, or any other unwhitelisted field in the patch — the MCP will reject the row but you'll spend a round-trip learning it.
---
## 6. Probe-then-confirm workflow
1. Walk all new quotes, paginate fully. Build the full list of unpriced items.
2. Run the matcher on each. Build the patch list.
3. Send the patches as `bulk_update_quote_items` with `dry_run: true`. Get back per-row diagnostics.
4. Print histogram: `Tier 1 / Tier 2 / Tier 3-exact-fill / Tier 3-flag-with-candidates / Tier 3-flag-no-match / skipped (already priced)`.
5. Show sample fills and the top 3 fuzzy-match outliers.
6. **Stop and ask the operator** which mode to run:
- **Full auto-fill + flag** — Tier 1/2/3-exact fill, Tier 3-partial/empty flag with `pricing_notes`. **Default mode.**
- **Probe-only** — keep `dry_run: true`, write nothing. (Useful for a first look on a brand-new supplier.)
- **Report only** — don't even call the MCP. Send the histogram and candidate list as a message.
7. After confirmation, send the same batch with `dry_run: false`. The MCP reports per-row outcomes — surface `errors[]` in your final report so the operator sees what didn't land.
**Exception:** if the operator explicitly says "go" or "fill and flag them" in the prompt, skip step 5 and run the full MCP call immediately. The `dry_run` probe is operator-time, not a safety guarantee — the tool still rejects forbidden fields and already-priced rows for you.
---
## 7. Schema corrections vs the v1 brief (carried from v2)
| v1 said | v3 truth |
|---|---|
| `price_list.supplier` is a display name string | It's a relation id (15 chars). Copy verbatim. |
| Tier 3: `search_text~"<full desc>"` works for any description | Breaks on `"`, `(`, `)`, `*` — always strip noise chars or use dimension-AND |
| Trust first `-updated` sort hit | Bulk imports share `updated` dates; sort is effectively random. Always verify exact description match. |
| Flag candidates only when Tier 3 returns ≥ 1 hit | Also flag when Tier 3 returns 0 hits — otherwise rows disappear into the noise |
| (new in v3) Use `curl -X PATCH` with an admin JWT for each row | Use `bulk_update_quote_items` MCP tool — one call covers the whole batch, you never see the admin token |
---
## 8. Known traps
### 8.1 `search_text~` chokes on `"`, `(`, `)`, `*`
PocketBase's substring operator parses the first `"` in your query as the end of the string literal and bails. Affected: every description with fractional-inch dimensions like `HSS Annular Cutter 1/2" (12.7mm) 25mm DOC`. The literal substring **is** in `search_text` — you just can't reach it through `~` with an unescaped quote.
**Symptoms:** Tier 3 returns 0 hits for items that visibly match the catalogue.
**Fix:** strip noise chars (`" ( ) * ~ \``) from the query, OR skip to step B of the Tier 3 cascade and use dimension-AND instead.
### 8.2 `sort=-updated` is unreliable after bulk imports
When a batch import lands, every imported row shares the same `updated` timestamp. `sort=-updated` then returns an effectively arbitrary order. The 18 carbide burrs in this catalogue all updated 2026-06-30; trusting the first hit gave us `A0616` (head 6×16mm) when we needed `D0605` (head 6×5mm).
**Fix:** from the candidate set, find the row whose `description` normalises equal to the quote line's description. Only fall back to "first by `-updated`" if no exact-normalised match exists — and even then, return top 3 candidates rather than auto-fill.
### 8.3 Empty-SKU guard
`sku=""` makes Tier 2's `~""` substring match every catalogue row (872 in our case). The script must short-circuit:
```python
if not sku: # skip Tier 1 + 2
# go straight to Tier 3
```
### 8.4 Outrider — `"test"` description
Quote `7h1w44755zkvknw` has a row with `description = "test"`. It will match every `TEST_GRINDER` row in Tier 3. Do NOT auto-fill these — leave flagged.
### 8.5 Hermes redaction mangles the token
When a JWT is echoed back into a Python variable (e.g. from a Python-issued bearer), Hermes replaces the middle with asterisks. The variable ends up as `eyJhbG...XXXXX` (~13 chars). Subsequent requests succeed but return empty results because the auth header has garbage.
**Fix:** always read the MCP bearer from `$WFDASH_AGENT_API_KEY` (or whatever env var the operator set). Never assign the token in Python directly.
---
## 9. Final report
After running, report:
- How many quotes walked
- How many items filled (with totals: cost R…, sell R…)
- How many items flagged (and whether flag has candidates or "no match")
- How many items skipped as `already_priced` (operator had hand-set them)
- Any `errors[]` from the MCP — usually empty, but if `pb_error: ...` shows up the row landed a 4xx and you should retry that specific id
- Any anomalies (sort weirdness, empty hits that should have matched, etc.)
---
## 10. Test fixtures (use to validate before touching real data)
Test catalogue rows: `brand="TEST_GRINDER"` — 5 rows (`MAK-AG-125-9558`, `BOS-AG-125-GWS850`, `DEW-AG-125-DWE4257`, `MAK-AG-125-9558HD`, `GEN-DISC-125-CUT`). All supplier `q6hpwbxsicyoiuu`.
Test quote `7lvyrbdmftr3arx` (Q1782862717620): 3 items, mix of Tier 1 / Tier 2-3 / no-match. The `circular saw blade 184mm` row should produce Tier 3 with 0 hits and a "no catalogue match" flag.
Test quote `7h1w44755zkvknw`: has a junk row with `description="test"` that produces 5 false-positive Tier 3 hits — must NOT auto-fill.
Run the matcher against the test fixtures first. Confirm the cascade produces the expected outcomes before running on real data. Use `dry_run: true` for the validation pass — zero writes, real diagnostics.