import dayjs from 'dayjs'; import { getBookingsPage, getHubs, getVehicles, getTripsheets, getExceptions, getAppUsers, getAdminCustomers, getAdminPricing, getMilers, getConsignments, getPartners, getCompetitorBranches, getCarrierPricing, getMilerSummary, getMilerActivity, trackConsignment, getConsignmentLogs, getAdminTenant, getTenantLocations } 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. import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions'; import { CREATE_ORDER_TRIGGER } from './orderActions'; 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 'utils/orderStatusGroups'; // ==============================|| Doormile Bot — intent catalog ||============================== // // // Deterministic, not LLM-based (see the plan this was built from — a real // LLM step needs a secret key held server-side, and this app has no backend // of its own; the tradeoff accepted here is broader keyword/synonym coverage // instead). Every answer comes from a real API call against the same // functions the rest of the console uses, never a generated guess. `match` // extracts params from raw text or returns null (this intent doesn't // apply); `run` calls real data and returns { headline, detail, // sourceCalls } — `sourceCalls` feeds so an operator can see // exactly what was queried. // // `run` may also return null (matched the pattern but couldn't resolve // something, e.g. an unrecognised tenant name) — the caller then tries the // next intent in the list rather than answering with a guess. // // GET /admin/bookings has no server-side date/status/tenant filter — every // intent bulk-fetches and filters client-side, the same pattern // fetchDeliveries (api.js) already uses. const BULK_PAGESIZE = 1000; // ---- Typo tolerance -------------------------------------------------------- // A small Levenshtein-distance corrector run once, before any intent match, // so a misspelled domain keyword ("riedrs", "vehcile") still routes to the // right intent. Deliberately narrow: only whole alphabetic words of length // >=5 are ever considered, and only against a fixed vocabulary of the same // keywords the intents below key off — order IDs, tenant names, and short // words are never touched, so this can't quietly rewrite something that was // supposed to stay exact. const levenshtein = (a, b) => { const m = a.length; const n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i += 1) dp[i][0] = i; for (let j = 0; j <= n; j += 1) dp[0][j] = j; for (let i = 1; i <= m; i += 1) { for (let j = 1; j <= n; j += 1) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]); } } return dp[m][n]; }; const KEYWORD_VOCAB = [ 'order', 'orders', 'booking', 'bookings', 'rider', 'riders', 'tenant', 'tenants', 'hub', 'hubs', 'vehicle', 'vehicles', 'tripsheet', 'tripsheets', 'exception', 'exceptions', 'customer', 'customers', 'pricing', 'revenue', 'consignment', 'consignments', 'partner', 'partners', 'competitor', 'competitors', 'carrier', 'status', 'morning', 'afternoon', 'evening', 'pending', 'cancelled', 'delivered', 'active', 'today', 'yesterday', 'week', 'month', 'available' ]; // Words that form a NAMED ENTITY must never be "corrected". A tenant called // "Partnerz" is one edit from "partners" and a rider called "Delivara" is two // from "delivered"; rewriting either makes the name unresolvable, and // orderQuery extracts tenant/rider names from the corrected text, so the // failure is silent — the filter just never matches. // // The original comment here claimed order IDs and tenant names were never // touched. Nothing enforced that; this does. const ENTITY_NAME_SPAN = /\b(?:rider|tenant|hub|vehicle|customer|partner|named|called|for)\s+((?:[A-Za-z][A-Za-z0-9.'-]*\s*){1,4})/gi; const protectedNameWords = (text) => { const keep = new Set(); const re = new RegExp(ENTITY_NAME_SPAN.source, 'gi'); let m = re.exec(text); while (m !== null) { m[1] .split(/\s+/) .filter(Boolean) .forEach((w) => keep.add(w.toLowerCase())); m = re.exec(text); } return keep; }; const correctTypos = (text) => { const keep = protectedNameWords(text); return text .split(/\b/) .map((token) => { const word = token.toLowerCase(); if (keep.has(word)) return token; if (!/^[a-z]+$/.test(word) || word.length < 5 || KEYWORD_VOCAB.includes(word)) return token; // >=6 chars allows distance 2, which is what a single adjacent-letter // transposition ("riedrs" for "riders") costs in plain Levenshtein // distance (two substitutions, not one) — a very common typo shape. const maxDist = word.length >= 6 ? 2 : 1; let best = null; let bestDist = maxDist + 1; KEYWORD_VOCAB.forEach((v) => { if (Math.abs(v.length - word.length) > maxDist) return; const dist = levenshtein(word, v); if (dist < bestDist) { bestDist = dist; best = v; } }); return best && bestDist <= maxDist ? best : token; }) .join(''); }; const TODAY = () => dayjs().format('YYYY-MM-DD'); const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; // Explicit calendar date — "12/08/2026" or "12-08-2026" (DD/MM/YYYY, matching // this console's Indian-locale date convention elsewhere), or ISO // "2026-08-12". Returns null when nothing explicit is found — callers fall // back to relative-word parsing rather than guessing a date shape. const explicitDateFromWords = (text) => { const iso = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/); if (iso) { const d = dayjs(`${iso[1]}-${iso[2]}-${iso[3]}`); return d.isValid() ? d.format('YYYY-MM-DD') : null; } const dmy = text.match(/\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b/); if (dmy) { const d = dayjs(`${dmy[3]}-${dmy[2].padStart(2, '0')}-${dmy[1].padStart(2, '0')}`); return d.isValid() ? d.format('YYYY-MM-DD') : null; } return null; }; // "last Monday" / "on Monday" / bare "Monday" — the most recent day // (including today) that falls on that weekday. Never resolves to a future // date. const weekdayFromWords = (text) => { const lower = text.toLowerCase(); const found = WEEKDAYS.find((w) => lower.includes(w)); if (!found) return null; const targetDow = WEEKDAYS.indexOf(found); let d = dayjs(); for (let i = 0; i < 7; i += 1) { if (d.day() === targetDow) return d.format('YYYY-MM-DD'); d = d.subtract(1, 'day'); } return null; }; // Broader than v1's today/yesterday-only: explicit dates, named weekdays, // and a few more relative-date phrasings. Anything not recognised falls // back to today — this stays a fixed, defensive vocabulary, not a general // date-parsing library; guessing wrong on a date is worse than defaulting // to today. const dayFromWords = (text) => { const explicit = explicitDateFromWords(text); if (explicit) return explicit; const weekday = weekdayFromWords(text); if (weekday) return weekday; if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD'); return TODAY(); }; // Ranges: rolling "this week", calendar "last week", "this/last month", and // an explicit "from to ". Returns null when the text doesn't // ask for a range, so callers can tell "today" and "this week" apart. const rangeFromWords = (text) => { if (/\bthis week\b|\bpast week\b|\blast 7 days\b|\blast seven days\b/i.test(text)) { return { start: dayjs().subtract(6, 'day').format('YYYY-MM-DD'), end: TODAY(), label: 'this week' }; } if (/\blast week\b/i.test(text)) { const start = dayjs().subtract(1, 'week').startOf('week'); const end = dayjs().subtract(1, 'week').endOf('week'); return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD'), label: 'last week' }; } if (/\bthis month\b/i.test(text)) { return { start: dayjs().startOf('month').format('YYYY-MM-DD'), end: TODAY(), label: 'this month' }; } if (/\blast month\b/i.test(text)) { const start = dayjs().subtract(1, 'month').startOf('month'); const end = dayjs().subtract(1, 'month').endOf('month'); return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD'), label: 'last month' }; } // A named calendar year. Requires a preposition so a bare 4-digit run (an // order number) is never read as a year, and only accepts 19xx/20xx. const year = text.match(/\b(?:in|during|for|of)\s+((?:19|20)\d{2})\b/i); if (year) { return { start: `${year[1]}-01-01`, end: `${year[1]}-12-31`, label: year[1] }; } // "from X to Y" — only resolves when BOTH sides parse as a real date/ // weekday; otherwise this is probably "orders from Chennai to Mumbai" and // must fall through untouched. const fromTo = text.match(/\bfrom\b(.+?)\bto\b(.+)/i); if (fromTo) { const start = explicitDateFromWords(fromTo[1]) || weekdayFromWords(fromTo[1]); const end = explicitDateFromWords(fromTo[2]) || weekdayFromWords(fromTo[2]); if (start && end) { const [rangeStart, rangeEnd] = end >= start ? [start, end] : [end, start]; return { start: rangeStart, end: rangeEnd, label: `${dayjs(rangeStart).format('DD MMM')} to ${dayjs(rangeEnd).format('DD MMM')}` }; } } return null; }; const describeDay = (day) => (day === TODAY() ? 'today' : dayjs(day).format('DD MMM YYYY')); const batchFromWords = (text) => { if (/\bmorning\b/i.test(text)) return 'morning'; if (/\bafternoon\b/i.test(text)) return 'afternoon'; if (/\bevening\b/i.test(text)) return 'evening'; return null; }; // Wider synonym coverage than v1 — "in progress"/"on the way" for active, // "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 (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. // // Branch order matters: "not assigned"/"unassigned" must be caught by the // pending branch before the assigned branch sees the word "assigned". const statusFromWords = (text) => { if (/\bpending\b|\bnot\s*assigned\b|\bunassigned\b|\bawaiting\b/i.test(text)) return 'pending'; // `cancel(led)?` did not match \"cancellation\" — the \b after \"cancel\" fails // against the following 'l', so \"cancellation rate\" fell through to a plain // week count. if (/\bcancel(?:s|led|lation|lations)?\b|\bdeclined?\b|\brejected\b/i.test(text)) return 'cancelled'; if (/\bassigned\b|\baccept(ed)?\b/i.test(text)) return 'assigned'; if (/\bactive\b|\bin[- ]?transit\b|\bin\s*progress\b|\bon\s*the\s*way\b|\bout\s*for\s*delivery\b/i.test(text)) return 'active'; if (/\bundeliver/i.test(text)) return null; if (/\bdeliver(ed)?\b|\bdone\b|\bcomplete[d]?\b/i.test(text)) return 'delivered'; return null; }; // True only when the question actually NAMES a date. Distinguishing "named a // date" from "defaulted to today" is what lets a state question ("how many are // cancelled") mean right-now while a flow question ("how many orders today") // still means today. const mentionsAnyDate = (text) => explicitDateFromWords(text) !== null || weekdayFromWords(text) !== null || rangeFromWords(text) !== null || /\btoday\b|\byesterday\b/i.test(text); // ---- Paginated booking source --------------------------------------------- // // Previously this was a single `getBookings(1, 1000)`. That is page ONE at the // API's hard cap (express-console-api.md: pagesize default 500, cap 1000), and // getBookings discards the envelope's `total`, so once an account passed 1000 // lifetime bookings EVERY count and sum in this file silently under-reported // while the sourceCalls line next to it still read `status: 'complete'`. That // is precisely the failure mode CLAUDE.md §3 forbids ("a wrong number from // this bot is worse than no answer"). // // Now: drain pages up to a budget, and when the budget is hit say so instead // of presenting a partial scan as a total. const MAX_PAGES = 12; // 12k rows — generous, but bounded so one question can't hammer the API // Row order is not documented. When page 1 comes back newest-first we can stop // as soon as a page ends older than the range; otherwise we scan to the budget. // Detected per-call rather than assumed, so a backend change degrades to "scan // everything" (slower, still correct) rather than to a wrong answer. const isDescendingByCreatedAt = (rows) => { if (rows.length < 2) return false; const first = parseDoormileTimestamp(rows[0].createdat); const last = parseDoormileTimestamp(rows[rows.length - 1].createdat); return first.isValid() && last.isValid() && first.valueOf() > last.valueOf(); }; const inRange = (b, start, end) => { const d = parseDoormileTimestamp(b.createdat); if (!d.isValid()) return false; const day = d.format('YYYY-MM-DD'); return day >= start && day <= end; }; // Interim request cache. answerMultiPart runs several intents for one // question, and comparisonIntent runs two ranges — each would otherwise // re-drain the same pages. Keyed by page number with a short TTL. // // Deliberately small and local: this is NOT a caching layer to settle on. The // real fix is routing these reads through TanStack Query like the rest of the // console (CLAUDE.md §7), at which point this goes away. const PAGE_CACHE_TTL_MS = 20000; const pageCache = new Map(); const getBookingsPageCached = (page) => { const hit = pageCache.get(page); if (hit && Date.now() - hit.at < PAGE_CACHE_TTL_MS) return hit.promise; const promise = getBookingsPage(page, BULK_PAGESIZE).catch((err) => { // Never cache a failure — the next question should retry, not inherit it. pageCache.delete(page); throw err; }); pageCache.set(page, { at: Date.now(), promise }); return promise; }; // Returns { rows, truncated, scanned, total } — NOT a bare array. Callers must // surface `truncated`; see countPhrase/truncationNote/scanCall below. const fetchBookingsInRange = async (start, end) => { const firstPage = await getBookingsPageCached(1); const total = firstPage.total; const pageCount = Math.max(1, Math.ceil(total / BULK_PAGESIZE)); const budget = Math.min(pageCount, MAX_PAGES); const collected = [...firstPage.rows]; const descending = isDescendingByCreatedAt(firstPage.rows); // Newest-first and page 1 already ends before the window opens → every later // page is older still. 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 lastPageFetched = 1; for (let page = 2; page <= budget && !stoppedEarly; page += 1) { // eslint-disable-next-line no-await-in-loop const next = await getBookingsPageCached(page); lastPageFetched = page; if (!next.rows.length) { stoppedEarly = true; break; } collected.push(...next.rows); stoppedEarly = pageEndsBeforeRange(next.rows); } return { rows: collected.filter((b) => inRange(b, start, end)), // Only truncated if we ran out of budget with pages still unread AND we // didn't stop early because we'd already scanned past the window. truncated: !stoppedEarly && pageCount > budget, scanned: collected.length, pagesFetched: lastPageFetched, total }; }; const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day); // Full scan, no date window — for the per-order lookup, which has to look // everywhere rather than inside a range. The sentinel bounds keep one code // path: they can never trigger the early-stop, so this always drains to the // page budget and reports `truncated` honestly if the id could be further back. // Exported for assignFlow.js, which resolves a typed order number against the // real booking list. It reuses this rather than growing a second scanner with // its own idea of pagination and truncation. export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31'); // A count built on a truncated scan is a floor, not a total — say "at least". const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`; const truncationNote = (scan) => scan.truncated ? `\nScanned the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString( 'en-IN' )} bookings — this is a floor, not a complete count.` : ''; // The audit entry for a scan. Reports the real page count, and flags itself as // `error` when truncated so the tool-call strip can't show a green "complete" // beside a partial number. const scanCall = (scan, note) => ({ name: 'getBookingsPage', target: `/admin/bookings (${scan.pagesFetched} page${scan.pagesFetched === 1 ? '' : 's'} x ${BULK_PAGESIZE})`, status: scan.truncated ? 'error' : 'complete', errorMessage: scan.truncated ? `Scan capped at ${MAX_PAGES} pages; ${scan.total} bookings exist` : undefined, stats: note }); const summarizeStatuses = (rows) => { const counts = {}; rows.forEach((b) => { const s = ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] || groupForBookingStatus(b.status); counts[s] = (counts[s] || 0) + 1; }); return Object.entries(counts) .map(([k, v]) => `${v} ${k}`) .join(', '); }; // Structured counterpart to summarizeStatuses — the SAME tallies, shaped for // the AI panel's metric grid instead of a prose string. Both read the same // groupForBookingStatus classification, so the sentence and the // cards can never disagree, and a card can never show a number that didn't // come from the rows the sourceCalls entry accounts for. // One colour per order status group. `assigned` borrows the 'accepted' hex — // 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 GROUP_COLOR = { pending: STATUS.pending, assigned: STATUS.accepted, active: STATUS.active, delivered: STATUS.delivered, cancelled: STATUS.cancelled }; const statusStats = (rows) => { const counts = {}; rows.forEach((b) => { const g = groupForBookingStatus(b.status); counts[g] = (counts[g] || 0) + 1; }); const ordered = ORDER_STATUS_ORDER.filter((g) => counts[g]).map((g) => ({ label: ORDER_STATUS_LABELS[g], value: counts[g], color: GROUP_COLOR[g] })); // A backend enum the group map has never seen still shows (neutral) rather // than being dropped — an unmapped state should be visible, not silently // absent from the totals. const unmapped = Object.keys(counts) .filter((g) => !ORDER_STATUS_ORDER.includes(g)) .map((g) => ({ label: g, value: counts[g], color: STATUS.muted })); return [...ordered, ...unmapped]; }; // Generic "count per distinct value of `field`" — used by the fleet/ops // intents below (hub type, vehicle type, exception severity, ...) so they // don't each hand-roll the same tally loop. const summarizeByField = (rows, field) => { const counts = {}; rows.forEach((r) => { const key = r?.[field] || 'unknown'; counts[key] = (counts[key] || 0) + 1; }); return Object.entries(counts) .map(([k, v]) => `${v} ${k}`) .join(', '); }; const resolveTenant = async (text) => { const tenants = (await getalltenants()) || []; const lower = text.toLowerCase(); return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase())); }; const bookingLabel = (b) => b.bookingno || `#${b.bookingid}`; // ---- Lateness -------------------------------------------------------------- // // The promised delivery time. deliveries.js and Dispatch.js BOTH document this // field as "stable, but it is the PROMISED DELIVERY slot" and both rejected it // — for BATCH BUCKETING, because a promised ETA is not the wave an order // belongs to. That reasoning doesn't carry over here: an SLA promise that // never gets re-stamped is exactly the right baseline to measure lateness // against. (`assigntime` would be useless here for the same reason it was // useless there — api.js maps it to the last-modified column.) // // ⚠ `serviceoptions` is NOT documented in express-console-api.md; its shape is // inferred from fetchDeliveries. Hence the coverage guard in the intent below: // if no row carries an ETA we say we can't tell, rather than reporting a // reassuring "0 delayed". const etaOf = (b) => b?.serviceoptions?.[0]?.estimateddeliveryat || null; const AT_RISK_MINUTES = 60; // 'closed' — delivered or cancelled; lateness is not a live concern // 'no-eta' — open, but nothing to measure against // 'late' — open and past its promised time // 'at-risk' — open and due within the hour // 'on-time' — open and comfortably ahead const delayState = (b, now) => { const group = groupForBookingStatus(b.status); if (group === 'delivered' || group === 'cancelled') return 'closed'; const raw = etaOf(b); if (!raw) return 'no-eta'; const eta = parseDoormileTimestamp(raw); if (!eta.isValid()) return 'no-eta'; const minutes = eta.diff(now, 'minute'); if (minutes < 0) return 'late'; if (minutes <= AT_RISK_MINUTES) return 'at-risk'; return 'on-time'; }; const formatLateness = (b, now) => { const mins = now.diff(parseDoormileTimestamp(etaOf(b)), 'minute'); if (mins < 60) return `${mins}m late`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ${mins % 60}m late`; return `${Math.floor(hours / 24)}d late`; }; // Late orders carry no hub of their own — a booking has no hubid/hubname field // at all (verified against fetchDeliveries' mapping). The only route to a hub // is the assigned rider: booking.assignedmileruserid matches a miler's // `userid`, and GET /admin/milers/summary is confirmed live to carry // hubname per rider. Unassigned orders genuinely have no hub and are reported // as their own bucket rather than being dropped or guessed at. const hubBreakdownForLate = async (late) => { if (!late.length) return null; const milers = await getMilerSummary().catch(() => null); if (!milers) return null; const list = Array.isArray(milers) ? milers : [milers]; const hubByUser = new Map(list.map((m) => [m.userid, m.hubname]).filter(([userid]) => userid != null)); const counts = {}; late.forEach((b) => { const hub = b.assignedmileruserid ? hubByUser.get(b.assignedmileruserid) || 'Unknown hub' : 'Unassigned'; counts[hub] = (counts[hub] || 0) + 1; }); return Object.entries(counts).sort((a, b) => b[1] - a[1]); }; // Guard so the delay question can't be swallowed by hubStatus ("which HUBS are // experiencing delays" contains "hubs"), statusBreakdown or totalOrders. Same // belt-and-braces rule as mentionsRiders: delayedOrders is ordered ahead of all // three AND they each refuse explicitly, so a later reorder can't regress it. // Trailing verbs/adverbs that get swept up by a greedy name capture — // "rider Suresh deliver today" must resolve to "Suresh", not "Suresh deliver". const NAME_TAIL_WORDS = /\b(deliver(ed|y|ies)?|complete[d]?|assign(ed)?|do|did|does|has|have|is|are|was|were|today|yesterday|now|status|this|last|week|month|year)\b/gi; const cleanEntityName = (raw) => { const cleaned = String(raw || '') .replace(NAME_TAIL_WORDS, ' ') .replace(/[?.,]/g, ' ') .replace(/\s+/g, ' ') .trim(); return cleaned.length >= 2 ? cleaned : null; }; const riderNameFromWords = (text) => { const m = text.match(/\brider\s+(?:named\s+|called\s+)?([A-Za-z][A-Za-z0-9 .'-]{1,40})/i); return m ? cleanEntityName(m[1]) : null; }; const tenantNameCandidate = (text) => { const m = text.match(/\b(?:for|tenant)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9 &.'-]{1,40})/i); return m ? cleanEntityName(m[1]) : null; }; // "top 5 tenants by orders", "orders by hub" — a ranking request rather than a // single count. const rankingFromWords = (text) => { const top = text.match(/\btop\s+(\d{1,2})\b/i); const by = text.match(/\bby\s+(tenant|hub|rider|status|batch)s?\b/i); if (!top && !by) return null; let dimension = by ? by[1].toLowerCase() : null; if (!dimension) { if (/\btenants?\b/i.test(text)) dimension = 'tenant'; else if (/\briders?\b/i.test(text)) dimension = 'rider'; else if (/\bhubs?\b/i.test(text)) dimension = 'hub'; } if (!dimension) return null; return { groupBy: dimension, limit: top ? Math.min(Number(top[1]), 25) : 5 }; }; // Bidirectional, case-insensitive containment — the question may name a // shorter form of the record ("acme foods" vs "Acme Foods Pvt Ltd") or a // longer one. Longest match wins so "Acme" doesn't beat "Acme Foods" when // both exist. const bestNameMatch = (needle, records, nameOf) => { const q = String(needle).toLowerCase(); const hits = records.filter((r) => { const name = String(nameOf(r) || '').toLowerCase(); return name.length > 1 && (name.includes(q) || q.includes(name)); }); if (!hits.length) return null; return hits.sort((a, b) => String(nameOf(b) || '').length - String(nameOf(a) || '').length)[0]; }; // ---- L4 analytics helpers -------------------------------------------------- // Percentage with one decimal, but only when the denominator is big enough to // mean anything. "100% cancelled" off two orders is noise, not a rate. const rateOf = (part, whole) => (whole > 0 ? `${((part / whole) * 100).toFixed(whole < 20 ? 0 : 1)}%` : '—'); // Buckets rows by day (for a range) or by hour (for a single day), so a trend // is answered from the same scan every other count uses. const trendBuckets = (rows, byHour) => { const counts = new Map(); rows.forEach((b) => { const d = parseDoormileTimestamp(b.createdat); if (!d.isValid()) return; const key = byHour ? d.format('HH:00') : d.format('DD MMM'); counts.set(key, (counts.get(key) || 0) + 1); }); return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])); }; // A text bar keeps a trend readable in a 428px panel without a chart library. const sparkBar = (n, max) => '█'.repeat(Math.max(1, Math.round((n / Math.max(max, 1)) * 12))); const TREND_TRIGGER = /\btrend\b|\bper\s+day\b|\bper\s+hour\b|\bby\s+day\b|\bby\s+hour\b|\bdaily\b|\bhourly\b|\bover\s+time\b|\bbreakdown\s+by\s+(?:day|hour)\b/i; const RATE_TRIGGER = /\brate\b|\bpercentage\b|\bpercent\b|\b%\b|\bratio\b/i; const mentionsDelay = (text) => /\bdelay(ed|s|ing)?\b|\blate\b|\boverdue\b|\bbehind\s+schedule\b|\brunning\s+late\b|\bsla\b|\bbreach(ed|es)?\b|\bat\s*risk\b/i.test(text); // Sum across ALL service options. Reading only serviceoptions[0] under-counted // any booking carrying more than one priced option. const bookingCharge = (b) => (Array.isArray(b.serviceoptions) ? b.serviceoptions : []).reduce((sum, o) => sum + (Number(o?.estimatedprice) || 0), 0); // Revenue excludes cancelled bookings — a cancelled order is not money earned, // and counting it inflated every "total revenue" answer. const isCancelled = (b) => groupForBookingStatus(b.status) === 'cancelled'; const revenueOf = (rows) => rows.filter((b) => !isCancelled(b)).reduce((sum, b) => sum + bookingCharge(b), 0); const formatRupees = (n) => new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2 }).format(n || 0); // A specific order id/number mentioned in the question — "#1234", "DM-xxx", // or a bare number 4+ digits long (short numbers are too likely to be part of // an unrelated word/date to treat as an order id). // Returns { id, strong } or null. `strong` marks an unmistakable order // reference (#1234, DM-...); a bare run of digits is WEAK because it could // equally be a year, a pincode or a quantity. The distinction decides what // happens on a miss: a strong id that isn't found is answered "I couldn't find // it", a weak one falls through to the broader intents rather than hard-failing // a question that was never about one specific order. const orderIdFromWords = (text) => { const hash = text.match(/#\s*([A-Za-z0-9-]{3,})/); if (hash) return { id: hash[1], strong: true }; const dmCode = text.match(/\bDM-[A-Za-z0-9-]+\b/i); if (dmCode) return { id: dmCode[0], strong: true }; // Strip explicit dates first so "order status on 12/08/2026" doesn't read // 2026 as an order number. const withoutDates = text.replace(/\b\d{1,2}[/-]\d{1,2}[/-]\d{4}\b/g, ' ').replace(/\b\d{4}-\d{2}-\d{2}\b/g, ' '); const bareDigits = withoutDates.match(/\b\d{4,}\b/); if (bareDigits) return { id: bareDigits[0], strong: false }; return null; }; // Named-entity lookup helper for riderLookup/hubLookup/vehicleLookup below — // pulls the word(s) right after "rider"/"hub"/"vehicle" (optionally preceded // by "named"/"called"/"number"/"no.") as the thing to search for. Alnum only // (vehicle numbers mix letters and digits), stops at a trailing "?" or a // trailing status word. const nameAfterKeyword = (text, keyword) => { const re = new RegExp( `\\b${keyword}\\b\\s+(?:named\\s+|called\\s+|number\\s+|no\\.?\\s+)?([a-z0-9][a-z0-9 .-]{1,40}?)(?:\\s*\\?|\\s+(?:today|now|status)\\b|$)`, 'i' ); const m = text.match(re); return m ? m[1].trim() : null; }; // Requires one of these explicit phrasings before treating a rider/hub/ // vehicle question as a specific-entity lookup rather than an aggregate // count — "how many riders are active" must NOT be swallowed by // riderLookup just because it contains the word "rider". const LOOKUP_TRIGGER = /\b(?:find|where\s+is|status\s+of|search(?:\s+for)?|lookup)\b/i; // Questions ABOUT Doormile or about the assistant itself, rather than about // the data. Kept tight: "how many doormile orders today" mentions the name but // is an orders question, and must not be caught here. // `['’]s|s` covers "what's", "what’s" and the apostrophe-less "whats" people // actually type. const ABOUT_TRIGGER = /\b(?:what|who)(?:['’]s|s|\s+is|\s+are)\s+doormile\b|\b(?:tell\s+me\s+)?about\s+doormile\b|\bdoormile\s*\.\s*com\b|\bwho\s+are\s+you\b|\bwhat\s+(?:can|do)\s+you\s+(?:do|answer|help)\b|\bwhat\s+are\s+you\b/i; // Guards against the class of bug found live: "how many riders are active // today" contains the word "active", which is ALSO a valid order status // (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 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 // the bug can't come back just because someone reorders the array later. const mentionsRiders = (text) => /\brider(s)?\b/i.test(text); // "orders today vs yesterday" / "revenue this week compared to last week" — // a narrow, explicit trigger phrase so this can't misfire on plain aggregate // questions. const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i; // "orders and revenue today" — segments a multi-part question so each half // can be matched independently through the normal INTENTS catalog. const MULTI_SPLIT = /\band\b|,|\+|&/i; const INTENTS = [ { // Ordered ahead of BOTH create triggers. "repeat yesterday's orders" // contains "orders", so createBulkOrders and createOrder would otherwise // claim it and open a blank create instead of recalling the run. id: 'repeatRun', label: 'Repeat a past day’s orders — e.g. "repeat yesterday’s orders"', match: (text) => (REPEAT_TRIGGER.test(text) ? {} : null), run: async () => ({ headline: 'Let’s repeat a previous run.', form: { kind: 'repeatRun', status: 'open' }, sourceCalls: [] }) }, { // Ordered FIRST, ahead of createOrder: "create multiple orders" also // matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger // has to get first refusal or every bulk request opens the single form. id: 'createBulkOrders', label: 'Create several orders from a sheet or a paste — e.g. "bulk upload orders"', match: (text) => (CREATE_BULK_TRIGGER.test(text) ? {} : null), run: async () => ({ headline: 'Upload a sheet or paste your rows and I’ll create them.', form: { kind: 'createBulkOrders', status: 'open' }, sourceCalls: [] }) }, { // Ordered FIRST, above createCustomer: "create an order" contains // "order", which orderLookup / orderQuery / totalOrders all match on. // Like createCustomer this NEVER mutates — the panel intercepts this and // starts a CONVERSATION (orderFlow.js); the mutation fires only when the // operator presses Create on the priced confirmation at the end. id: 'createOrder', label: 'Create an order — e.g. "create an order"', match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null), run: async () => ({ headline: 'Let’s build the order.', form: { kind: 'createOrder', status: 'open' }, sourceCalls: [] }) }, { // ---- The only write-capable intent ------------------------------------- // // Ordered FIRST: "create a customer …" contains the word "customer" // (customerCount matches on it) and usually a 10-digit phone number // (which orderIdFromWords would read as a weak order id). Neither may get // the question. // // This intent NEVER mutates. It returns a proposal — the exact payload it // would submit — and the panel does not send anything until the operator // presses Create. See actions.js. id: 'createCustomer', label: 'Create a customer — e.g. "create a customer"', match: (text) => (CREATE_CUSTOMER_TRIGGER.test(text) ? { text } : null), run: async ({ text }) => { // Opens a FORM rather than collecting fields conversationally. // // The conversational version shipped and immediately failed in use: a // reply of just "8494948494" matches no intent, so answerQuestion routed // it to the "I can't answer that yet" fallback and the number was lost. // Threading a partial draft through `context` did not help, because the // router selects an intent by matching TEXT and a bare phone number // matches nothing. Real inputs remove the parsing step altogether. // // Still no mutation here: this returns the form's initial values, and // the panel submits only when the operator presses Create. const parsed = parseCustomerDraft(text); return { headline: 'Fill this in and I’ll create the customer.', form: { kind: 'createCustomer', status: 'open', initial: parsed }, sourceCalls: [] }; } }, { // Ordered ahead of orderLookup: "assign a rider to DM-BK-…" names an order // number, and orderLookup would otherwise claim it and answer with the // order's details instead of assigning anything. id: 'assignRider', label: 'Assign a rider to an order — e.g. "assign a rider to DM-BK-0D915D43-33705"', match: (text) => (ASSIGN_TRIGGER.test(text) ? { ref: orderIdFromWords(text) } : null), run: async ({ ref }) => { // No order named — the flow will ask for one. if (!ref?.strong) { return { headline: 'Which order should I assign?', form: { kind: 'assignRider', status: 'open' }, sourceCalls: [] }; } const scan = await scanBookings(); const needle = String(ref.id).toLowerCase(); const found = scan.rows.find((b) => String(b.bookingno || '').toLowerCase() === needle || String(b.bookingid) === ref.id); if (!found) { return { headline: `I couldn't find order ${ref.id}.`, detail: truncationNote(scan).trim() || 'Check the order number and try again.', sourceCalls: [scanCall(scan, `no match for "${ref.id}"`)] }; } return { headline: `Assigning ${bookingLabel(found)}.`, form: { kind: 'assignRider', status: 'open', booking: found }, sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`)] }; } }, { id: 'orderLookup', label: 'Everything about one order — e.g. "DM-BK-0D915D43-33705" or "status of order #1234"', match: (text) => { const ref = orderIdFromWords(text); if (!ref) return null; // A STRONG reference (DM-…, #1234) is the whole question — an operator // pasting a booking number shouldn't have to wrap a sentence around it. // A WEAK one (bare digits) still needs an order/booking/status/where // word, or a stray "42" would be read as an order id. if (!ref.strong && !/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null; return { orderId: ref.id, strong: ref.strong }; }, run: async ({ orderId, strong }) => { const scan = await scanBookings(); const needle = orderId.toLowerCase(); const found = scan.rows.find( (b) => String(b.bookingno || '').toLowerCase() === needle || String(b.bookingid) === orderId || String(b.bookingno || '') .toLowerCase() .includes(needle) ); if (!found) { // A weak reference (bare digits) was probably never an order id — // let the broader intents have the question. A strong one (#1234, // DM-...) unmistakably WAS, so say it wasn't found rather than // falling through and answering something else entirely. if (!strong) return null; return { headline: `I couldn't find order ${orderId}.`, detail: scan.truncated ? `Searched the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString( 'en-IN' )} bookings — it may exist further back than I can scan.` : `Searched all ${scan.scanned.toLocaleString('en-IN')} bookings. Check the order number and try again.`, sourceCalls: [scanCall(scan, `no match for "${orderId}"`)] }; } // Same taxonomy the Orders page shows, so a per-order answer and the // tab that order sits under can't disagree. const status = ORDER_STATUS_LABELS[groupForBookingStatus(found.status)] || found.status; // Resolve the rider to a NAME (the booking only carries an id), and pull // the tracking trail. Both are enrichment: either failing degrades that // one line rather than the whole answer. // A booking carries `appcustomerid`, never the recipient's name — so the // customer store is read to turn it into one. All three are enrichment: // any of them failing costs that one line, not the whole answer. // GET /admin/bookings/:id/track is deliberately NOT called. Its response // shape was never confirmed (express-console-api.md lists it as // written-but-unproven), so it contributed 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. const [milers, customers] = await Promise.all([ found.assignedmileruserid ? getMilers().catch(() => null) : Promise.resolve(null), found.appcustomerid ? getAdminCustomers().catch(() => null) : Promise.resolve(null) ]); const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null; const customer = customers ? customers.find((c) => (c.appcustomerid ?? c.id) === found.appcustomerid) : null; const extraCalls = []; if (milers) { extraCalls.push({ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id' }); } if (customers) { extraCalls.push({ name: 'getAdminCustomers', target: '/admin/customers', status: 'complete', stats: customer ? `resolved ${customer.name || customer.phone || `#${found.appcustomerid}`}` : 'no match for customer id' }); } const riderLine = found.assignedmileruserid ? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${ rider?.phone ? ` · ${rider.phone}` : '' }` : 'Not yet assigned to a rider.'; const service = found.serviceoptions?.[0]; const parcels = found.parcels || []; const addr = (a, pin, city) => [a, city, pin].filter(Boolean).join(', '); // Every field that is actually on the record, and nothing that isn't — a // row is omitted rather than rendered as "—", so a blank never reads as // "we checked and it's empty" when it means "this booking has no such // field at all". const items = [ { label: 'Status', meta: `${status}${found.status && found.status !== status ? ` (${found.status})` : ''}` }, { label: 'Rider', meta: riderLine.replace(/^Rider: /, '') }, customer || found.appcustomerid ? { label: 'Customer', meta: customer ? [customer.name || [customer.firstname, customer.lastname].filter(Boolean).join(' '), customer.phone] .filter(Boolean) .join(' · ') : `#${found.appcustomerid} (name unavailable)` } : null, found.pickupaddress ? { label: 'Pickup', meta: addr(found.pickupaddress, found.pickuppincode) } : null, found.deliveryaddress ? { label: 'Drop', meta: addr(found.deliveryaddress, found.deliverypincode, found.deliverycity) } : null, service ? { label: 'Service', meta: [service.servicetype, service.estimatedprice != null ? `₹${Number(service.estimatedprice).toFixed(2)}` : null] .filter(Boolean) .join(' · ') } : null, parcels.length ? { label: `Parcel${parcels.length === 1 ? '' : `s (${parcels.length})`}`, meta: parcels .slice(0, 3) .map((p) => [p.itemcategory, p.itemdescription].filter(Boolean).join(' · ')) .join(' | ') } : null, found.createdat ? { label: 'Created', meta: parseDoormileTimestamp(found.createdat).format('DD MMM YYYY, hh:mm A') } : null, found.updatedat ? { label: 'Last updated', meta: parseDoormileTimestamp(found.updatedat).format('DD MMM YYYY, hh:mm A') } : null, etaOf(found) ? { label: 'Promised by', meta: parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A') } : null, service?.sladueat ? { label: 'SLA due', meta: parseDoormileTimestamp(service.sladueat).format('DD MMM, hh:mm A') } : null, found.consignmentid ? { label: 'Consignment', meta: `#${found.consignmentid}` } : null, found.bookingsource ? { label: 'Source', meta: found.bookingsource } : null, found.notes ? { label: 'Notes', meta: found.notes } : null ].filter(Boolean); return { headline: `Order ${bookingLabel(found)} is ${status}.`, list: { title: 'The full record', numbered: false, items }, // Lateness is the one thing worth saying before the rows are read. detail: delayState(found, dayjs()) === 'late' ? `Past its promised time — it was due ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}.` : undefined, sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls] }; } }, { // Ahead of riderCounts so "where is rider Kumar" resolves to a specific // rider instead of the aggregate count. id: 'riderLookup', label: 'Look up a specific rider — e.g. "where is rider Kumar"', match: (text) => { if (!mentionsRiders(text) || !LOOKUP_TRIGGER.test(text)) return null; const name = nameAfterKeyword(text, 'rider'); return name ? { name } : null; }, run: async ({ name }) => { const riders = (await getMilers()) || []; const needle = name.toLowerCase(); const found = riders.find((r) => String(r.displayname || r.name || '') .toLowerCase() .includes(needle) ); if (!found) return null; return { // Field names matter here: a miler has NO `status`, `phonenumber` or // `vehicletype` field. The real ones are `availabilitystatus`, // `phone` and `defaultvehicletype` (api.js documents the confirmed // live shape). Reading the wrong names meant this answer always said // "status unknown" and never showed a phone or vehicle. headline: `${found.displayname || found.name} — ${found.availabilitystatus || 'availability unknown'}.`, detail: [ found.phone ? `Phone: ${found.phone}` : null, found.defaultvehicletype ? `Vehicle: ${found.defaultvehicletype}` : null, found.hubname ? `Hub: ${found.hubname}` : null ] .filter(Boolean) .join('\n') || undefined, sourceCalls: [{ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: `matched "${name}"` }] }; } }, { // ---- L3: what has this rider actually done ---------------------------- // // orderQuery can already count a named rider's ORDERS, but the rider's own // record carries what the bookings feed cannot: accepted vs rejected, // distance covered, and current duty state. GET /admin/milers/summary is // the confirmed-live shape for that (api.js documents the fields). id: 'riderActivity', label: 'A rider’s own numbers — e.g. "how is rider Kumar doing today"', match: (text) => { if (!mentionsRiders(text)) return null; if ( !/\bperformance\b|\bactivity\b|\bdoing\b|\bstats\b|\bhow\s+is\b|\bhow\s+has\b|\bcompleted\b|\brejected\b|\baccepted\b|\bkms?\b|\bdistance\b/i.test( text ) ) return null; const name = riderNameFromWords(text); if (!name) return null; const range = rangeFromWords(text); if (range) return { name, start: range.start, end: range.end, label: range.label }; const day = dayFromWords(text); return { name, start: day, end: day, label: describeDay(day) }; }, run: async ({ name, start, end, label }) => { const summary = (await getMilerSummary(undefined, start, end)) || []; const list = Array.isArray(summary) ? summary : [summary]; const rider = bestNameMatch(name, list, (m) => m.displayname || m.name); if (!rider) return null; // The activity feed is optional colour — a failure degrades that one // line rather than the whole answer. const activity = rider.milerprofileid ? await getMilerActivity(rider.milerprofileid, start, end).catch(() => null) : null; const assigned = Number(rider.assigned) || 0; const stats = [ { label: 'Assigned', value: assigned, color: STATUS.accepted }, { label: 'Completed', value: Number(rider.completed) || Number(rider.delivered) || 0, color: STATUS.delivered }, { label: 'Rejected', value: Number(rider.rejected) || 0, color: STATUS.cancelled }, { label: 'Cancelled', value: Number(rider.cancelled) || 0, color: STATUS.muted } ]; return { headline: `${rider.displayname || rider.name} — ${rider.availabilitystatus || 'status unknown'} ${label}.`, metric: { value: Number(rider.completed) || Number(rider.delivered) || 0, label: `Completed ${label}` }, stats, detail: [ assigned ? `Acceptance ${rateOf(assigned - (Number(rider.rejected) || 0), assigned)} of ${assigned} assigned.` : null, rider.riderkms ? `${rider.riderkms} km covered.` : null, rider.hubname ? `Hub: ${rider.hubname}` : null, rider.onduty != null ? `On duty: ${rider.onduty ? 'yes' : 'no'}` : null, Array.isArray(activity) && activity.length ? `${activity.length} activity events in range.` : null ] .filter(Boolean) .join('\n'), sourceCalls: [ { name: 'getMilerSummary', target: '/admin/milers/summary', status: 'complete', stats: `matched ${rider.displayname || name}` }, activity ? { name: 'getMilerActivity', target: `/admin/milers/${rider.milerprofileid}/activity`, status: 'complete', stats: `${activity.length} events` } : null ].filter(Boolean) }; } }, { // ---- L3: follow a parcel ---------------------------------------------- id: 'parcelTrack', label: 'Track a parcel — e.g. "track consignment DM-CN-123"', match: (text) => { if (!/\btrack\b|\bconsignment\b|\bparcel\b|\bshipment\b/i.test(text)) return null; const code = text.match(/\b([A-Z]{2,}-[A-Za-z0-9-]+)\b/) || text.match(/#\s*([A-Za-z0-9-]{3,})/); if (!code) return null; return { trackingno: code[1] }; }, run: async ({ trackingno }) => { const found = await trackConsignment(trackingno).catch(() => null); if (!found) { return { headline: `I couldn’t find a consignment for ${trackingno}.`, detail: 'Check the tracking number — it looks like a code but nothing matched.', sourceCalls: [ { name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'error', errorMessage: 'No match' } ] }; } const id = found.consignmentid ?? found.id; const logs = id ? await getConsignmentLogs(id).catch(() => null) : null; const events = Array.isArray(logs) ? logs : []; return { headline: `${trackingno} — ${found.status || 'status unknown'}.`, detail: [found.origin ? `From: ${found.origin}` : null, found.destination ? `To: ${found.destination}` : null] .filter(Boolean) .join('\n'), list: events.length ? { title: 'Scan history', items: events.map((e) => ({ label: e.status || e.event || 'event', meta: e.createdat ? parseDoormileTimestamp(e.createdat).format('DD MMM, hh:mm A') : undefined })) } : undefined, sourceCalls: [ { name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'complete' }, logs ? { name: 'getConsignmentLogs', target: `/admin/consignments/${id}/logs`, status: 'complete', stats: `${events.length} events` } : null ].filter(Boolean) }; } }, { // ---- L3: one tenant in depth ------------------------------------------- id: 'tenantDetail', label: 'A tenant’s detail — e.g. "tell me about Acme Foods"', match: (text) => { if (!/\btenant\b|\babout\b|\bdetails?\s+(?:of|for)\b/i.test(text)) return null; if (/\bhow many\b/i.test(text)) return null; // that's tenantList const name = tenantNameCandidate(text) || riderNameFromWords(text); return name ? { name } : null; }, run: async ({ name }) => { const tenants = (await getalltenants()) || []; const match = bestNameMatch(name, tenants, (t) => t.tenantname); if (!match) return null; const [detail, locations] = await Promise.all([ getAdminTenant(match.tenantid).catch(() => null), getTenantLocations(match.tenantid).catch(() => null) ]); const t = detail || match; const sites = Array.isArray(locations) ? locations : []; return { headline: `${t.tenantname}${t.status ? ` — ${t.status}` : ''}.`, metric: { value: sites.length, label: 'Pickup locations' }, detail: [t.primaryemail ? `Email: ${t.primaryemail}` : null, t.contactno ? `Phone: ${t.contactno}` : null] .filter(Boolean) .join('\n'), list: sites.length ? { title: 'Locations', items: sites.map((l) => ({ label: l.locationname || `Location ${l.locationid}`, meta: l.pincode })) } : undefined, sourceCalls: [ { name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` }, { name: 'getAdminTenant', target: `/admin/tenants/${match.tenantid}`, status: detail ? 'complete' : 'error' }, { name: 'getTenantLocations', target: `/admin/tenants/${match.tenantid}/locations`, status: locations ? 'complete' : 'error', stats: `${sites.length} locations` } ] }; } }, { // ---- L4: trend over time ------------------------------------------------ // // Ordered above the plain counting intents: "orders per day this week" // contains "orders" and a range, so weekOrders would otherwise answer it // with a single number and drop the "per day" entirely. id: 'orderTrend', label: 'Order trend — e.g. "orders per day this week"', match: (text) => { if (mentionsRiders(text) || mentionsDelay(text)) return null; if (!TREND_TRIGGER.test(text)) return null; if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null; const byHour = /\bper\s+hour\b|\bhourly\b|\bby\s+hour\b/i.test(text); const range = rangeFromWords(text); if (range) return { start: range.start, end: range.end, label: range.label, byHour }; const day = dayFromWords(text); return { start: day, end: day, label: describeDay(day), byHour: true }; }, run: async ({ start, end, label, byHour }) => { const scan = await fetchBookingsInRange(start, end); const buckets = trendBuckets(scan.rows, byHour); if (!buckets.length) { return { headline: `No orders to chart ${label}.`, sourceCalls: [scanCall(scan, '0 rows')] }; } const max = Math.max(...buckets.map(([, n]) => n)); const peak = buckets.find(([, n]) => n === max); return { headline: `${countPhrase(scan, scan.rows.length)} order${scan.rows.length === 1 ? '' : 's'} ${label}, peaking at ${max} (${ peak[0] }).`, metric: { value: max, label: `Peak ${byHour ? 'hour' : 'day'} — ${peak[0]}` }, list: { title: byHour ? 'Orders per hour' : 'Orders per day', numbered: false, items: buckets.map(([k, n]) => ({ label: k, meta: `${sparkBar(n, max)} ${n}` })) }, detail: truncationNote(scan).trim() || undefined, sourceCalls: [scanCall(scan, `${scan.rows.length} rows → ${buckets.length} buckets`)] }; } }, { // ---- L4: rates --------------------------------------------------------- id: 'orderRate', label: 'A rate — e.g. "cancellation rate this week"', match: (text) => { if (mentionsRiders(text) || mentionsDelay(text)) return null; if (!RATE_TRIGGER.test(text)) return null; const group = statusFromWords(text); if (!group) return null; const range = rangeFromWords(text); if (range) return { group, start: range.start, end: range.end, label: range.label }; if (mentionsAnyDate(text)) { const day = dayFromWords(text); return { group, start: day, end: day, label: describeDay(day) }; } return { group, start: null, end: null, label: null }; }, run: async ({ group, start, end, label }) => { const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings(); const rows = scan.rows; const matched = rows.filter((b) => isInGroup(b.status, group)); const noun = ORDER_STATUS_LABELS[group].toLowerCase(); const scopeText = label ? ` ${label}` : ''; return { headline: `${rateOf(matched.length, rows.length)} of orders are ${noun}${scopeText}.`, metric: { value: rateOf(matched.length, rows.length), label: `${ORDER_STATUS_LABELS[group]} rate${scopeText}` }, stats: statusStats(rows), detail: `${matched.length} of ${rows.length} orders.` + (rows.length < 20 ? ' Small sample — treat the percentage loosely.' : '') + (label ? '' : '\nCovers every order, not just today.') + truncationNote(scan), sourceCalls: [scanCall(scan, `${matched.length}/${rows.length} ${noun}`)] }; } }, { // ---- Composable order questions ---------------------------------------- // // Every other intent in this file answers ONE dimension and silently // discards the rest of the sentence: "delivered orders for Acme this week" // was answered by statusBreakdown, which had nowhere to put the tenant. // This intent composes status x batch x date x tenant x rider, and also // handles rankings ("top 5 tenants by orders"). // // It deliberately claims a question ONLY when it carries two or more // filter dimensions, or asks for a ranking — precisely the shapes the // single-intent path cannot express. Anything simpler still routes to the // proven intents below, so this adds capability without re-routing what // already works. // // `run` returns null when a named tenant/rider doesn't resolve, so an // unrecognised name falls through rather than being silently ignored. id: 'orderQuery', label: 'Composite order question — e.g. "delivered orders for Acme this week"', match: (text) => { if (mentionsDelay(text)) return null; if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null; const ranking = rankingFromWords(text); const status = statusFromWords(text); const batch = batchFromWords(text); const range = rangeFromWords(text); const dated = mentionsAnyDate(text); const riderName = riderNameFromWords(text); const tenantName = tenantNameCandidate(text); // A date is NOT counted as a dimension. Every intent below already // handles dates, so counting it would make "how many cancelled orders // today" look composite and re-route four working questions away from // the intents that answer them best. What the single-intent path // genuinely cannot express is two or more of // status / batch / tenant / rider at once. const dimensions = [status, batch, riderName, tenantName].filter(Boolean).length; if (!ranking && dimensions < 2) return null; let scope = { start: null, end: null, label: null }; if (range) { scope = { start: range.start, end: range.end, label: range.label }; } else if (dated) { const day = dayFromWords(text); scope = { start: day, end: day, label: describeDay(day) }; } return { status, batch, riderName, tenantName, ranking, ...scope }; }, run: async ({ status, batch, riderName, tenantName, ranking, start, end, label }) => { const sourceCalls = []; // ---- resolve named entities before touching bookings ---- let tenant = null; let tenants = null; if (tenantName || ranking?.groupBy === 'tenant') { 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 // broader question, which is exactly the bug this intent exists to // fix. Fall through instead. if (!tenant) return null; } } let rider = null; let milers = null; if (riderName || ranking?.groupBy === 'rider') { milers = (await getMilers()) || []; sourceCalls.push({ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: `${milers.length} riders` }); if (riderName) { rider = bestNameMatch(riderName, milers, (m) => m.displayname || m.name); if (!rider) return null; } } const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings(); sourceCalls.push(scanCall(scan, `${scan.rows.length} in scope`)); // ---- apply every named filter ---- let rows = scan.rows; if (status) rows = rows.filter((b) => isInGroup(b.status, status)); if (batch) rows = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch); if (tenant) rows = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid)); if (rider) rows = rows.filter((b) => b.assignedmileruserid === (rider.userid ?? rider.milerid)); const scopeText = label ? ` ${label}` : ''; const filterText = [ status ? ORDER_STATUS_LABELS[status].toLowerCase() : null, batch ? getBatchLabel(batch) : null, tenant ? `for ${tenant.tenantname}` : null, rider ? `by ${rider.displayname || rider.name}` : null ] .filter(Boolean) .join(', '); // ---- ranking ---- if (ranking) { const nameFor = (b) => { if (ranking.groupBy === 'tenant') { return (tenants || []).find((t) => Number(t.tenantid) === Number(b.tenantid))?.tenantname || `Tenant #${b.tenantid}`; } if (ranking.groupBy === 'rider') { if (!b.assignedmileruserid) return 'Unassigned'; const m = (milers || []).find((x) => x.userid === b.assignedmileruserid); return m?.displayname || m?.name || `Rider #${b.assignedmileruserid}`; } if (ranking.groupBy === 'status') return ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] || b.status; if (ranking.groupBy === 'batch') return getBatchLabel(getRowBatchId({ orderdate: b.createdat })) || 'No batch'; return 'Unknown'; }; // No hub field exists on a booking (see hubBreakdownForLate) — refuse // rather than invent a grouping. if (ranking.groupBy === 'hub') return null; const counts = {}; rows.forEach((b) => { const k = nameFor(b); counts[k] = (counts[k] || 0) + 1; }); const ordered = Object.entries(counts).sort((a, b) => b[1] - a[1]); const top = ordered.slice(0, ranking.limit); if (!top.length) { return { headline: `No orders to rank${scopeText}.`, sourceCalls }; } return { headline: `Top ${top.length} ${ranking.groupBy}${top.length === 1 ? '' : 's'} by orders${scopeText}: ${top[0][0]} (${ top[0][1] }).`, metric: { value: top[0][1], label: `${top[0][0]} — most orders${scopeText}` }, stats: top.map(([name, n]) => ({ label: name, value: n, color: STATUS.info })), detail: `${ordered.length} ${ranking.groupBy}${ordered.length === 1 ? '' : 's'} with orders${scopeText}.` + (label ? '' : '\nCovers every order, not just today.') + truncationNote(scan), sourceCalls }; } // ---- plain composite count ---- return { headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'}${ filterText ? ` ${filterText}` : '' }${scopeText}.`, metric: { value: rows.length, label: [filterText, label].filter(Boolean).join(' · ') || 'Orders' }, stats: statusStats(rows), list: rows.length ? { title: 'Matching orders', items: rows.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] })) } : undefined, detail: (rows.length ? '' : 'Nothing matched every part of that question.') + (label ? '' : '\nCovers every order, not just today.') + truncationNote(scan), sourceCalls }; } }, { // Ordered early, right after the order-id lookup — "rider(s)" is a // strong, unambiguous domain signal and must win before any of the // generic order-status/date intents below get a chance to misfire on a // shared word like "active". See the mentionsRiders comment above. id: 'riderCounts', label: 'Rider availability — e.g. "how many riders are active"', match: (text) => (mentionsRiders(text) ? {} : null), run: async () => { 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: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }] }; } }, { id: 'tenantList', 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 getalltenants()) || []; return { headline: `${tenants.length} tenant${tenants.length === 1 ? '' : 's'} total.`, metric: { value: tenants.length, label: 'Tenants' }, // Full list, not the first ten. "…and 4 more" left the operator with // no way to see the rest at all. list: { title: 'Tenants', items: tenants .filter((t) => t.tenantname) .map((t) => ({ label: t.tenantname, meta: t.tenantid != null ? `#${t.tenantid}` : undefined })) }, sourceCalls: [{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }] }; } }, { // Ordered high: "which hubs are experiencing delays" contains "hubs" and // would otherwise be answered by hubStatus with a hub inventory, and // "which orders are delayed" contains "orders" and would fall through to // totalOrders. Both now also guard with mentionsDelay. id: 'delayedOrders', label: 'Late and at-risk orders — e.g. "which orders are delayed"', match: (text) => { if (mentionsRiders(text)) return null; if (!mentionsDelay(text)) return null; const range = rangeFromWords(text); if (range) return { start: range.start, end: range.end, label: range.label }; if (mentionsAnyDate(text)) { const day = dayFromWords(text); return { start: day, end: day, label: describeDay(day) }; } // Lateness is a state question — "what is late right now" — so with no // date named it covers every open order, not just today's. return { start: null, end: null, label: null }; }, run: async ({ start, end, label }) => { const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings(); const rows = scan.rows; const now = dayjs(); const buckets = { late: [], 'at-risk': [], 'on-time': [], 'no-eta': [], closed: [] }; rows.forEach((b) => buckets[delayState(b, now)].push(b)); const open = rows.length - buckets.closed.length; const measurable = buckets.late.length + buckets['at-risk'].length + buckets['on-time'].length; const scopeNote = label ? ` ${label}` : ''; // Coverage guard. Reporting "0 delayed" when nothing carries an ETA // would be a falsely reassuring answer about data we simply don't have. if (open > 0 && measurable === 0) { return { headline: "I can't tell which orders are late.", detail: `None of the ${open} open order${ open === 1 ? '' : 's' }${scopeNote} carry a delivery ETA, so there is nothing to measure lateness against.`, sourceCalls: [scanCall(scan, `${rows.length} scanned, 0 with an ETA`)] }; } const late = buckets.late; const hubs = await hubBreakdownForLate(late); return { headline: `${countPhrase(scan, late.length)} order${late.length === 1 ? '' : 's'} running late${scopeNote}.`, metric: { value: late.length, label: `Late${label ? `, ${label}` : ' right now'}` }, list: late.length ? { title: 'Late orders', items: late.map((b) => ({ label: bookingLabel(b), meta: formatLateness(b, now) })) } : undefined, stats: [ { label: 'Late', value: late.length, color: STATUS.cancelled }, { label: 'Due within 1h', value: buckets['at-risk'].length, color: STATUS.pending }, { label: 'On time', value: buckets['on-time'].length, color: STATUS.delivered }, { label: 'No ETA', value: buckets['no-eta'].length, color: STATUS.muted } ], detail: [ late.length ? null : 'Nothing is past its promised delivery time.', hubs ? `By hub: ${hubs.map(([hub, n]) => `${hub} ${n}`).join(', ')}` : null, buckets['no-eta'].length ? `${buckets['no-eta'].length} open order${ buckets['no-eta'].length === 1 ? '' : 's' } have no ETA and are not counted either way.` : null, label ? null : 'Covers every open order, not just today.' ] .filter(Boolean) .join('\n') + truncationNote(scan), sourceCalls: [ scanCall(scan, `${open} open → ${late.length} late, ${buckets['at-risk'].length} at risk`), hubs ? { name: 'getMilerSummary', target: '/admin/milers/summary', status: 'complete', stats: `${hubs.length} hubs with late orders` } : null ].filter(Boolean) }; } }, { // A rare, explicit trigger phrase ("vs"/"versus"/"compare[d] to") — // ordered ahead of every generic order/revenue intent below so a // comparison question can't be swallowed by totalOrders/revenueTotal // (neither of which ever returns null, so whichever gets checked first // wins the whole question). id: 'comparisonIntent', label: 'Compare two periods — e.g. "orders today vs yesterday"', match: (text) => { if (mentionsRiders(text)) return null; if (!COMPARE_TRIGGER.test(text)) return null; const parts = text.split(COMPARE_TRIGGER); if (parts.length < 2) return null; const left = parts[0]; const right = parts.slice(1).join(' '); const leftRange = rangeFromWords(left); const rightRange = rangeFromWords(right); const isRevenue = /\brevenue\b|\bcharges?\b|\bearnings?\b/i.test(text); return { isRevenue, left: leftRange || { start: dayFromWords(left), end: dayFromWords(left), label: describeDay(dayFromWords(left)) }, right: rightRange || { start: dayFromWords(right), end: dayFromWords(right), label: describeDay(dayFromWords(right)) } }; }, run: async ({ isRevenue, left, right }) => { const [leftScan, rightScan] = await Promise.all([ fetchBookingsInRange(left.start, left.end), fetchBookingsInRange(right.start, right.end) ]); const leftRows = leftScan.rows; const rightRows = rightScan.rows; // A comparison across a truncated scan isn't just imprecise, it's // directionally unsafe — one side can be capped and the other not. const truncated = leftScan.truncated || rightScan.truncated; const sourceCalls = [ scanCall(leftScan, `${leftRows.length} (${left.label})`), scanCall(rightScan, `${rightRows.length} (${right.label})`) ]; const caveat = truncated ? '\nBoth sides come from a capped scan — treat the difference as indicative, not exact.' : undefined; if (isRevenue) { const leftTotal = revenueOf(leftRows); const rightTotal = revenueOf(rightRows); const diff = leftTotal - rightTotal; return { headline: `${formatRupees(leftTotal)} (${left.label}) vs ${formatRupees(rightTotal)} (${right.label}) — ${ diff >= 0 ? 'up' : 'down' } ${formatRupees(Math.abs(diff))}.`, detail: caveat, sourceCalls }; } const diff = leftRows.length - rightRows.length; return { headline: `${countPhrase(leftScan, leftRows.length)} order${leftRows.length === 1 ? '' : 's'} (${left.label}) vs ${countPhrase( rightScan, rightRows.length )} order${rightRows.length === 1 ? '' : 's'} (${right.label}) — ${diff >= 0 ? 'up' : 'down'} ${Math.abs(diff)}.`, detail: caveat, sourceCalls }; } }, // Fleet/ops intents below — each is a live count against one more of the // console's own resources (hubs, vehicles, tripsheets, exceptions, app // users, customers, pricing, consignments, partners, competitor branches, // carrier pricing), so the bot's coverage grows the same way the rest of // this console does: as new admin resources get their own page here, add // a matching intent here too, always reading the SAME getX() call that // page's own table uses — never a bespoke fetch. Each trigger word is // domain-unique enough that none of them need a mentionsRiders-style // guard against the order/rider/tenant intents above. { // Ahead of hubStatus so "status of hub Chennai" resolves to one hub // instead of the full list. id: 'hubLookup', label: 'Look up a specific hub — e.g. "status of hub Chennai"', match: (text) => { if (!/\bhub(s)?\b/i.test(text) || !LOOKUP_TRIGGER.test(text)) return null; const name = nameAfterKeyword(text, 'hub'); return name ? { name } : null; }, run: async ({ name }) => { const hubs = (await getHubs()) || []; const needle = name.toLowerCase(); const found = hubs.find((h) => String(h.hubname || '') .toLowerCase() .includes(needle) ); if (!found) return null; return { headline: `${found.hubname} — ${found.status || 'status unknown'}.`, detail: found.address ? `Address: ${found.address}` : undefined, sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `matched "${name}"` }] }; } }, { id: 'hubStatus', label: 'Hub status — e.g. "current hub status"', match: (text) => (/\bhub(s)?\b/i.test(text) && !mentionsDelay(text) ? {} : null), run: async () => { const hubs = (await getHubs()) || []; const active = hubs.filter((h) => String(h.status || '').toLowerCase() === 'active').length; return { headline: `${hubs.length} hub${hubs.length === 1 ? '' : 's'} total, ${active} active.`, metric: { value: active, label: `Active of ${hubs.length} hubs` }, list: { title: 'Hubs', items: hubs.map((h) => ({ label: h.hubname || `Hub #${h.hubid}`, meta: h.status || 'unknown' })) }, sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `${hubs.length} hubs` }] }; } }, { // Ahead of vehicleStatus so "find vehicle TN01AB1234" resolves to one // vehicle instead of the fleet aggregate. id: 'vehicleLookup', label: 'Look up a specific vehicle — e.g. "find vehicle TN01AB1234"', match: (text) => { if (!/\bvehicles?\b/i.test(text) || !LOOKUP_TRIGGER.test(text)) return null; const name = nameAfterKeyword(text, 'vehicle'); return name ? { name } : null; }, run: async ({ name }) => { const vehicles = (await getVehicles()) || []; const needle = name.toLowerCase().replace(/\s+/g, ''); const found = vehicles.find((v) => String(v.vehicleno || v.vehiclenumber || '') .toLowerCase() .replace(/\s+/g, '') .includes(needle) ); if (!found) return null; return { headline: `${found.vehicleno || found.vehiclenumber} — ${found.status || 'status unknown'}.`, detail: found.vehicletype ? `Type: ${found.vehicletype}` : undefined, sourceCalls: [{ name: 'getVehicles', target: '/admin/vehicles', status: 'complete', stats: `matched "${name}"` }] }; } }, { id: 'vehicleStatus', label: 'Vehicle status — e.g. "how many vehicles are available"', match: (text) => (/\bvehicles?\b/i.test(text) ? {} : null), run: async () => { const vehicles = (await getVehicles()) || []; const available = vehicles.filter((v) => String(v.status || '').toLowerCase() === 'available').length; return { headline: `${vehicles.length} vehicle${vehicles.length === 1 ? '' : 's'} total, ${available} available.`, detail: vehicles.length ? `By type: ${summarizeByField(vehicles, 'vehicletype')}` : undefined, sourceCalls: [{ name: 'getVehicles', target: '/admin/vehicles', status: 'complete', stats: `${vehicles.length} vehicles` }] }; } }, { id: 'tripsheetStatus', label: 'Tripsheet status — e.g. "how many tripsheets are dispatched"', match: (text) => (/\btrip\s*sheets?\b/i.test(text) ? {} : null), run: async () => { const trips = (await getTripsheets()) || []; return { headline: `${trips.length} tripsheet${trips.length === 1 ? '' : 's'} total.`, detail: trips.length ? `Statuses: ${summarizeByField(trips, 'status')}` : undefined, sourceCalls: [{ name: 'getTripsheets', target: '/admin/tripsheets', status: 'complete', stats: `${trips.length} tripsheets` }] }; } }, { id: 'exceptionStatus', label: 'Exceptions — e.g. "how many open exceptions"', match: (text) => (/\bexceptions?\b/i.test(text) ? {} : null), run: async () => { const exceptions = (await getExceptions()) || []; return { headline: `${exceptions.length} exception${exceptions.length === 1 ? '' : 's'} total.`, detail: exceptions.length ? `By severity: ${summarizeByField(exceptions, 'severity')}` : undefined, sourceCalls: [{ name: 'getExceptions', target: '/admin/exceptions', status: 'complete', stats: `${exceptions.length} exceptions` }] }; } }, { id: 'appUserCount', label: 'App users — e.g. "how many app users do we have"', match: (text) => (/\bapp\s*users?\b/i.test(text) ? {} : null), run: async () => { const users = (await getAppUsers()) || []; return { headline: `${users.length} app user${users.length === 1 ? '' : 's'} total.`, detail: users.length ? `Statuses: ${summarizeByField(users, 'status')}` : undefined, sourceCalls: [{ name: 'getAppUsers', target: '/admin/users', status: 'complete', stats: `${users.length} users` }] }; } }, { id: 'customerCount', label: 'Customers — e.g. "how many customers do we have"', match: (text) => (/\bcustomers?\b/i.test(text) ? {} : null), run: async () => { const customers = (await getAdminCustomers()) || []; return { headline: `${customers.length} customer${customers.length === 1 ? '' : 's'} total.`, sourceCalls: [{ name: 'getAdminCustomers', target: '/admin/customers', status: 'complete', stats: `${customers.length} customers` }] }; } }, { id: 'pricingCount', label: 'Pricing rules — e.g. "how many pricing rules are configured"', match: (text) => (/\bpricing\b|\bprice\s*list\b|\brate\s*card\b/i.test(text) ? {} : null), run: async () => { const pricing = (await getAdminPricing()) || []; return { headline: `${pricing.length} pricing rule${pricing.length === 1 ? '' : 's'} configured.`, detail: pricing.length ? `By vehicle type: ${summarizeByField(pricing, 'vehicletype')}` : undefined, sourceCalls: [{ name: 'getAdminPricing', target: '/admin/pricing', status: 'complete', stats: `${pricing.length} rules` }] }; } }, { id: 'consignmentStatus', label: 'Consignments — e.g. "how many consignments do we have"', match: (text) => (/\bconsignments?\b/i.test(text) ? {} : null), run: async () => { const consignments = (await getConsignments()) || []; return { headline: `${consignments.length} consignment${consignments.length === 1 ? '' : 's'} total.`, detail: consignments.length ? `Statuses: ${summarizeByField(consignments, 'status')}` : undefined, sourceCalls: [ { name: 'getConsignments', target: '/admin/consignments', status: 'complete', stats: `${consignments.length} consignments` } ] }; } }, { id: 'partnerCount', label: 'Partners — e.g. "how many partners do we have"', match: (text) => (/\bpartners?\b/i.test(text) ? {} : null), run: async () => { const partners = (await getPartners()) || []; return { headline: `${partners.length} partner${partners.length === 1 ? '' : 's'} total.`, sourceCalls: [{ name: 'getPartners', target: '/admin/partners', status: 'complete', stats: `${partners.length} partners` }] }; } }, { id: 'competitorBranchCount', label: 'Competitor branches — e.g. "how many competitor branches are tracked"', match: (text) => (/\bcompetitors?\b/i.test(text) ? {} : null), run: async () => { const resp = await getCompetitorBranches(1, BULK_PAGESIZE); const branches = resp?.data || []; const total = resp?.total ?? branches.length; return { headline: `${total} competitor branch${total === 1 ? '' : 'es'} tracked.`, detail: branches.length ? `By city: ${summarizeByField(branches, 'city')}` : undefined, sourceCalls: [ { name: 'getCompetitorBranches', target: '/admin/competitor-branches', status: 'complete', stats: `${total} branches` } ] }; } }, { id: 'carrierPricingCount', label: 'Carrier pricing — e.g. "how many carrier pricing rules"', match: (text) => (/\bcarriers?\b/i.test(text) ? {} : null), run: async () => { const resp = await getCarrierPricing(1, BULK_PAGESIZE); const rows = resp?.data || []; const total = resp?.total ?? rows.length; return { headline: `${total} carrier pricing rule${total === 1 ? '' : 's'} configured.`, sourceCalls: [{ name: 'getCarrierPricing', target: '/admin/carrier-pricing', status: 'complete', stats: `${total} rules` }] }; } }, { // Ordered ahead of the generic order/status intents: "summary"/"overview" // is an explicit, rare trigger, but a phrasing like "order summary today" // would otherwise be swallowed by totalOrders (whose `run` never returns // null, so whichever is checked first wins the whole question). id: 'opsSummary', label: 'Operations summary — e.g. "give me today\'s operations summary"', match: (text) => { if (mentionsRiders(text)) return null; if (!/\bsummary\b|\boverview\b|\bsnapshot\b|\bhow\s+are\s+we\s+doing\b|\bops\b|\boperations?\b/i.test(text)) return null; const range = rangeFromWords(text); if (range) return { start: range.start, end: range.end, rangeLabel: range.label }; const day = dayFromWords(text); return { start: day, end: day, rangeLabel: describeDay(day) }; }, run: async ({ start, end, rangeLabel }) => { // Two independent real calls — bookings for the tallies, milers for // the fleet line. A rider-summary failure degrades that one line // rather than failing the whole answer. const scan = await fetchBookingsInRange(start, end); const rows = scan.rows; 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)' : ''}` }, stats: statusStats(rows), detail: [ riders ? `${riders.active} of ${riders.total} riders active (${riders.available} available).` : null, `${formatRupees(revenueOf(rows))} estimated revenue, excluding cancelled.` ] .filter(Boolean) .join('\n') + truncationNote(scan), sourceCalls: [ scanCall(scan, `${rows.length} orders ${rangeLabel}`), riders ? { name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${riders.total} riders` } : { name: 'getallridersummary', target: '/admin/milers', status: 'error', errorMessage: 'Rider summary unavailable' } ] }; } }, { id: 'batchCount', label: 'Orders in a batch — e.g. "morning batch orders today"', match: (text) => { const batch = batchFromWords(text); if (!batch) return null; return { batch, day: dayFromWords(text) }; }, run: async ({ batch, day }) => { const scan = await fetchBookingsForDay(day); const rows = scan.rows; const matched = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch); return { headline: `${countPhrase(scan, matched.length)} order${matched.length === 1 ? '' : 's'} in the ${getBatchLabel( batch )} ${describeDay(day)}.`, metric: { value: matched.length, label: `${getBatchLabel(batch)} ${describeDay(day)}` }, list: matched.length ? { title: `${getBatchLabel(batch)} orders`, items: matched.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] })) } : undefined, detail: (matched.length ? '' : 'No orders fall in this batch for that day.') + truncationNote(scan), sourceCalls: [scanCall(scan, `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`)] }; } }, { id: 'statusBreakdown', label: 'Orders by status — e.g. "how many delivered orders today"', match: (text) => { // "active" is a real order status AND common rider-availability // language ("riders active today") — riderCounts already runs first, // but refuse explicitly too so this can't regress if reordered. if (mentionsRiders(text) || mentionsDelay(text)) return null; const group = statusFromWords(text); if (!group) return null; const range = rangeFromWords(text); if (range) return { group, start: range.start, end: range.end, label: range.label }; if (mentionsAnyDate(text)) { const day = dayFromWords(text); return { group, start: day, end: day, label: describeDay(day) }; } // No date named at all. "How many orders are assigned" is a question // about the CURRENT STATE of the queue, which is what the Orders page's // tabs show — they apply no date filter either. Silently scoping it to // orders *created today* answered a different question and returned 0 // while the page showed 19. return { group, start: null, end: null, label: null }; }, run: async ({ group, start, end, label }) => { const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings(); const rows = scan.rows; const matched = rows.filter((b) => isInGroup(b.status, group)); const noun = ORDER_STATUS_LABELS[group].toLowerCase(); const whenHeadline = label ? ` ${label}` : ''; return { headline: `${countPhrase(scan, matched.length)} ${noun} order${matched.length === 1 ? '' : 's'}${whenHeadline}.`, metric: { value: matched.length, label: `${ORDER_STATUS_LABELS[group]}${label ? `, ${label}` : ''}` }, stats: statusStats(rows), detail: (label ? `Out of ${rows.length} orders created ${label}.` : `Out of ${rows.length} orders in total. Matches the Orders page's ${ORDER_STATUS_LABELS[group]} tab, which is also unfiltered by date — ask "${noun} orders today" to scope it.`) + `\nCounts ${statusesInGroup(group).join(', ')}.` + truncationNote(scan), sourceCalls: [scanCall(scan, `${rows.length} total → ${matched.length} ${noun}`)] }; } }, { id: 'revenueTotal', label: 'Revenue/charges total — e.g. "total revenue today"', match: (text) => { if (!/\brevenue\b|\bcharges?\b|\bamount\b|\bearnings?\b|\bcollections?\b/i.test(text)) return null; const range = rangeFromWords(text); if (range) return { start: range.start, end: range.end, rangeLabel: range.label }; const day = dayFromWords(text); return { start: day, end: day, rangeLabel: describeDay(day) }; }, run: async ({ start, end, rangeLabel }) => { const scan = await fetchBookingsInRange(start, end); const rows = scan.rows; const billable = rows.filter((b) => !isCancelled(b)); const total = revenueOf(rows); const cancelledCount = rows.length - billable.length; return { // "Estimated" is not hedging — the figure is the sum of // serviceoptions[].estimatedprice, which is a quote, not a settled // amount. Calling it "revenue" flat was the misleading part. headline: `${formatRupees(total)} estimated across ${billable.length} order${billable.length === 1 ? '' : 's'} ${rangeLabel}.`, metric: { value: formatRupees(total), label: `Estimated, ${rangeLabel}` }, detail: [ billable.length ? `Average ${formatRupees(total / billable.length)} per order.` : null, cancelledCount ? `${cancelledCount} cancelled order${cancelledCount === 1 ? '' : 's'} excluded.` : null ] .filter(Boolean) .join('\n') + truncationNote(scan), sourceCalls: [scanCall(scan, `${billable.length} billable orders, ${formatRupees(total)}`)] }; } }, { id: 'tenantCount', label: 'Orders for a tenant — e.g. "orders for today"', match: (text) => { if (mentionsRiders(text)) return null; if (!/\bfor\b/i.test(text) && !/\btenant\b/i.test(text)) return null; return { text, day: dayFromWords(text) }; }, run: async ({ text, day }) => { const tenant = await resolveTenant(text); // No tenant name recognised in the question — don't guess which one // was meant, fall through to the next intent instead. if (!tenant) return null; const scan = await fetchBookingsForDay(day); const rows = scan.rows; const matched = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid)); return { headline: `${countPhrase(scan, matched.length)} order${matched.length === 1 ? '' : 's'} for ${tenant.tenantname} ${describeDay( day )}.`, metric: { value: matched.length, label: `${tenant.tenantname}, ${describeDay(day)}` }, stats: statusStats(matched), detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.` + truncationNote(scan), sourceCalls: [ { name: 'getalltenants', target: '/admin/tenants', status: 'complete' }, scanCall(scan, `${rows.length} total → ${matched.length} for ${tenant.tenantname}`) ] }; } }, { id: 'weekOrders', label: 'Orders this week — e.g. "how many orders this week"', match: (text) => { const range = rangeFromWords(text); if (!range) return null; return range; }, run: async ({ start, end, label }) => { const scan = await fetchBookingsInRange(start, end); const rows = scan.rows; return { headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} ${label}.`, metric: { value: rows.length, label: `Orders ${label}${scan.truncated ? ' (at least)' : ''}` }, stats: statusStats(rows), detail: (rows.length ? `Statuses: ${summarizeStatuses(rows)}` : '') + truncationNote(scan), sourceCalls: [scanCall(scan, `${rows.length} matched (${start} to ${end})`)] }; } }, { // Deliberately last — the broadest match ("order"/"orders" alone), so // every more specific intent above gets first refusal. id: 'totalOrders', label: 'Total order count — e.g. "how many orders today"', match: (text) => !mentionsRiders(text) && !mentionsDelay(text) && /\border(s)?\b|\bbooking(s)?\b/i.test(text) ? { day: dayFromWords(text) } : null, run: async ({ day }) => { const scan = await fetchBookingsForDay(day); const rows = scan.rows; return { headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} created ${describeDay(day)}.`, metric: { value: rows.length, label: `Orders ${describeDay(day)}${scan.truncated ? ' (at least)' : ''}` }, stats: statusStats(rows), detail: truncationNote(scan).trim() || undefined, sourceCalls: [scanCall(scan, `${rows.length} matched`)] }; } }, { // LAST in the catalog on purpose: every operational intent gets first // refusal, so this can only ever claim a question none of them recognised. // Its trigger is deliberately narrow — a question that merely CONTAINS the // word "doormile" ("how many doormile orders today") must still route to // the intent that can actually count it. id: 'aboutDoormile', label: 'What Doormile is, and what I can answer — e.g. "what is doormile"', match: (text) => (ABOUT_TRIGGER.test(text) ? {} : null), run: async () => ({ // Only what this console demonstrably does. Nothing here is a claim about // the company, its coverage, its pricing or its history — none of that is // in this app, and inventing it would be exactly the failure mode the // whole catalog is built to avoid. doormile.com is where that lives. headline: 'Doormile is the delivery operation this console runs.', detail: 'From here you manage orders and deliveries, dispatch riders in batches, run hubs and vehicles, and handle tenants, pricing and reports. I answer questions about that live data — I don’t hold Doormile’s own company information, so for anything beyond day-to-day operations see doormile.com.', list: { title: 'What I can answer', numbered: false, items: [ { label: 'Orders', meta: 'counts by status, batch, tenant or rider · revenue · delays · one order by its number' }, { label: 'Riders', meta: 'how many are active · what a named rider has done' }, { label: 'Fleet', meta: 'hubs, vehicles, tripsheets and exceptions' }, { label: 'Business', meta: 'tenants, customers, pricing, consignments, partners' }, { label: 'Creating', meta: 'a customer, one order, or many from a spreadsheet' } ] }, sourceCalls: [] }) } ]; const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i])); export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label); // Clean, directly-askable example phrasings — for "recommended question" // chips in the UI. Kept separate from SUPPORTED_QUESTIONS (which reads as // documentation, "Orders in a batch — e.g. ...") since a chip needs to be // the literal text to send, not a description of the intent. export const EXAMPLE_QUESTIONS = [ 'How many orders today?', `${BATCHES[0].label} orders today`, 'How many riders are active?', 'Current hub status', 'How many vehicles are available?', 'Total revenue today', 'Orders today vs yesterday', 'How many consignments do we have?' ]; // Chips suggested right after a given intent answers — a light nudge toward // a plausible next question, not a real "understands context" feature. // Keyed by intent id so the AI panel can look these up off the last answer. export const FOLLOW_UP_SUGGESTIONS = { totalOrders: ['What about yesterday?', 'Revenue today'], batchCount: ['What about yesterday?'], statusBreakdown: ['Revenue today'], revenueTotal: ['What about yesterday?', 'This week'], weekOrders: ['Last week'], riderCounts: ['Current hub status'], hubStatus: ['How many vehicles are available?'], vehicleStatus: ['Current hub status'], tenantCount: ['What about yesterday?'] }; const matchAndRun = async (text) => { for (const intent of INTENTS) { const params = intent.match(text); if (!params) continue; // eslint-disable-next-line no-await-in-loop const result = await intent.run(params); if (result) return { ...result, intentId: intent.id, params }; } return null; }; // True when, after stripping filler words, the text is JUST a date/range // phrase with no other recognisable domain keyword — "what about // yesterday?" qualifies, "how many riders yesterday" does not (that's its // own new question, not a follow-up on the same one). const isBareDatePhrase = (text) => { const stripped = text.replace(/\bwhat\s+about\b|\bhow\s+about\b|\band\b|\?/gi, '').trim(); if (!stripped) return false; const looksLikeADate = explicitDateFromWords(stripped) !== null || weekdayFromWords(stripped) !== null || rangeFromWords(stripped) !== null || /\byesterday\b|\btoday\b/i.test(stripped); if (!looksLikeADate) return false; return !/\brider|\btenant|\bhub|\bvehicle|\btripsheet|\bexception|\bcustomer|\bpricing|\bconsignment|\bpartner|\bcompetitor|\bcarrier/i.test( stripped ); }; // Follow-up context: "what about yesterday?" after an order-count question // re-runs the SAME intent with just the date/range swapped, instead of // requiring the whole question to be repeated. Only kicks in when the new // text doesn't resolve to anything on its own (checked by the caller) AND // reads as a bare date phrase AND there's a previous intent to re-run. const rerunWithNewDate = async (lastIntentId, lastParams, text) => { const intent = INTENTS_BY_ID[lastIntentId]; if (!intent || !lastParams) return null; const range = rangeFromWords(text); const newParams = { ...lastParams }; if (range) { if ('start' in newParams) newParams.start = range.start; if ('end' in newParams) newParams.end = range.end; if ('rangeLabel' in newParams) newParams.rangeLabel = range.label; if ('label' in newParams) newParams.label = range.label; } else { const day = dayFromWords(text); if ('day' in newParams) newParams.day = day; if ('start' in newParams) { newParams.start = day; newParams.end = day; } if ('rangeLabel' in newParams) newParams.rangeLabel = describeDay(day); } const result = await intent.run(newParams); return result ? { ...result, intentId: intent.id, params: newParams } : null; }; // "orders and revenue today" — answered as one combined response instead of // only the first-matching intent, when the question plainly asks two things // at once (segments joined by and/,/&). Each segment is matched // independently through the exact same INTENTS catalog; a segment that // doesn't resolve to anything is silently dropped rather than surfacing a // partial/wrong result — same "no answer beats a guessed one" rule as // everywhere else in this file. const answerMultiPart = async (text) => { const segments = text .split(MULTI_SPLIT) .map((s) => s.trim()) .filter((s) => s.length > 2); if (segments.length < 2) return null; const results = []; for (const segment of segments) { // eslint-disable-next-line no-await-in-loop const r = await matchAndRun(segment); if (r) results.push(r); } if (results.length < 2) return null; // not genuinely multi-part — let the normal single-intent path handle it return { headline: results.map((r) => r.headline).join(' '), detail: results .map((r) => r.detail) .filter(Boolean) .join('\n\n') || undefined, sourceCalls: results.flatMap((r) => r.sourceCalls || []), intentId: 'multiPart', params: { segments } }; }; // Tries each intent in order; the first one whose `match` recognises the // text AND whose `run` resolves to a real answer wins. Falls back to // multi-part splitting, then to follow-up context (re-running the previous // turn's intent with a new date) if `context` was passed in. Returns null // if nothing matched (or every match failed to resolve) — the caller shows // the "I can't answer that yet" fallback rather than a guess. // // `context` is optional: { lastIntentId, lastParams } from the previous // turn's result, used only for the "what about yesterday?" follow-up path. export async function answerQuestion(text, context = {}) { const normalized = correctTypos(text); // ---- Semantic routing (optional) --------------------------------------- // // Tried FIRST, because the regex catalog's weakness is vocabulary, not // logic: "cancellation" not matching `cancel(led)?`, a bare reply matching // nothing, "per day" being dropped. Retrieval fixes the matching problem // without touching how an answer is produced — the intent's own run() still // executes and every number still comes from a live API call. // // Returns null whenever the sidecar is absent, slow, or unsure, in which // case the deterministic matcher below runs exactly as it does today. This // path can only add coverage. const routed = await routeQuestion(normalized); if (isRouteTrustworthy(routed)) { const intent = INTENTS_BY_ID[routed.intentId]; // The intent's own match() still extracts the slots — dates, statuses, // tenants, riders. Retrieval decides WHICH question; parsing decides WITH // WHAT. Embeddings are good at the former and unreliable at the latter. const params = intent?.match(normalized); if (intent && params) { // eslint-disable-next-line no-await-in-loop const result = await intent.run(params); if (result) return { ...result, intentId: intent.id, params, routing: routed }; } } // Tried BEFORE the single-intent pass: several intents match on a bare // substring ("revenue" anywhere in the text) and their `run` never // returns null, so on a combined question like "orders and revenue // today" the broad intent would swallow the whole sentence and // answerMultiPart would never get a turn. Only spend the extra fetches // on this path when the text actually contains a connector; a real // multi-part answer still requires >=2 segments to independently // resolve, so a single-question false trigger ("service and delivery // timing?") safely falls through to the normal single-intent match below. if (MULTI_SPLIT.test(normalized)) { const multi = await answerMultiPart(normalized); if (multi) return multi; } const direct = await matchAndRun(normalized); if (direct) return direct; if (context.lastIntentId && isBareDatePhrase(normalized)) { const followUp = await rerunWithNewDate(context.lastIntentId, context.lastParams, normalized); if (followUp) return followUp; } // ---- Last resort: is this a question about how the console WORKS? ------- // // Reached only when no intent produced an answer. "What is CityGate", "why // does dispatch reconcile before commit", "what's the pagination cap" are // real operator questions that no amount of API access can answer — they're // answered by the documentation. // // Passages are returned VERBATIM with their source. There is no generation // step: summarising would need a hosted model (CLAUDE.md §2) and would let a // paraphrase drift from what the doc actually says. The operator reads the // real words and can see which file they came from. const docs = await askDocs(normalized); if (docs?.chunks?.length) { const best = docs.chunks[0]; return { headline: best.heading || 'From the documentation', detail: best.text, list: docs.chunks.length > 1 ? { title: 'Other passages', numbered: false, items: docs.chunks.slice(1).map((c) => ({ label: c.heading || c.source, meta: c.source })) } : undefined, intentId: 'docsAnswer', sourceCalls: docs.chunks.map((c) => ({ name: 'console_docs', target: c.source, status: 'complete', stats: `similarity ${c.score}` })) }; } return null; }