initial commit
This commit is contained in:
285
src/lib/assistant/CLAUDE.md
Normal file
285
src/lib/assistant/CLAUDE.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# CLAUDE.md — `src/pages/nearle/assistant/`
|
||||
|
||||
Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `DoormileAI/`). Read this before touching either.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
An in-console Q&A assistant that answers operator questions about live data — "how many orders today", "morning batch orders", "how many riders are active" — by calling the same API functions every other page in this console already uses. Product name is **Doormile AI**, subtitle **Operations Copilot**. Not a standalone page: it lives as a **right-side slide-over** opened from a header icon.
|
||||
|
||||
- **Mounted in**: `src/layout/MainLayout/AppTopNav.js` — a single `<DoormileAITrigger />`. There is **no route and no sidebar entry** for this feature — don't add one back. If you're tempted to give it a full page, re-read §2 first; that was tried and deliberately reverted.
|
||||
- **`intents.js`** — all data logic: the intent catalog, keyword/phrase matching, and the API calls that produce answers. The UI never fetches.
|
||||
- **`DoormileAI/`** — all UI:
|
||||
- `index.js` — the trigger button; owns open/closed state and returns focus to itself on close.
|
||||
- `AIPanel.js` — the portal, scrim, slide-over, focus/Escape handling, message state, history persistence, and the ask() flow.
|
||||
- `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
|
||||
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
|
||||
- `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline.
|
||||
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
|
||||
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
|
||||
- `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
|
||||
- `pageContext.js` — route → context label + suggested questions.
|
||||
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
|
||||
|
||||
### UI rules that are load-bearing, not cosmetic
|
||||
|
||||
- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot.
|
||||
- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message.
|
||||
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
|
||||
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
|
||||
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
|
||||
- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
|
||||
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
|
||||
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why this is deterministic, not LLM-based
|
||||
|
||||
This was a deliberate, explicit product decision (not a technical limitation worked around silently): **this app has zero backend of its own** — confirmed exhaustively (no `server/`, no Firebase Cloud Functions, no `firebase-admin`/`firebase-functions` dependency, `Dockerfile` just serves a static CRA build via nginx). An LLM call needs an API key held server-side; there is nowhere in this repo's infrastructure to put one without shipping it to the browser.
|
||||
|
||||
Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no.
|
||||
|
||||
### RAG — rejected for the data, later built for the ROUTING
|
||||
|
||||
RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.**
|
||||
|
||||
What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced:
|
||||
|
||||
```
|
||||
question → embed → Chroma → intentId + confidence → the SAME run() → live API call
|
||||
```
|
||||
|
||||
Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase.
|
||||
|
||||
Three rules that must hold:
|
||||
|
||||
- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today.
|
||||
- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values.
|
||||
- **Write intents need high confidence.** A semantic near-miss must never open a create form.
|
||||
|
||||
---
|
||||
|
||||
## 3. The intent pattern (`intents.js`)
|
||||
|
||||
Each entry in `INTENTS` is:
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'someIntent',
|
||||
label: 'Human-readable description — e.g. "example phrasing"',
|
||||
match: (text) => params | null, // does this intent apply? extract params or refuse
|
||||
run: async (params) => ({ headline, detail, sourceCalls }) | null // real answer, or "couldn't resolve"
|
||||
}
|
||||
```
|
||||
|
||||
- `answerQuestion(text)` walks `INTENTS` **in order** and returns the first intent whose `match` recognises the text **and** whose `run` resolves to a non-null result. `run` returning `null` means "the pattern matched but couldn't be resolved" (e.g. no tenant name in the question actually matched a real tenant) — the loop falls through to the next intent rather than answering with a guess.
|
||||
- `sourceCalls` feeds `<ChatToolCalls>` behind the per-message "Sources" disclosure in `AIMessage.js` — every answer can still show which API was queried and what came back, so an operator can verify it wasn't invented. It is collapsed by default for visual quiet; **do not remove it**, that disclosure is the verifiability contract.
|
||||
- `metric` and `stats` (optional) drive the panel's headline number and breakdown grid. `stats` comes from `statusStats()`, which tallies the *same* `mapBookingStatusToDeliveryStatus` classification as the prose `headline`, so the sentence and the cards can never disagree.
|
||||
- Every `run` calls a real function from `pages/api/api.js` / `pages/api/doormileApi.js`. **Never fabricate a number** — if no existing function covers a question, either add a new intent that calls a real endpoint, or leave the question unanswered (falls through to the "I can't answer that one yet" state in `AIPanel.js`). A wrong number from this bot is worse than no answer.
|
||||
|
||||
### Coverage grows with the console, not ahead of it
|
||||
|
||||
The catalog covers orders/bookings, riders, tenants, and the fleet/ops resources (hubs, vehicles, tripsheets, exceptions, app users, customers, pricing, consignments, partners, competitor branches, carrier pricing). When a new admin resource gets its own page in this console, add a matching intent here too — and **always call the exact same `getX()` function that page's own table already calls** (e.g. `hubStatus` calls `getHubs()`, the same function `hubs.js` uses). Never write a bespoke fetch for the bot. This is what keeps the bot's numbers live and in agreement with what the corresponding page shows — the whole point of not hand-rolling a separate data path.
|
||||
|
||||
### Composite questions — `orderQuery`
|
||||
|
||||
`orderQuery` (ordered 3rd, ahead of `riderCounts`) is the one intent that composes filters: status x batch x tenant x rider, plus rankings ("top 5 tenants by orders"). Everything else in the catalog answers exactly one dimension and discards the rest of the sentence.
|
||||
|
||||
**It claims a question only when two or more of status/batch/tenant/rider are present, or a ranking is asked for.** A date is deliberately NOT counted as a dimension — every intent already handles dates, and counting it re-routed four working questions ("how many cancelled orders today") away from the intents that answer them better. If you widen this matcher, re-run the routing probe first; over-claiming here silently changes answers across the whole catalog.
|
||||
|
||||
`run` returns `null` when a named tenant or rider doesn't resolve, so an unrecognised name falls through rather than having its filter silently dropped — which is the exact bug this intent exists to fix.
|
||||
|
||||
Entity names resolve through `bestNameMatch`, which is bidirectional (the question may name a shorter or longer form than the record) and prefers the longest match, so "Acme" can't beat "Acme Foods" when both exist.
|
||||
|
||||
### Beyond single-question matching
|
||||
|
||||
A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, all still deterministic (no LLM):
|
||||
|
||||
- **Typo tolerance** — `correctTypos()` runs once before matching, correcting misspelled domain keywords (length ≥5, Levenshtein distance ≤1/≤2) against a fixed `KEYWORD_VOCAB`. It never touches order IDs, tenant names, or short words — only known keywords get "corrected," so it can't invent a wrong one.
|
||||
- **Richer dates** — `explicitDateFromWords` (DD/MM/YYYY, ISO), `weekdayFromWords` (most recent past occurrence of a named day), and `rangeFromWords` (this/last week, this/last month, explicit "from X to Y") feed `dayFromWords`/`rangeFromWords`. Still a fixed vocabulary, not a date-parsing library — an unrecognised phrase falls back to today, never a guessed date.
|
||||
- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue".
|
||||
- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched.
|
||||
- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up.
|
||||
- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale.
|
||||
- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all.
|
||||
- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent.
|
||||
|
||||
### Ordering and cross-domain guards — read before adding an intent
|
||||
|
||||
A real bug shipped here once: `statusBreakdown` matched the word "active" (a valid order status), so "how many riders are active today" was swallowed by the order-status intent and called `getBookings` instead of `getallridersummary` — because `statusBreakdown` sat earlier in `INTENTS` than `riderCounts` and its `run` never returns `null` (it always finds *some* count, even 0), so it never yielded.
|
||||
|
||||
The fix, and the rule going forward:
|
||||
|
||||
1. **Domain-specific intents (rider, tenant) are ordered near the top**, ahead of the generic order/status/date intents, so an unambiguous keyword like "rider" always wins first-match.
|
||||
2. **Generic intents explicitly refuse to match on another domain's keyword**, via helper guards like `mentionsRiders(text)` at the top of their `match`. This is deliberately redundant with (1) — if someone reorders `INTENTS` later without noticing the significance, the guards still hold.
|
||||
|
||||
If you add a new intent whose trigger words could plausibly appear in an unrelated intent's question (status words, "for", generic nouns), do both: place it appropriately in the order, and add a guard to anything downstream it could shadow — don't rely on ordering alone.
|
||||
|
||||
### Date/batch/status vocabulary — reuse, don't reinvent
|
||||
|
||||
- **Batch bucketing** (`morning`/`afternoon`/`evening`) comes from `src/utils/batchBucket.js`, extracted from `Dispatch.js`/`deliveries.js`'s canonical model (see `dispatch/CLAUDE.md` §1). Bucketing on anything other than `orderdate` (a booking's `createdat`) will disagree with what those two pages show — don't reintroduce `expecteddeliverytime`/`assigntime` bucketing here, they were both tried and rejected for the same reasons documented there.
|
||||
- **Order status classification comes from `utils/orderStatusGroups.js`**, which is the SAME match set the Orders page's tabs count with (`orders.js` imports `statusesInGroup` for its `ORDERS_STATUS_TABS`). Use `groupForBookingStatus` / `isInGroup` / `statusesInGroup`; don't grow a third definition.
|
||||
- It is deliberately **not** `mapBookingStatusToDeliveryStatus` (api.js), which is the *Deliveries* page's rider-centric taxonomy and keeps `miler_assigned` on `pending`. The two exist on purpose — Orders tracks the operator's action, Deliveries tracks the rider's. Don't merge them; that was tried and reverted per explicit product direction.
|
||||
- The assistant answers order-status questions with the ORDERS taxonomy because that is the screen an operator compares its answers against. A live bug came from the mismatch: the Orders page showed 19 Assigned while the bot said 0.
|
||||
- **Date words** are a fixed, small vocabulary — not a real date-parsing library. Don't guess at "the 5th" style phrasing; an unrecognised date phrase falls back to today rather than to a wrong date.
|
||||
- **State questions vs flow questions — do not default a state question to today.** `mentionsAnyDate(text)` distinguishes "the question named a date" from "we defaulted to one":
|
||||
- *State* ("how many orders are assigned / cancelled") describes the queue **right now** and must be unscoped, because the Orders page's tabs apply no date filter either. Scoping it to orders *created today* is what made the bot answer 0 against a page showing 19.
|
||||
- *Flow* ("how many orders today", revenue, batches) genuinely needs a period and keeps the today default.
|
||||
When a state question is answered unscoped, say so in the detail — the answer must never leave the operator guessing which window it covered.
|
||||
- `GET /admin/bookings` has no server-side date/status/tenant filter, so every intent fetches and filters client-side. It does **not** fetch a single page: `pagesize` is capped at 1000 server-side, so a lone `getBookings(1, 1000)` silently under-reports the moment an account passes 1000 lifetime bookings. Use `fetchBookingsInRange(start, end)` / `fetchBookingsForDay(day)` / `scanBookings()`, which drain pages via `getBookingsPage` up to `MAX_PAGES` and return `{ rows, truncated, scanned, pagesFetched, total }`.
|
||||
- **`truncated` is not optional to handle.** If you write a new intent, run its count through `countPhrase(scan, n)` ("At least 42"), append `truncationNote(scan)` to the detail, and build its audit entry with `scanCall(scan, ...)` — which reports `status: 'error'` when capped so the tool-call strip can't show a green "complete" beside a partial number. An intent that reads `scan.rows` and ignores `scan.truncated` reintroduces exactly the bug this replaced.
|
||||
- **Revenue excludes cancelled orders and is labelled "estimated"** — `revenueOf(rows)` sums every `serviceoptions[].estimatedprice` on non-cancelled rows. It is a quote, not a settled amount; don't relabel it "revenue" flat.
|
||||
- **"Assigned" does not go through the coarse bucket.** `mapBookingStatusToDeliveryStatus` collapses `miler_assigned` into `pending` alongside `pending_pickup` (orders with no rider at all), so `rawStatusFromWords` matches the backend enum directly. Any other question naming a raw enum should do the same rather than being forced into a delivery-status bucket.
|
||||
|
||||
---
|
||||
|
||||
### Customer creation writes to `/admin/tenantcustomers`
|
||||
|
||||
Settled by evidence, not by reading the docs:
|
||||
|
||||
```
|
||||
POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
```
|
||||
|
||||
405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.**
|
||||
|
||||
**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.)
|
||||
|
||||
**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately.
|
||||
|
||||
Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change.
|
||||
|
||||
The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks.
|
||||
|
||||
Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery.
|
||||
|
||||
The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js`
|
||||
|
||||
Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable.
|
||||
|
||||
**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`.
|
||||
|
||||
### The panel drives the conversation, not the router
|
||||
|
||||
`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router.
|
||||
|
||||
Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer.
|
||||
|
||||
### One engine, three flows
|
||||
|
||||
The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker.
|
||||
|
||||
Step entries carry:
|
||||
|
||||
| key | meaning |
|
||||
|---|---|
|
||||
| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. |
|
||||
| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. |
|
||||
| `type: 'text'` | answered through the composer. |
|
||||
| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). |
|
||||
| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. |
|
||||
| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. |
|
||||
| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. |
|
||||
| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. |
|
||||
|
||||
### Two silent-NaN traps that were live
|
||||
|
||||
- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value.
|
||||
- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was.
|
||||
|
||||
`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate.
|
||||
|
||||
### Bulk — a conversation, then one long pass
|
||||
|
||||
Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient.
|
||||
|
||||
**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked.
|
||||
|
||||
**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept.
|
||||
|
||||
**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column.
|
||||
|
||||
### One row pipeline, two inputs
|
||||
|
||||
A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else.
|
||||
|
||||
**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column.
|
||||
|
||||
`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row.
|
||||
|
||||
**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional:
|
||||
- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely.
|
||||
- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197.
|
||||
- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup.
|
||||
|
||||
**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose.
|
||||
|
||||
**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded.
|
||||
|
||||
Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
|
||||
|
||||
### Repeat Runs — `repeatRuns.js` / `repeatFlow.js`
|
||||
|
||||
"Same orders as yesterday." One question (which day), then a pass, then the usual gate.
|
||||
|
||||
**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply.
|
||||
|
||||
**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination:
|
||||
|
||||
| Trap | Seen on |
|
||||
|---|---|
|
||||
| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key |
|
||||
| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` |
|
||||
| pickup pincode no longer served | CityGate refuses at the middleware, before the handler |
|
||||
|
||||
Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale.
|
||||
|
||||
**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key.
|
||||
|
||||
**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number.
|
||||
|
||||
**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders.
|
||||
|
||||
Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round".
|
||||
|
||||
### Assigning a rider — `assignActions.js` / `assignFlow.js`
|
||||
|
||||
The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run.
|
||||
|
||||
**Two endpoints, and they are not interchangeable.** One order → `POST /admin/bookings/:id/assign-miler`. Many orders → `POST /hub/bookings/batch-assign`, which is **the only call that sequences stops** (doormile-flow.md §4): it sends each affected rider's whole active set to the route optimiser and writes step order, per-leg distance and ETA. Assigning ten orders with ten single calls leaves every route unsequenced.
|
||||
|
||||
**⚠ Two different rider IDs on adjacent endpoints.** `assign-miler` takes a **`mileruserid`**; `/admin/milers/:id/notify` keys off a **`milerprofileid`**. Getting it wrong fails silently in both directions — the assign 404s, or the rider is never told. `buildMilerLookup` is the bridge and orders.js already uses it for exactly this; don't grow a second lookup. The assertions cover this specifically because it is invisible in review: both are small integers on the same record.
|
||||
|
||||
**The backend already assigns riders.** Creation publishes `booking.assignment_requested`; a worker picks a rider within 10km on proximity and retries 5× over 10 minutes (§3). Everything here is an **override**, which is why the flow re-reads the booking's current assignee and asks before replacing them. Silently overwriting throws away a better-informed choice and strands a rider who has already been told the job is theirs.
|
||||
|
||||
**The holder lookup happens inside the booking step's `resolve`, not after it.** `advanceFlow` evaluates the keep/replace step's `when` the instant the booking is applied — a lookup landing one tick later means the step is skipped and an already-assigned order is silently reassigned. That was a live bug caught by the assertions.
|
||||
|
||||
Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed.
|
||||
|
||||
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's deliberately out of scope right now
|
||||
|
||||
- **Deleting or cancelling anything.** Creates and rider assignment are built (§3.5); destructive writes are not. Cancelling an order has downstream effects a confirm button doesn't cover. Note that *replacing* an already-assigned rider IS reachable — but only behind an explicit keep-or-replace question naming the current holder, never as a silent overwrite.
|
||||
- **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives.
|
||||
- **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built.
|
||||
- **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started.
|
||||
- **Automated tests for the intent matcher.** The matcher is pure functions (`match`/`run` per intent) and would be straightforward to unit-test, but the project's stated convention is "no tests of consequence, lint is the only gate" (root `CLAUDE.md`). Adding a test framework here is a scope decision for the user, not something to introduce silently.
|
||||
|
||||
---
|
||||
|
||||
## 5. Don'ts
|
||||
|
||||
- Don't re-add a route/sidebar entry for this feature — it's a header slide-over, not a page.
|
||||
- Don't let a new intent's `match` fire without considering what other intents' trigger words it might contain (see §3's ordering rule).
|
||||
- Don't bucket batches or classify statuses with page-local logic — reuse `utils/batchBucket.js` and `mapBookingStatusToDeliveryStatus`.
|
||||
- Don't answer with a number that didn't come from `sourceCalls`-tracked real data — including anything rendered into a `metric` or `stats` card.
|
||||
- Don't surface a raw API error string to the operator. Errors log to `console.error` and render as the polished error state; the toast that used to leak `err.response.data.message` is gone.
|
||||
1185
src/lib/assistant/DoormileAI.css
Normal file
1185
src/lib/assistant/DoormileAI.css
Normal file
File diff suppressed because it is too large
Load Diff
306
src/lib/assistant/RAG_PLAN.md
Normal file
306
src/lib/assistant/RAG_PLAN.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Doormile AI — RAG implementation plan
|
||||
|
||||
Retrieval-augmented routing and document Q&A, backed by a local ChromaDB.
|
||||
|
||||
**Status:** plan only. Nothing here is built.
|
||||
**Prerequisite decision:** where the sidecar runs (see §11).
|
||||
|
||||
---
|
||||
|
||||
## 1. What this changes, and what it deliberately does not
|
||||
|
||||
### The problem being solved
|
||||
|
||||
The assistant routes questions with **34 hand-written regex matchers**. That has a hard vocabulary ceiling, and we hit it repeatedly:
|
||||
|
||||
| Phrase | What went wrong |
|
||||
|---|---|
|
||||
| "cancellation rate" | `\bcancel(led)?\b` doesn't match "cancellation" — the `\b` fails on the following `l` |
|
||||
| "8494948494" (a bare reply) | matched no intent at all; the reply was dropped |
|
||||
| "orders per day this week" | `weekOrders` answered with one number and silently dropped "per day" |
|
||||
| "delivered orders for Acme" | tenant discarded until `orderQuery` was built |
|
||||
|
||||
Every one of those was a regex fix. The next ten will be too. **RAG removes the ceiling** — a new phrasing works because it's *semantically near* an example, not because someone wrote a pattern for it.
|
||||
|
||||
### What RAG must NOT do here
|
||||
|
||||
`assistant/CLAUDE.md` §2 records RAG being considered and rejected, and that reasoning stands **for the data**:
|
||||
|
||||
> "this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions."
|
||||
|
||||
A vector store is a snapshot. "How many orders today" changes every minute. Answering it from embeddings means answering from whenever we last indexed.
|
||||
|
||||
**So: RAG selects the question. The existing API layer still produces every number.**
|
||||
|
||||
```
|
||||
question ──► embed ──► Chroma ──► top-k intent examples ──► intentId + slots + confidence
|
||||
│
|
||||
▼
|
||||
the SAME deterministic run() executes
|
||||
│
|
||||
▼
|
||||
real API call ──► real number
|
||||
```
|
||||
|
||||
The "never fabricate a number" guarantee survives untouched. Nothing in the vector store ever becomes a figure the operator reads.
|
||||
|
||||
### Why this is not blocked by the §2 decision
|
||||
|
||||
§2's blocker was: *an LLM call needs an API key, and a static CRA build has nowhere to put one.*
|
||||
|
||||
This plan uses a **local embedding model running in Node** (`@xenova/transformers`). No key, no network, no per-call cost. The blocker doesn't apply. If we later want a hosted embedding model or a generation step, §2 applies again and needs its own decision.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
docker-compose.yml
|
||||
├── chroma chromadb/chroma:latest :8000 persistent volume ./.chroma
|
||||
└── ai-sidecar node:20 :8787 services/ai
|
||||
|
||||
services/ai/
|
||||
├── package.json own deps — NOT added to the CRA package.json
|
||||
├── index.js Express app: /health, /route, /ask, /reindex
|
||||
├── embed.js MiniLM via @xenova/transformers, cached in-process
|
||||
├── collections.js Chroma client, collection get-or-create
|
||||
├── seed/
|
||||
│ ├── intents.js builds intent_examples from the phrasing catalog
|
||||
│ ├── docs.js chunks the markdown docs
|
||||
│ └── phrasings.json ~20 example phrasings per intent (hand-written)
|
||||
├── eval.js routing accuracy vs the regex baseline
|
||||
└── README.md how to run it locally
|
||||
```
|
||||
|
||||
**The CRA app gains no new dependencies.** The sidecar is a separate package with its own `package.json`, so `react-scripts`, the webpack config and the `resolutions` block in the root `package.json` are untouched (root `CLAUDE.md` §4.3).
|
||||
|
||||
### Ports and env
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chroma | `http://localhost:8000` |
|
||||
| Sidecar | `http://localhost:8787` |
|
||||
| CRA reads | `REACT_APP_AI_URL` (absent → RAG disabled, regex only) |
|
||||
|
||||
`REACT_APP_AI_URL` being unset must be a supported state, not a broken one — that is what keeps the app deployable exactly as it is today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
### Collection `intent_examples`
|
||||
|
||||
One vector per example phrasing. ~34 intents × ~20 phrasings ≈ **700 vectors**. Trivially small; Chroma handles it in memory.
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'statusBreakdown::07',
|
||||
document: 'how many orders got cancelled this week',
|
||||
metadata: {
|
||||
intentId: 'statusBreakdown',
|
||||
domain: 'orders', // orders | riders | tenants | hubs | vehicles | ops | write
|
||||
isWrite: false, // write intents need a higher bar — see §6
|
||||
slotsHint: 'status,range' // documentation only; slots still parsed deterministically
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Collection `console_docs`
|
||||
|
||||
Chunked markdown for genuine document Q&A — "what is CityGate", "why does dispatch reconcile before commit".
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'express-console-api.md::conventions::2',
|
||||
document: '<chunk text>',
|
||||
metadata: {
|
||||
source: 'express-console-api.md',
|
||||
heading: 'Conventions across every endpoint',
|
||||
updatedAt: '2026-08-19'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Chunking:** split on markdown headings, then hard-wrap at ~800 characters with ~100 characters of overlap. Heading path is prepended to each chunk so a chunk carries its own context.
|
||||
|
||||
**Sources to index:** `express-console-api.md`, root `CLAUDE.md`, `src/pages/api/CLAUDE.md`, `src/pages/nearle/assistant/CLAUDE.md`, `src/pages/nearle/dispatch/CLAUDE.md`, `src/pages/nearle/orders/CLAUDE.md`, `ROADMAP.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. The routing contract
|
||||
|
||||
### `POST /route`
|
||||
|
||||
```jsonc
|
||||
// request
|
||||
{ "text": "how many orders got cancelled this week" }
|
||||
|
||||
// response
|
||||
{
|
||||
"intentId": "statusBreakdown",
|
||||
"confidence": "high", // high | medium | low
|
||||
"score": 0.91, // cosine similarity of top-1
|
||||
"margin": 0.19, // top-1 minus top-2 — the honest signal
|
||||
"alternatives": [
|
||||
{ "intentId": "orderRate", "score": 0.72 }
|
||||
],
|
||||
"matchedExample": "how many orders got cancelled this week"
|
||||
}
|
||||
```
|
||||
|
||||
### How confidence is derived — and why margin, not score
|
||||
|
||||
Cosine similarity is **not** a probability of correctness. 0.87 does not mean "87% likely right". What actually carries information is the **margin** between the best and second-best match:
|
||||
|
||||
| Condition | Confidence | Bot behaviour |
|
||||
|---|---|---|
|
||||
| `score ≥ 0.75` and `margin ≥ 0.10` | **high** | route and answer |
|
||||
| `score ≥ 0.60` and `margin ≥ 0.05` | **medium** | route, and name the interpretation in the answer |
|
||||
| otherwise | **low** | **don't guess** — offer the top 2–3 as buttons |
|
||||
|
||||
Thresholds are starting values, tuned in phase 7 against the eval corpus.
|
||||
|
||||
**What the operator sees:** *"Matched: orders by status · high confidence"* — never *"87% sure the answer is 42."* The answer's correctness comes from deterministic execution; the score only describes how sure we are which question was asked. Conflating the two would be exactly the kind of false precision this bot has avoided all along.
|
||||
|
||||
### `POST /ask` (docs)
|
||||
|
||||
```jsonc
|
||||
{ "text": "what is CityGate" }
|
||||
→ { "chunks": [ { "text": "...", "source": "express-console-api.md",
|
||||
"heading": "Conventions", "score": 0.88 } ] }
|
||||
```
|
||||
|
||||
**No generation step.** It returns the source passages with attribution and the panel renders them. Summarising them into new prose would require an LLM — which is §2's blocked decision — and would also let a paraphrase drift from what the doc says.
|
||||
|
||||
---
|
||||
|
||||
## 5. Embedding model
|
||||
|
||||
**`Xenova/all-MiniLM-L6-v2`** via `@xenova/transformers`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Size | ~25 MB, downloaded once, cached on disk |
|
||||
| Dimensions | 384 |
|
||||
| Runs | in-process in Node — no key, no network, no per-call cost |
|
||||
| Speed | ~5 ms per short query on CPU |
|
||||
|
||||
Good enough for short operator phrases, which is the whole workload. Upgrade path if the eval shows it's not: `text-embedding-3-small`. That needs a key, which re-opens §2 — do not do it silently.
|
||||
|
||||
**Important:** queries and documents must be embedded with the *same* model and the same normalisation. A model change means a full re-seed; `seed.js` writes the model name into collection metadata so a mismatch is detectable rather than silently wrong.
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration with the bot
|
||||
|
||||
`answerQuestion(text, context)` gains a routing step **in front of** the existing matcher:
|
||||
|
||||
```js
|
||||
// 1. try semantic routing, but never let it break the bot
|
||||
const routed = await routeViaRag(text).catch(() => null);
|
||||
|
||||
// 2. high/medium confidence → run that intent's own run()
|
||||
if (routed && routed.confidence !== 'low') {
|
||||
const intent = INTENTS_BY_ID[routed.intentId];
|
||||
const params = intent?.match(text) ?? deriveSlots(text, routed);
|
||||
const result = params && (await intent.run(params));
|
||||
if (result) return { ...result, routing: routed };
|
||||
}
|
||||
|
||||
// 3. fall back to the 34 regex intents, exactly as today
|
||||
return matchAndRun(text);
|
||||
```
|
||||
|
||||
### Non-negotiables
|
||||
|
||||
**The regex matcher stays.** It is the fallback when the sidecar is down, `REACT_APP_AI_URL` is unset, or confidence is low. Today's behaviour is the floor — RAG can only improve it, never remove it.
|
||||
|
||||
**Slots stay deterministic.** `rangeFromWords`, `statusFromWords`, `orderStatusGroups`, entity resolution all keep their jobs. Embeddings are good at *"what kind of question is this"* and bad at *"the 14th"*. RAG picks the intent; parsing extracts the values.
|
||||
|
||||
**Write intents need a higher bar.** `createCustomer` and `createOrder` must require **high** confidence AND an explicit verb match. A semantic near-miss must never open a write form. The `isWrite` flag in metadata exists for exactly this check.
|
||||
|
||||
**Timeout.** 400 ms budget on `/route`; past that, fall through to regex. The bot must never feel slower because a container is cold.
|
||||
|
||||
---
|
||||
|
||||
## 7. Evaluation — how we prove it is better
|
||||
|
||||
The existing **40-phrase routing corpus** (`scratchpad/route_probe.mjs`) becomes the regression baseline. But it is not a fair test: those phrases were written *for* the regex matcher and it scores 40/40 on them.
|
||||
|
||||
The real test is a **held-out set of ~60 phrasings neither implementation was tuned against**, written by someone who hasn't read the matchers. Operator language, not developer language: *"anything stuck?"*, *"what's late"*, *"how'd Kumar do"*.
|
||||
|
||||
`eval.js` reports:
|
||||
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| Routing accuracy | correct `intentId` — the headline number |
|
||||
| Coverage | % answered at all (regex's weakness: silent no-match) |
|
||||
| False routes | wrong intent answered confidently — **the number that matters most** |
|
||||
| Write safety | zero write intents triggered by non-write phrasings |
|
||||
| p50 / p95 latency | must stay under the 400 ms budget |
|
||||
|
||||
**Ship criterion:** RAG beats regex on accuracy *and* coverage on the held-out set, with **zero** false write routes. A false route is worse than a no-match — it's the same class of failure as the "19 assigned vs 0" bug.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phases
|
||||
|
||||
| # | Deliverable | Acceptance | Effort |
|
||||
|---|---|---|---|
|
||||
| 0 | **Sidecar hosting decision** (§11) | answered | — |
|
||||
| 1 | `docker-compose.yml`, Chroma up, Express `/health` | `curl :8787/health` → ok, Chroma reachable | 0.5 d |
|
||||
| 2 | `embed.js`, model cached, round-trip verified | same text → identical vector twice | 0.5 d |
|
||||
| 3 | `phrasings.json` — 20 per intent × 34 | seeded, count verified | 1.5 d |
|
||||
| 4 | `POST /route` with confidence + margin | returns correct intent for 20 hand checks | 0.5 d |
|
||||
| 5 | Bot integration + fallback + 400 ms timeout | **kill the sidecar mid-session → bot still answers** | 0.5 d |
|
||||
| 6 | `console_docs` + `POST /ask` + panel rendering | "what is CityGate" returns the right passage | 1 d |
|
||||
| 7 | `eval.js` + held-out set + threshold tuning | report produced, thresholds fixed from data | 1 d |
|
||||
|
||||
**Total ≈ 5.5 days.** Phases 1–5 (≈3.5 d) deliver the whole routing win; 6–7 add docs and proof.
|
||||
|
||||
---
|
||||
|
||||
## 9. Operations
|
||||
|
||||
- **`npm run seed:ai`** rebuilds both collections from scratch. Idempotent.
|
||||
- **Docs drift silently.** Re-seed on any change to an indexed markdown file — a CI step, or a pre-commit hook. A stale doc answer is worse than none, because it looks authoritative.
|
||||
- **Chroma persistence** is a bind-mounted `./.chroma` volume. Add to `.gitignore`.
|
||||
- **Model cache** likewise (`.cache/transformers`).
|
||||
- **Nothing in the sidecar touches the Doormile API.** It only routes text. All data access stays in the browser through the existing typed functions, which keeps the tenant scoping and the bearer token exactly where they are today.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| **First backend in the project** | Dev-only to start. Deploying it is a separate decision with real ops cost. |
|
||||
| Sidecar down → bot dead | Regex fallback + timeout. Tested explicitly in phase 5. |
|
||||
| Semantic near-miss opens a write form | `isWrite` requires high confidence **and** verb match. Zero-tolerance metric in eval. |
|
||||
| Local model too weak on short phrases | Measured in phase 7. Upgrade path exists but re-opens §2. |
|
||||
| Docs go stale | Re-seed in CI. |
|
||||
| Phrasing catalog becomes a second matcher to maintain | It is data, not code — and unlike regexes, near-misses still work. |
|
||||
| Scope creep into generation | Explicitly out (§12). |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions — needed before phase 1
|
||||
|
||||
1. **Where does the sidecar run?**
|
||||
- **(a) Dev-only** — runs on operator machines / a dev box. Zero ops. RAG is an enhancement that's simply absent in production.
|
||||
- **(b) Deployed alongside nginx** — the project gains a backend: hosting, monitoring, a deploy pipeline, an internal network hop. Bigger commitment than the vector DB itself.
|
||||
|
||||
*Recommendation: (a) first.* Prove routing accuracy with real operator language before taking on ops.
|
||||
|
||||
2. **Does `/ask` (docs) ship to operators, or is it internal?** The indexed docs contain engineering notes, including known backend bugs.
|
||||
|
||||
3. **Who writes the held-out eval set?** It has to be someone who hasn't read the matchers, or the test is worthless.
|
||||
|
||||
---
|
||||
|
||||
## 12. Explicitly out of scope
|
||||
|
||||
- **Any generation step.** `/ask` returns source passages with attribution, never paraphrase. Generation needs a hosted model and a key — §2's blocked decision.
|
||||
- **Embedding operational data.** No bookings, riders or customers in the vector store. Numbers come from the API, always.
|
||||
- **Replacing the regex matcher.** It becomes the fallback, permanently.
|
||||
- **Semantic slot extraction.** Dates, statuses and entities stay deterministic.
|
||||
390
src/lib/assistant/ROADMAP.md
Normal file
390
src/lib/assistant/ROADMAP.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Doormile Bot — v3 development plan
|
||||
|
||||
Analysis of the shipped v2 (`BotPanel.js` + `intents.js`, 9 intents) and the staged plan to take it to an advanced operator assistant.
|
||||
|
||||
Authority: `src/pages/nearle/assistant/CLAUDE.md` constrains this work — no route/sidebar (§5), no LLM without a key-location decision (§2), no write action without a propose→confirm gate (§4). This plan does not override any of those; where it touches one, it says so explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Analysis of v2
|
||||
|
||||
### Architecture today
|
||||
|
||||
```
|
||||
answerQuestion(text)
|
||||
└─ for each of 9 INTENTS, in array order
|
||||
├─ intent.match(text) → params | null
|
||||
└─ intent.run(params) → { headline, detail, sourceCalls } | null
|
||||
first match that resolves wins; otherwise "I can't answer that yet"
|
||||
```
|
||||
|
||||
One question maps to exactly one intent. Every data intent bulk-fetches `getBookings(1, 1000)` and filters client-side.
|
||||
|
||||
### Defects, ranked by damage
|
||||
|
||||
#### Tier A — the bot states wrong numbers confidently
|
||||
|
||||
**A1. The 1000-row ceiling is real and undetectable.**
|
||||
`BULK_PAGESIZE = 1000` is not a chosen page size, it is the API's hard cap (`express-console-api.md` → Conventions: "Default 500, cap 1000"). Worse, `getBookings` returns `response.data.data` and discards the envelope's `total`, so no caller can even detect truncation. Past 1000 lifetime bookings every count intent under-reports with no warning, while `sourceCalls` displays `status: 'complete'` beside it. This directly violates the stated promise in CLAUDE.md §3 ("a wrong number from this bot is worse than no answer").
|
||||
|
||||
**A2. Range words are silently downgraded to "today".**
|
||||
Only `revenueTotal` and `weekOrders` call `rangeFromWords`. The other seven use `dayFromWords`, which ignores "this week" and returns today. "How many delivered orders this week" is answered by `statusBreakdown` as *today's* count. The headline says "today", so it is disclosed — but the operator asked something else and got an answer to a different question.
|
||||
|
||||
**A3. `revenueTotal` is mislabelled and includes cancelled orders.**
|
||||
It sums `serviceoptions[0].estimatedprice` over every row in range with no status filter. Cancelled bookings inflate it, only the first service option is counted, and the figure is an *estimate* presented as "Total revenue".
|
||||
|
||||
**A4. "assigned" resolves to the wrong bucket.**
|
||||
`statusFromWords` maps `assigned → 'accepted'`, but `mapBookingStatusToDeliveryStatus` maps the backend's `miler_assigned → 'pending'`. "How many assigned orders today" therefore counts `pickup_scheduled` + `converted_to_consignment` and excludes the orders the operator means.
|
||||
|
||||
#### Tier B — the bot answers a different question
|
||||
|
||||
**B1. `riderCounts` swallows every sentence containing "rider".**
|
||||
`match: (text) => (mentionsRiders(text) ? {} : null)` and its `run` never returns null. "Which rider has order #4821", "how many orders did rider Suresh deliver" and "rider performance this week" all return the same fleet availability summary. This is the mirror image of the bug CLAUDE.md §3 documents as fixed — the guard stopped `statusBreakdown` stealing rider questions, but nothing stops `riderCounts` stealing everything else.
|
||||
|
||||
**B2. `orderLookup`'s fall-through answers a different question entirely.**
|
||||
It searches only page 1. An order not in the most recent 1000 returns `null`, the loop continues, and `totalOrders` answers "142 orders created today" to the question "status of order #9931". Fall-through is right when an intent *mismatched*; it is wrong when the intent matched and the lookup failed.
|
||||
|
||||
**B3. No composite filters.** "Delivered orders for Acme this week" is answered by `statusBreakdown` alone — tenant and range discarded.
|
||||
|
||||
**B4. `resolveTenant` is one-directional substring matching.** The question must contain the tenant's full name. "Orders for acme foods" against tenant "Acme Foods Pvt Ltd" fails, falls through, and `totalOrders` answers with the all-tenant count.
|
||||
|
||||
**B5. `orderIdFromWords` treats any bare 4+ digit run as an order id** — years, pincodes, quantities.
|
||||
|
||||
#### Tier C — architecture
|
||||
|
||||
**C1. No TanStack Query.** CLAUDE.md §7 makes it the rule for all reads. `answerQuestion` calls the API functions raw. Clicking the five suggestion chips issues five separate 1000-row fetches; `tenantCount` issues two sequentially.
|
||||
|
||||
**C2. `sourceCalls` are hand-written after the fact** with `status: 'complete'` hardcoded. They cannot represent a failed or partial call and will drift from the code the moment a `run` is edited. `ChatToolCalls` already supports `'pending' | 'running' | 'complete' | 'error'` plus `duration`, `errorMessage` and `resultDetail` — none used.
|
||||
|
||||
**C3. The two aggregation endpoints are unused.** `GET /admin/reports` (`from`/`to`/`tenantid`/`locationid`/`hubid`, with `by_location`/`by_hub`/`by_tenant`/`by_rider` blocks per `getReports`'s comment) and `GET /admin/dashboard` ("counts + today's numbers") are server-computed, uncapped, and already wrapped in `doormileApi.js`. They are the correct source for every counting question and the answer to A1.
|
||||
|
||||
**C4. No abort or timeout.** A 1000-row fetch cannot be cancelled; the composer is simply disabled.
|
||||
|
||||
#### Tier D — UX
|
||||
|
||||
- **D1.** Suggestion chips are gated on `messages.length === 0`, so they vanish permanently after the first question. No reset control.
|
||||
- **D2.** History dies when the popover closes (accepted in CLAUDE.md §1, but a liability once answers get expensive).
|
||||
- **D3.** Answers are two strings. `detail` truncates at 10 ids with "…and N more" and there is no way to see the rest, and no way to jump to the matching rows on the Orders page.
|
||||
- **D4.** Unanswered questions are dropped — no signal on what to build next.
|
||||
- **D5.** Fixed `PANEL_WIDTH = 400` / `MESSAGES_HEIGHT = 420` raw px.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Target architecture
|
||||
|
||||
Replace one-question-one-intent with a three-stage pipeline:
|
||||
|
||||
```
|
||||
parse(text) → Query pure, no I/O, fully unit-testable
|
||||
resolve(Query) → Dataset cached, paginated, audited, abortable
|
||||
render(Query, Dataset) → Answer headline + blocks + real sourceCalls
|
||||
```
|
||||
|
||||
`Query` is a slot bag, not an intent id:
|
||||
|
||||
```js
|
||||
{
|
||||
subject : 'orders' | 'riders' | 'tenants' | 'revenue' | 'order',
|
||||
metric : 'count' | 'sum' | 'breakdown' | 'lookup' | 'top',
|
||||
filters : { range: {start, end, label}, batch, status, tenantId, hubId, riderId, orderId },
|
||||
groupBy : 'status' | 'batch' | 'tenant' | 'rider' | 'hour' | null,
|
||||
limit : number
|
||||
}
|
||||
```
|
||||
|
||||
Filters compose. "Delivered orders for Acme this week" fills three slots and runs one query instead of picking one of three intents.
|
||||
|
||||
Proposed file layout inside `src/pages/nearle/assistant/`:
|
||||
|
||||
```
|
||||
parse/
|
||||
vocab.js date/range, batch, status, metric, groupBy vocabularies
|
||||
entities.js tenant/rider/hub resolution + fuzzy scoring
|
||||
parseQuery.js text → Query (+ confidence, + unresolved slots)
|
||||
resolve/
|
||||
source.js cached, paginated booking source; reports/dashboard source
|
||||
aggregate.js count / sum / breakdown / top over a normalised row set
|
||||
render/
|
||||
answer.js Query + Dataset → { headline, blocks[], sourceCalls[] }
|
||||
intents.js thin compatibility shim → parseQuery + resolve + render
|
||||
BotPanel.js richer rendering, chips, reset, stop, persistence
|
||||
```
|
||||
|
||||
`intents.js` keeps its exported surface (`answerQuestion`, `EXAMPLE_QUESTIONS`) so `BotPanel.js` and `AppTopNav.js` are unaffected during the swap.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Phased plan
|
||||
|
||||
### Phase 0 — Truth foundation (blocking; ship before any new capability)
|
||||
|
||||
Nothing else matters while the numbers can be wrong.
|
||||
|
||||
| # | Task | Files | Acceptance |
|
||||
|---|---|---|---|
|
||||
| 0.1 | Expose the envelope `total`/`page` from `getBookings` — return `{ rows, total, page }` or add `getBookingsPage`. Keep the existing signature working for `fetchDeliveries`' four callers. | `pages/api/doormileApi.js` | A caller can detect that more rows exist than were returned. |
|
||||
| 0.2 | Build a paginated booking source that drains pages until the oldest row predates the requested range, with a hard page budget. Emits a `truncated` flag when the budget is hit. | `resolve/source.js` | "How many orders today" is correct on an account with >1000 lifetime bookings. |
|
||||
| 0.3 | Route counting/aggregate questions through `getReports(from, to, tenantid, locationid, hubid)` first; fall back to the paginated booking scan only when reports can't answer the shape. | `resolve/source.js` | Counts for a date range come from one server call, not a 1000-row scan. |
|
||||
| 0.4 | Never present a truncated result as complete — if `truncated`, headline reads "at least N" and the tool call carries `status: 'error'` or an `errorMessage`. | `render/answer.js` | Truncation is visible in the answer, not just the console. |
|
||||
| 0.5 | Fix A3: exclude `cancelled` from revenue, sum all `serviceoptions`, relabel as "estimated". | `render/answer.js` | "Total revenue today" excludes cancelled and says "estimated". |
|
||||
| 0.6 | Fix A4: align status synonyms with `BOOKING_STATUS_TO_DELIVERY_STATUS`. "assigned" → the bucket `miler_assigned` actually lands in. | `parse/vocab.js` | "Assigned orders today" matches what the Orders page shows for the same filter. |
|
||||
| 0.7 | Fix B2: when an intent matched but its lookup failed, answer "I couldn't find order X" — do not fall through to a broader intent. | `resolve/` + `render/` | Asking for a nonexistent order never returns a global count. |
|
||||
|
||||
**Risk:** 0.3 depends on the `/admin/reports` response shape, which is documented only in `jupiter2doormile.md` and is not in `express-console-api.md`. Confirm live before building on it; if the shape doesn't carry what's needed, 0.2 alone still fixes A1 at higher cost.
|
||||
|
||||
### Phase 1 — Slot parser (the capability multiplier)
|
||||
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 1.1 | `parseQuery(text) → Query` with independent slot extraction; unrecognised slots stay empty rather than defaulting. | Unit tests over a fixed corpus (Part 5). |
|
||||
| 1.2 | Real relative-date vocabulary: today, yesterday, this/last week, last N days, this month, explicit `DD MMM` and `YYYY-MM-DD`. Anything unparsed → *ask*, don't assume today. | "Delivered orders this week" returns the week, not today. |
|
||||
| 1.3 | Entity resolution with bidirectional + fuzzy matching and an ambiguity path: 0 matches → say so; 1 → use it; 2+ → ask which. | "Orders for acme foods" resolves to "Acme Foods Pvt Ltd". |
|
||||
| 1.4 | Confidence scoring replaces first-match-wins. Below threshold → clarifying question listing what was understood. | "Rider Suresh's orders today" no longer returns a fleet summary (fixes B1). |
|
||||
| 1.5 | Conversation context: carry the last `Query` forward; a follow-up mutates only the slots it names. Reset on explicit "start over" and on an entity switch. | "and yesterday?" / "just for Acme" work as follow-ups. |
|
||||
| 1.6 | Guard B5: a bare number is an order id only with an order-ish trigger word nearby and no date/quantity reading. | "Orders in 2026" is not treated as an id lookup. |
|
||||
|
||||
Coverage after this phase is the product of the slots, not a list of nine — subject × range × status × batch × tenant × rider all compose.
|
||||
|
||||
### Phase 2 — Answers that are objects, not sentences
|
||||
|
||||
| # | Task | Notes |
|
||||
|---|---|---|
|
||||
| 2.1 | `Answer.blocks[]` — typed blocks (`stat`, `table`, `breakdown`, `link`) rendered by `BotPanel`. | Replaces the two-string shape. |
|
||||
| 2.2 | Result table for row-returning answers: `Table` + `StatusBadge` cells instead of "…and N more". | Reuse `components/nearle_components/StatusBadge`. |
|
||||
| 2.3 | Deep link — every answer carries the filter state that produced it, with a button that navigates to the Orders/Deliveries page pre-filtered. | The single biggest usability jump: answer → action. |
|
||||
| 2.4 | Breakdown answers via `groupBy` (by status / batch / tenant / rider / hour). | Feeds off `/admin/reports` `by_*` blocks where available. |
|
||||
| 2.5 | New subjects using already-exported functions: `getBookingTrack` (where is order X), `getMilerActivity` / `getMilerSummary` (what has rider X done), `getConsignments`, `getTripsheets`, `getHubs`, `getVehicles`. | No new endpoints needed. |
|
||||
| 2.6 | Real `sourceCalls`: emitted by the fetch layer, streaming `pending → running → complete/error`, with `duration` and `errorMessage`. | `ChatToolCalls` already supports all four states. |
|
||||
|
||||
### Phase 3 — Panel UX
|
||||
|
||||
| # | Task |
|
||||
|---|---|
|
||||
| 3.1 | Keep suggestion chips available after the first message (collapse into a `ChatComposerDrawer` or a header affordance), plus a "New chat" reset. |
|
||||
| 3.2 | Persist thread + last `Query` to `sessionStorage` so closing the popover doesn't lose it. |
|
||||
| 3.3 | `onStop` / `isStopShown` on `ChatComposer` wired to an `AbortController` through the fetch layer. |
|
||||
| 3.4 | `ChatSystemMessage` for context resets, day dividers and truncation notices. |
|
||||
| 3.5 | `ChatLayoutScrollButton` + `useChatNewMessages` for long threads. |
|
||||
| 3.6 | `useTriggerMenu` + `ChatComposerTokenElement`: `@tenant` / `@rider` / `/` commands so an operator *picks* a real entity instead of relying on fuzzy matching. Directly de-risks 1.3. |
|
||||
| 3.7 | Replace raw `PANEL_WIDTH`/`MESSAGES_HEIGHT` px with tokens; keyboard/focus check inside the `Popover` (Escape currently closes the panel mid-typing). |
|
||||
| 3.8 | Log unanswered questions locally (capped ring buffer) and surface them — this is the backlog for the next intent round. |
|
||||
| 3.9 | i18n the bot's strings into `utils/locales/en.json` like the rest of the app. |
|
||||
|
||||
### Phase 4 — Actions, confirm-gated (needs sign-off)
|
||||
|
||||
CLAUDE.md §4 rules this out today and specifies the shape it must take if built: propose → operator confirms → execute. Plan accordingly:
|
||||
|
||||
1. Parse produces an `Action` (never executed at parse time).
|
||||
2. Render shows exactly what will be submitted — target rows, field values, the endpoint — as a `ChatSystemMessage` with explicit confirm/cancel.
|
||||
3. Execute only on confirm, through the same api.js functions the pages use, honouring the dispatch reconcile rule (root CLAUDE.md §4) and the notify-rider-after-mutation rule (§9).
|
||||
4. Post-action, refetch the related queries and show the new state.
|
||||
|
||||
Safe first candidates: `notifyMiler` (broadcast to a rider), `cancelBooking` (single, with confirm). Deliberately last: order creation — CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed.
|
||||
|
||||
### Phase 5 — LLM as parser only (BLOCKED on a decision)
|
||||
|
||||
CLAUDE.md §2 records that this was raised and rejected because there is nowhere to hold a key — no backend, static nginx build. That reasoning still stands, so this phase is blocked, not dismissed. If the key question is ever answered, the correct shape is narrow:
|
||||
|
||||
- The model does **slot extraction only** — text in, a validated `Query` JSON out via tool-use / structured output. It never produces a number, a row, or a sentence the operator reads as fact.
|
||||
- Deterministic code still executes every fetch and every calculation.
|
||||
- Slots that don't resolve against real tenants/hubs/riders are rejected and the regex parser runs as fallback.
|
||||
|
||||
This preserves the "no fabricated numbers" guarantee exactly, while removing the vocabulary ceiling. Note it also makes Phase 1 the fallback path rather than dead work.
|
||||
|
||||
### Phase 6 — Proactive
|
||||
|
||||
Once the query layer is trustworthy: watch for conditions rather than waiting to be asked — "14 morning-batch orders unassigned, 30 minutes to cutoff", "rider X offline mid-route". Surfaces as a badge on the bot icon and a `ChatSystemMessage`. Ties into the existing FCM path.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Decisions needed
|
||||
|
||||
1. **`/admin/reports` response shape** — confirm live. Blocks task 0.3, which is the cheap fix for the 1000-row problem.
|
||||
2. **Write actions** — in scope for this round, or stays read-only? Blocks Phase 4 entirely.
|
||||
3. **LLM key location** — unchanged from CLAUDE.md §2? Blocks Phase 5.
|
||||
4. **History persistence** — `sessionStorage` (dies with the tab) or Redux + `localStorage` (survives)? Affects 3.2.
|
||||
5. **Tenant-scoped logins** — should the bot say "across your tenant" when the token carries a tenantid, rather than implying global figures?
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Regression corpus
|
||||
|
||||
Build this as a fixture the parser is tested against; every row is a question the bot must either answer correctly or explicitly decline.
|
||||
|
||||
| Question | Must produce |
|
||||
|---|---|
|
||||
| how many orders today | count, orders, today |
|
||||
| how many orders this week | count, orders, 7-day range (currently → today) |
|
||||
| how many delivered orders this week | count + status + range (currently drops range) |
|
||||
| delivered orders for Acme this week | count + status + tenant + range (currently drops two) |
|
||||
| morning batch orders yesterday | count + batch + day |
|
||||
| how many riders are active | rider availability |
|
||||
| which rider has order #4821 | order lookup → rider (currently fleet summary) |
|
||||
| how many orders did rider Suresh deliver today | rider activity (currently fleet summary) |
|
||||
| status of order #9931 (nonexistent) | "couldn't find it" (currently a global count) |
|
||||
| orders in 2026 | not an id lookup |
|
||||
| total revenue today | estimated, cancelled excluded |
|
||||
| how many assigned orders today | must agree with the Orders page |
|
||||
| and yesterday? (follow-up) | previous query, day shifted |
|
||||
| how many tenants | tenant count |
|
||||
| where is order DM-BK-123 | tracking |
|
||||
| top 5 tenants by orders this week | breakdown + limit |
|
||||
| unassigned orders right now | pending count |
|
||||
| how many orders (account with >1000 bookings) | correct, or explicitly "at least N" |
|
||||
|
||||
---
|
||||
|
||||
# Part 6 — Capability levels
|
||||
|
||||
A different cut from the phases above: not *how* to build it, but *what the bot
|
||||
could do*, ordered by how much has to exist underneath.
|
||||
|
||||
Baseline: `doormileApi.js` exports **96 functions, 37 of them reads**. The bot
|
||||
calls **13** — all list endpoints. Every level below L6 is built from functions
|
||||
that already exist and are already used by some page in this console.
|
||||
|
||||
---
|
||||
|
||||
## L0 — Foundation (not a feature; blocks everything)
|
||||
|
||||
Every count the bot gives is capped at `getBookings(1, 1000)` — page 1, at the
|
||||
API's hard cap — and `getBookings` discards the envelope's `total`, so
|
||||
truncation is undetectable. Fix pagination, surface `total`, route counts
|
||||
through `GET /admin/reports`, and say "at least N" when truncated.
|
||||
|
||||
Until this lands, every level below inherits a silent wrong-number risk.
|
||||
|
||||
---
|
||||
|
||||
## L1 — Counts and lists — **SHIPPED**
|
||||
|
||||
25 intents over 13 API functions. Single-dimension questions, plus typo
|
||||
tolerance, date vocabulary, comparisons, multi-part, and date follow-ups.
|
||||
|
||||
**Ceiling:** one filter at a time. "Delivered orders for Acme this week"
|
||||
answers only the status.
|
||||
|
||||
---
|
||||
|
||||
## L2 — Composable queries
|
||||
|
||||
Slot filling replaces first-match-wins: `subject x range x status x batch x
|
||||
tenant x rider x hub` all compose into one query.
|
||||
|
||||
- "Delivered orders for Acme this week"
|
||||
- "Pending morning-batch orders at Coimbatore hub yesterday"
|
||||
- "Cancelled orders for Acme vs Beta last month"
|
||||
|
||||
**New endpoints needed:** none. Coverage becomes the product of the slots
|
||||
rather than a list of 25.
|
||||
|
||||
---
|
||||
|
||||
## L3 — Entity intelligence
|
||||
|
||||
Deep answers about *one* thing, using the detail endpoints the bot has never
|
||||
touched.
|
||||
|
||||
| Subject | Functions available now | Unlocks |
|
||||
|---|---|---|
|
||||
| Order | `getBooking`, `getBookingTrack` | "Where is DM-BK-123", full status timeline, assigned rider, ETA |
|
||||
| Parcel | `getConsignment`, `getConsignmentLogs`, `trackConsignment` | Scan history, exception trail |
|
||||
| Rider | `getMiler`, `getMilerActivity`, `getMilerLogs`, `getMilerSummary` | "What has Suresh done today" — assigned/completed/rejected/cancelled, riderkms, last ping, live position |
|
||||
| Hub / vehicle | `getHub`, `getVehicle` | Per-site detail, assigned fleet |
|
||||
| Tenant | `getAdminTenant`, `getTenantLocations`, `getTenantCustomers` | Sites, customers, contact |
|
||||
| Exception | `getException` | Why a delivery failed |
|
||||
| Pricing | `quotePricing`, `simulatePricing` | "What would a 5kg parcel from 641001 to 600001 cost?" — a real calculation, not a lookup |
|
||||
|
||||
**Warning:** `riderLookup` today reads `found.status`, `found.phonenumber`,
|
||||
`found.vehicletype`. The confirmed-live miler shape (documented in `api.js`)
|
||||
has none of those — it carries `availabilitystatus`, `phone`,
|
||||
`defaultvehicletype`. Fix against the real shape before extending this level.
|
||||
|
||||
---
|
||||
|
||||
## L4 — Analytics, ranking, anomaly
|
||||
|
||||
Built on `getReports` (`by_tenant` / `by_hub` / `by_rider` blocks),
|
||||
`getLocationsSummary`, `getMilerSummary`.
|
||||
|
||||
- "Top 5 tenants by orders this week"
|
||||
- "Which hub is busiest / which needs attention"
|
||||
- "Which riders have the most cancellations"
|
||||
- "Cancellation rate this week vs last"
|
||||
- "Orders per hour today"
|
||||
|
||||
**"Which orders are delayed" is computable today** — `serviceoptions[0].
|
||||
estimateddeliveryat` exists on a booking, so "past estimated delivery and not
|
||||
yet delivered" is a real filter, not a guess. This is probably the single
|
||||
highest-value question on the list and nothing currently answers it.
|
||||
|
||||
---
|
||||
|
||||
## L5 — Navigation and UI control
|
||||
|
||||
The assistant stops being a read-only oracle and starts driving the console.
|
||||
|
||||
- Every answer carries the filter state that produced it → "Open in Orders"
|
||||
- "Show me cancelled orders" navigates and applies the filter, instead of
|
||||
returning a count
|
||||
- "Open order DM-BK-123" routes to the record
|
||||
|
||||
**Dependency:** the target pages must accept filter state from the URL. Check
|
||||
what `orders.js` supports before committing to this.
|
||||
|
||||
---
|
||||
|
||||
## L6 — Write actions, confirm-gated
|
||||
|
||||
Out of scope per `CLAUDE.md` §4 until signed off, and that doc already fixes
|
||||
the required shape: propose -> show the exact payload and affected rows ->
|
||||
operator confirms -> execute -> refetch. Never straight from match to mutation.
|
||||
|
||||
Tiered by blast radius:
|
||||
|
||||
| Tier | Functions | Risk |
|
||||
|---|---|---|
|
||||
| T1 | `notifyMiler` | Sends a message. Reversible by sending another. |
|
||||
| T2 | `assignMilerToBooking`, `assignVehicleToBooking`, `updateBookingStatus`, `cancelBooking`, `updateExceptionStatus`, `blockMiler` | Single record, real consequence |
|
||||
| T3 | `batchAssignBookings`, `bulkCancelBookings` | Many records at once |
|
||||
| T4 | `createTripsheet`, `addTripsheetItem`, `dispatchTripsheet`, `arriveTripsheet` | Multi-step workflow with ordering rules |
|
||||
| T5 | `createExpressBooking`, `createExpressBookingBulk` | Last. CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed. |
|
||||
|
||||
Must honour the dispatch reconcile rule (root `CLAUDE.md` §4) and the
|
||||
notify-rider-after-mutation rule (§9) inside the executor, so the assistant
|
||||
can't become a backdoor around either.
|
||||
|
||||
---
|
||||
|
||||
## L7 — Proactive / watch
|
||||
|
||||
Stops waiting to be asked. A watch loop evaluates threshold conditions and
|
||||
pushes into the thread plus a badge on the trigger.
|
||||
|
||||
- "14 morning-batch orders unassigned, 30 minutes to cutoff"
|
||||
- "Rider offline mid-route"
|
||||
- "Hub with zero active riders"
|
||||
|
||||
**Dependency:** the notification panel in `AppTopNav` is currently static
|
||||
scaffolding, not wired to a real alert stream.
|
||||
|
||||
---
|
||||
|
||||
## L8 — LLM as parser only — BLOCKED
|
||||
|
||||
`CLAUDE.md` §2 records the decision: no backend, nowhere to hold a key. If
|
||||
that ever changes, the model does **slot extraction only** — text in, a
|
||||
validated Query out. It never produces a number, a row, or a sentence read as
|
||||
fact. Deterministic code still executes every fetch and every calculation, and
|
||||
the L2 parser becomes the fallback.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **L0** — one day, removes the wrong-number risk
|
||||
2. **L4's delay detection** — highest value per unit of work, no new endpoints
|
||||
3. **L2** — multiplies coverage, deletes code
|
||||
4. **L3** — the 24 unused read functions
|
||||
5. **L5** — makes answers actionable
|
||||
6. **L6/L7** — only after sign-off
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTenantCustomer } from '@/api/doormile/endpoints';
|
||||
import { createTenantCustomer } from 'pages/api/doormileApi';
|
||||
|
||||
// ==============================|| Doormile AI — write actions ||============================== //
|
||||
//
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
import { getMilers, assignMilerToBooking } from '@/api/doormile/endpoints';
|
||||
import { notifyMiler } from '@/api/doormile/endpoints';
|
||||
|
||||
const normMilerName = (n) => (n || '').toString().trim().toLowerCase();
|
||||
|
||||
export const buildMilerLookup = (milers) => {
|
||||
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
|
||||
const byProfileId = new Map((milers || []).map((m) => [String(m.milerprofileid), m]));
|
||||
const byName = new Map();
|
||||
(milers || []).forEach((m) => {
|
||||
[m.displayname, m.authname].forEach((n) => {
|
||||
const key = normMilerName(n);
|
||||
if (key && !byName.has(key)) byName.set(key, m);
|
||||
});
|
||||
});
|
||||
return { byUserId, byProfileId, byName };
|
||||
};
|
||||
|
||||
import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi';
|
||||
import { buildMilerLookup, notifyRider } from 'pages/api/api';
|
||||
|
||||
// ==============================|| Doormile AI — assigning a rider ||============================== //
|
||||
//
|
||||
@@ -125,7 +109,7 @@ export const executeAssign = async (booking, rider) => {
|
||||
|
||||
if (rider.milerprofileid) {
|
||||
try {
|
||||
await notifyMiler(rider.milerprofileid);
|
||||
await notifyRider(rider.milerprofileid);
|
||||
notified = true;
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
@@ -240,7 +224,7 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
|
||||
if (rider?.milerprofileid) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await notifyMiler(rider.milerprofileid);
|
||||
await notifyRider(rider.milerprofileid);
|
||||
notified += 1;
|
||||
} catch {
|
||||
// Notification failure never fails the assignment — the order IS
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { scanBookings } from './intents';
|
||||
import { ORDER_STATUS_LABELS, groupForBookingStatus } from '@/lib/orderStatusGroups';
|
||||
import { getStatusMeta } from 'themes/dt/status';
|
||||
import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ASSIGN_STEPS = [
|
||||
// A raw booking carries `status`; `orderstatus` is the mapped
|
||||
// field the LIST pages add. Reading the wrong one made every
|
||||
// option in this dropdown say "Unknown" — seen live.
|
||||
ORDER_STATUS_LABELS[groupForBookingStatus(b.status ?? b.orderstatus)] || 'Unknown',
|
||||
getStatusMeta(b.status ?? b.orderstatus).label,
|
||||
holder ? `held by ${describeRider(holder)}` : 'unassigned'
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Papa from 'papaparse';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from '@/lib/bulkOrderColumns';
|
||||
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns';
|
||||
|
||||
// ==============================|| Doormile AI — bulk order file upload ||============================== //
|
||||
//
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getTenantLocations } from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
|
||||
import { getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createExpressBookingBulk, getAdminPricing } from '@/api/doormile/endpoints';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
|
||||
import { createExpressBookingBulk, getAdminPricing } from 'pages/api/doormileApi';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from 'utils/distance';
|
||||
import { buildOrderPayload } from './orderActions';
|
||||
|
||||
// ==============================|| Doormile AI — bulk order creation ||============================== //
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
getConsignmentLogs,
|
||||
getAdminTenant,
|
||||
getTenantLocations
|
||||
} from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { getRowBatchId, getBatchLabel, BATCHES } from '@/lib/batchBucket';
|
||||
|
||||
} from 'pages/api/doormileApi';
|
||||
import { getalltenants, getallridersummary } from 'pages/api/api';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
// Only the read-only half of actions.js belongs here. Tenant resolution,
|
||||
// payload building and execution live in the panel's submit handler — an
|
||||
// intent must have no route to a write.
|
||||
@@ -34,7 +34,7 @@ import { ASSIGN_TRIGGER } from './assignActions';
|
||||
import { REPEAT_TRIGGER } from './repeatRuns';
|
||||
import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
|
||||
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
|
||||
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from '@/lib/orderStatusGroups';
|
||||
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
|
||||
|
||||
// ==============================|| Doormile Bot — intent catalog ||============================== //
|
||||
//
|
||||
@@ -279,7 +279,7 @@ const batchFromWords = (text) => {
|
||||
// "done"/"completed" alongside "delivered", "declined"/"rejected" alongside
|
||||
// "cancelled". Order matters: more specific phrases are checked before the
|
||||
// broader "delivered" pattern so "undelivered" doesn't false-match it.
|
||||
// Returns an ORDER STATUS GROUP key (@/lib/orderStatusGroups.js) — the same
|
||||
// Returns an ORDER STATUS GROUP key (utils/orderStatusGroups.js) — the same
|
||||
// taxonomy the Orders page's tabs count with. It previously returned api.js's
|
||||
// delivery-status buckets, which is the Deliveries page's rider-centric view,
|
||||
// so "how many assigned orders" never agreed with the Assigned tab.
|
||||
@@ -460,15 +460,6 @@ const summarizeStatuses = (rows) => {
|
||||
// STATUS is the raw palette and has no 'assigned' key; the group taxonomy and
|
||||
// the colour palette are separate concerns and shouldn't be forced to match
|
||||
// names.
|
||||
const STATUS = {
|
||||
pending: '#f59e0b',
|
||||
accepted: '#6366f1',
|
||||
active: '#14b8a6',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
muted: '#94a3b8'
|
||||
};
|
||||
|
||||
const GROUP_COLOR = {
|
||||
pending: STATUS.pending,
|
||||
assigned: STATUS.accepted,
|
||||
@@ -512,7 +503,7 @@ const summarizeByField = (rows, field) => {
|
||||
};
|
||||
|
||||
const resolveTenant = async (text) => {
|
||||
const tenants = (await getAdminTenants()) || [];
|
||||
const tenants = (await getalltenants()) || [];
|
||||
const lower = text.toLowerCase();
|
||||
return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase()));
|
||||
};
|
||||
@@ -737,7 +728,7 @@ const ABOUT_TRIGGER =
|
||||
// (in-transit) — statusBreakdown's match used to fire on that word alone and
|
||||
// never returns null on a match (it always finds *some* count, even 0), so
|
||||
// it never yielded to riderCounts and every rider question silently called
|
||||
// getBookings instead of getMilerSummary. Fixed two ways, deliberately
|
||||
// getBookings instead of getallridersummary. Fixed two ways, deliberately
|
||||
// redundant: riderCounts/tenantList are ordered ahead of the generic
|
||||
// intents below (INTENTS is checked in order, first match wins), AND the
|
||||
// generic intents explicitly refuse to match when "rider" is mentioned, so
|
||||
@@ -1175,7 +1166,7 @@ const INTENTS = [
|
||||
return name ? { name } : null;
|
||||
},
|
||||
run: async ({ name }) => {
|
||||
const tenants = (await getAdminTenants()) || [];
|
||||
const tenants = (await getalltenants()) || [];
|
||||
const match = bestNameMatch(name, tenants, (t) => t.tenantname);
|
||||
if (!match) return null;
|
||||
const [detail, locations] = await Promise.all([
|
||||
@@ -1194,7 +1185,7 @@ const INTENTS = [
|
||||
? { title: 'Locations', items: sites.map((l) => ({ label: l.locationname || `Location ${l.locationid}`, meta: l.pincode })) }
|
||||
: undefined,
|
||||
sourceCalls: [
|
||||
{ name: 'getAdminTenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
|
||||
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
|
||||
{ name: 'getAdminTenant', target: `/admin/tenants/${match.tenantid}`, status: detail ? 'complete' : 'error' },
|
||||
{
|
||||
name: 'getTenantLocations',
|
||||
@@ -1339,8 +1330,8 @@ const INTENTS = [
|
||||
let tenant = null;
|
||||
let tenants = null;
|
||||
if (tenantName || ranking?.groupBy === 'tenant') {
|
||||
tenants = (await getAdminTenants()) || [];
|
||||
sourceCalls.push({ name: 'getAdminTenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` });
|
||||
tenants = (await getalltenants()) || [];
|
||||
sourceCalls.push({ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` });
|
||||
if (tenantName) {
|
||||
tenant = bestNameMatch(tenantName, tenants, (t) => t.tenantname);
|
||||
// Unrecognised name — don't quietly drop the filter and answer a
|
||||
@@ -1457,13 +1448,13 @@ const INTENTS = [
|
||||
label: 'Rider availability — e.g. "how many riders are active"',
|
||||
match: (text) => (mentionsRiders(text) ? {} : null),
|
||||
run: async () => {
|
||||
const summary = await getMilerSummary();
|
||||
const summary = await getallridersummary();
|
||||
return {
|
||||
headline: `${summary?.active ?? 0} active riders (${summary?.available ?? 0} available, ${
|
||||
summary?.onDelivery ?? 0
|
||||
} on a delivery) out of ${summary?.total ?? 0} total.`,
|
||||
detail: `${summary?.inactive ?? 0} inactive/offline.`,
|
||||
sourceCalls: [{ name: 'getMilerSummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
|
||||
sourceCalls: [{ name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -1472,7 +1463,7 @@ const INTENTS = [
|
||||
label: 'Tenant count — e.g. "how many tenants do we have"',
|
||||
match: (text) => (/\btenants?\b/i.test(text) && /\bhow many\b|\blist\b|\ball\b/i.test(text) ? {} : null),
|
||||
run: async () => {
|
||||
const tenants = (await getAdminTenants()) || [];
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return {
|
||||
headline: `${tenants.length} tenant${tenants.length === 1 ? '' : 's'} total.`,
|
||||
metric: { value: tenants.length, label: 'Tenants' },
|
||||
@@ -1484,7 +1475,7 @@ const INTENTS = [
|
||||
.filter((t) => t.tenantname)
|
||||
.map((t) => ({ label: t.tenantname, meta: t.tenantid != null ? `#${t.tenantid}` : undefined }))
|
||||
},
|
||||
sourceCalls: [{ name: 'getAdminTenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
|
||||
sourceCalls: [{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -1870,7 +1861,7 @@ const INTENTS = [
|
||||
// rather than failing the whole answer.
|
||||
const scan = await fetchBookingsInRange(start, end);
|
||||
const rows = scan.rows;
|
||||
const riders = await getMilerSummary().catch(() => null);
|
||||
const riders = await getallridersummary().catch(() => null);
|
||||
return {
|
||||
headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} ${rangeLabel}.`,
|
||||
metric: { value: rows.length, label: `Orders ${rangeLabel}${scan.truncated ? ' (at least)' : ''}` },
|
||||
@@ -1885,8 +1876,8 @@ const INTENTS = [
|
||||
sourceCalls: [
|
||||
scanCall(scan, `${rows.length} orders ${rangeLabel}`),
|
||||
riders
|
||||
? { name: 'getMilerSummary', target: '/admin/milers', status: 'complete', stats: `${riders.total} riders` }
|
||||
: { name: 'getMilerSummary', target: '/admin/milers', status: 'error', errorMessage: 'Rider summary unavailable' }
|
||||
? { name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${riders.total} riders` }
|
||||
: { name: 'getallridersummary', target: '/admin/milers', status: 'error', errorMessage: 'Rider summary unavailable' }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -2019,7 +2010,7 @@ const INTENTS = [
|
||||
stats: statusStats(matched),
|
||||
detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.` + truncationNote(scan),
|
||||
sourceCalls: [
|
||||
{ name: 'getAdminTenants', target: '/admin/tenants', status: 'complete' },
|
||||
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete' },
|
||||
scanCall(scan, `${rows.length} total → ${matched.length} for ${tenant.tenantname}`)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createExpressBooking, getTenantLocations } from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
import { createExpressBooking, getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
|
||||
// ==============================|| Doormile AI — create order ||============================== //
|
||||
//
|
||||
@@ -43,7 +43,7 @@ export const cityGateFor = (pincode) => {
|
||||
return OPEN_CITY_PREFIXES[prefix] || null;
|
||||
};
|
||||
|
||||
export const loadOrderTenants = async () => (await getAdminTenants()) || [];
|
||||
export const loadOrderTenants = async () => (await getalltenants()) || [];
|
||||
|
||||
// Saved pickup sites for a tenant. Each carries address / city / pincode /
|
||||
// coordinates — which is what the payload copies onto the booking, since
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { getAdminPricing, getAdminCustomers, getTenantLocations } from '@/api/doormile/endpoints';
|
||||
import { getAdminTenants } from '@/api/doormile/endpoints';
|
||||
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
|
||||
import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//
|
||||
// Today's behaviour is the floor. This can raise it, never lower it.
|
||||
|
||||
const BASE = import.meta.env.VITE_AI_URL || import.meta.env.REACT_APP_AI_URL || '';
|
||||
const BASE = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AI_URL) || (typeof process !== 'undefined' && process.env?.REACT_APP_AI_URL) || '';
|
||||
const ROUTE_TIMEOUT_MS = 400;
|
||||
|
||||
export const isRagEnabled = () => Boolean(BASE);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getAdminCustomers } from '@/api/doormile/endpoints';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { groupForBookingStatus } from '@/lib/orderStatusGroups';
|
||||
import { getAdminCustomers } from 'pages/api/doormileApi';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { groupForBookingStatus } from 'utils/orderStatusGroups';
|
||||
import { scanBookings } from './intents';
|
||||
import { cityGateFor } from './orderActions';
|
||||
import { priceBulkRows } from './bulkOrderActions';
|
||||
|
||||
@@ -66,39 +66,47 @@ const getPageCached = (page) => {
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const fetchBookingsInRange = async (start, end) => {
|
||||
const firstPage = await getPageCached(1);
|
||||
/**
|
||||
* Drain pages up to the budget and return every row, unfiltered.
|
||||
*
|
||||
* `makeStop` receives page 1's rows once and returns the per-page early-stop
|
||||
* predicate, so a range scan can bail as soon as it has read past its window
|
||||
* while an unfiltered drain simply reads to the budget.
|
||||
*
|
||||
* `fetchPage` is injectable because the page cache below is the wrong layer for
|
||||
* a caller that already has one. A TanStack-managed screen sets its own refetch
|
||||
* interval, and serving it from a 20s module-level cache would silently cap how
|
||||
* fresh that screen can ever be.
|
||||
*/
|
||||
const drainPages = async (makeStop, fetchPage = getPageCached) => {
|
||||
const firstPage = await fetchPage(1);
|
||||
const total = firstPage.total;
|
||||
const pageCount = Math.max(1, Math.ceil(total / BULK_PAGESIZE));
|
||||
const pageSize = Math.max(1, firstPage.rows?.length || 100);
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const budget = Math.min(pageCount, MAX_PAGES);
|
||||
|
||||
const collected = [...firstPage.rows];
|
||||
const descending = isDescendingByCreatedAt(firstPage.rows);
|
||||
const collected = [...(firstPage.rows || [])];
|
||||
const shouldStop = makeStop ? makeStop(firstPage.rows) : () => false;
|
||||
|
||||
/* Newest-first and this page already ends before the window opens → every
|
||||
later page is older still, so there is nothing left to find. */
|
||||
const pageEndsBeforeRange = (rows) => {
|
||||
if (!descending || !rows.length) return false;
|
||||
const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
|
||||
return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
|
||||
};
|
||||
|
||||
let stoppedEarly = pageEndsBeforeRange(firstPage.rows);
|
||||
let stoppedEarly = shouldStop(firstPage.rows) || collected.length >= total;
|
||||
let lastPageFetched = 1;
|
||||
|
||||
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
|
||||
const next = await getPageCached(page);
|
||||
const next = await fetchPage(page);
|
||||
lastPageFetched = page;
|
||||
if (!next.rows.length) {
|
||||
if (!next.rows?.length) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
collected.push(...next.rows);
|
||||
stoppedEarly = pageEndsBeforeRange(next.rows);
|
||||
if (collected.length >= total) {
|
||||
break;
|
||||
}
|
||||
stoppedEarly = shouldStop(next.rows);
|
||||
}
|
||||
|
||||
return {
|
||||
rows: collected.filter((booking) => inRange(booking, start, end)),
|
||||
rows: collected,
|
||||
/* Truncated only if the budget ran out with pages still unread AND the scan
|
||||
did not stop early because it had already passed the window. */
|
||||
truncated: !stoppedEarly && pageCount > budget,
|
||||
@@ -108,6 +116,35 @@ export const fetchBookingsInRange = async (start, end) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchBookingsInRange = async (start, end) => {
|
||||
const scan = await drainPages((firstRows) => {
|
||||
const descending = isDescendingByCreatedAt(firstRows);
|
||||
/* Newest-first and this page already ends before the window opens → every
|
||||
later page is older still, so there is nothing left to find. */
|
||||
return (rows) => {
|
||||
if (!descending || !rows.length) return false;
|
||||
const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
|
||||
return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
|
||||
};
|
||||
});
|
||||
|
||||
/* `scanned` deliberately stays the pre-filter count — it describes how much of
|
||||
the account was read, which is what `truncated` has to be judged against. */
|
||||
return { ...scan, rows: scan.rows.filter((booking) => inRange(booking, start, end)) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Every booking the account has, up to the page budget, with **no date filter**.
|
||||
*
|
||||
* For screens that show the whole list rather than a window. Deliberately not
|
||||
* `fetchBookingsInRange` with sentinel bounds: that path still runs `inRange`,
|
||||
* which drops any row whose `createdat` will not parse. Dropping a row from a
|
||||
* date-scoped answer is defensible; dropping it from "every order" is not — the
|
||||
* row exists and the operator has to be able to see it.
|
||||
*/
|
||||
export const drainBookings = ({ cached = true } = {}) =>
|
||||
drainPages(undefined, cached ? getPageCached : (page) => getBookingsPage(page, BULK_PAGESIZE));
|
||||
|
||||
export const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,14 +22,11 @@
|
||||
// ============================================================================
|
||||
|
||||
export const ORDER_STATUS_GROUPS = {
|
||||
pending: ['pending_pickup'],
|
||||
assigned: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'],
|
||||
// The Orders page has no tab for this one — a booking that is out for
|
||||
// delivery has left the operator's queue. It stays in the map so a status
|
||||
// breakdown accounts for every row rather than silently dropping some.
|
||||
active: ['out_for_delivery'],
|
||||
delivered: ['delivered'],
|
||||
cancelled: ['cancelled']
|
||||
pending: ['pending_pickup', 'pending', 'created', 'new', 'booked', 'order_placed', 'unassigned', ''],
|
||||
assigned: ['converted_to_consignment', 'collected_by_miler', 'miler_assigned', 'pickup_scheduled', 'arrived', 'picked', 'assigned', 'rider_assigned'],
|
||||
active: ['out_for_delivery', 'active', 'in_transit', 'picked_up', 'in_progress'],
|
||||
delivered: ['delivered', 'completed', 'success'],
|
||||
cancelled: ['cancelled', 'rejected', 'failed']
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_LABELS = {
|
||||
|
||||
@@ -5,6 +5,8 @@ export const queryClientInstance = new QueryClient({
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
refetchInterval: 8000, // Auto-refresh every 8 seconds across all pages
|
||||
staleTime: 4000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user