Files
doormilxpress_astryx/src/lib/assistant/intents.js
2026-08-26 15:01:15 +05:30

2311 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <ChatToolCalls> 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 <date> to <date>". 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 <tenant name> 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;
}