updatess on the ui changs
This commit is contained in:
215
src/pages/nearle/assistant/intents.js
Normal file
215
src/pages/nearle/assistant/intents.js
Normal file
@@ -0,0 +1,215 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getBookings } from 'pages/api/doormileApi';
|
||||
import { getalltenants, getallridersummary, mapBookingStatusToDeliveryStatus } from 'pages/api/api';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { getRowBatchId, getBatchLabel } from 'utils/batchBucket';
|
||||
|
||||
// ==============================|| Doormile Bot — intent catalog ||============================== //
|
||||
//
|
||||
// Deterministic, not LLM-based (see the plan this was built from): every
|
||||
// answer comes from a real API call against the same functions the rest of
|
||||
// the console uses, never from 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;
|
||||
|
||||
const TODAY = () => dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const dayFromWords = (text) => {
|
||||
if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||
return TODAY();
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const statusFromWords = (text) => {
|
||||
if (/\bpending\b/i.test(text)) return 'pending';
|
||||
if (/\bcancel(led)?\b/i.test(text)) return 'cancelled';
|
||||
if (/\baccept(ed)?\b/i.test(text)) return 'accepted';
|
||||
if (/\bactive\b|\bin[- ]?transit\b/i.test(text)) return 'active';
|
||||
// Checked after the more specific ones above — "delivered" would also
|
||||
// false-match "undelivered" without the negative lookahead-ish guard.
|
||||
if (/\bdeliver(ed)?\b/i.test(text) && !/\bundeliver/i.test(text)) return 'delivered';
|
||||
return null;
|
||||
};
|
||||
|
||||
// Bulk-fetch bookings and keep only the ones created on `day` — same
|
||||
// client-side date filter shape as fetchDeliveries' `inRange` in api.js.
|
||||
const fetchBookingsForDay = async (day) => {
|
||||
const bookings = (await getBookings(1, BULK_PAGESIZE)) || [];
|
||||
return bookings.filter((b) => {
|
||||
const d = parseDoormileTimestamp(b.createdat);
|
||||
return d.isValid() && d.format('YYYY-MM-DD') === day;
|
||||
});
|
||||
};
|
||||
|
||||
const summarizeStatuses = (rows) => {
|
||||
const counts = {};
|
||||
rows.forEach((b) => {
|
||||
const s = mapBookingStatusToDeliveryStatus(b.status);
|
||||
counts[s] = (counts[s] || 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}`;
|
||||
|
||||
const INTENTS = [
|
||||
{
|
||||
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 rows = await fetchBookingsForDay(day);
|
||||
const matched = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch);
|
||||
return {
|
||||
headline: `${matched.length} order${matched.length === 1 ? '' : 's'} in the ${getBatchLabel(batch)} ${describeDay(day)}.`,
|
||||
detail: matched.length
|
||||
? `Order IDs: ${matched.slice(0, 10).map(bookingLabel).join(', ')}${
|
||||
matched.length > 10 ? ` …and ${matched.length - 10} more` : ''
|
||||
}`
|
||||
: 'No orders fall in this batch for that day.',
|
||||
sourceCalls: [
|
||||
{
|
||||
name: 'getBookings',
|
||||
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
|
||||
status: 'complete',
|
||||
stats: `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'statusBreakdown',
|
||||
label: 'Orders by status — e.g. "how many delivered orders today"',
|
||||
match: (text) => {
|
||||
const status = statusFromWords(text);
|
||||
if (!status) return null;
|
||||
return { status, day: dayFromWords(text) };
|
||||
},
|
||||
run: async ({ status, day }) => {
|
||||
const rows = await fetchBookingsForDay(day);
|
||||
const matched = rows.filter((b) => mapBookingStatusToDeliveryStatus(b.status) === status);
|
||||
return {
|
||||
headline: `${matched.length} ${status} order${matched.length === 1 ? '' : 's'} ${describeDay(day)}.`,
|
||||
detail: `Out of ${rows.length} orders created ${describeDay(day)}.`,
|
||||
sourceCalls: [
|
||||
{
|
||||
name: 'getBookings',
|
||||
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
|
||||
status: 'complete',
|
||||
stats: `${rows.length} total → ${matched.length} ${status}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tenantCount',
|
||||
label: 'Orders for a tenant — e.g. "orders for <tenant name> today"',
|
||||
match: (text) => {
|
||||
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 rows = await fetchBookingsForDay(day);
|
||||
const matched = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid));
|
||||
return {
|
||||
headline: `${matched.length} order${matched.length === 1 ? '' : 's'} for ${tenant.tenantname} ${describeDay(day)}.`,
|
||||
detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.`,
|
||||
sourceCalls: [
|
||||
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete' },
|
||||
{
|
||||
name: 'getBookings',
|
||||
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
|
||||
status: 'complete',
|
||||
stats: `${rows.length} total → ${matched.length} for ${tenant.tenantname}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'riderCounts',
|
||||
label: 'Rider availability — e.g. "how many riders are active"',
|
||||
match: (text) => (/\brider(s)?\b/i.test(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` }]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// Deliberately last — the broadest match ("order"/"orders" alone), so
|
||||
// every more specific intent above gets first refusal.
|
||||
id: 'totalOrders',
|
||||
label: 'Today\'s total order count — e.g. "how many orders today"',
|
||||
match: (text) => (/\border(s)?\b/i.test(text) ? { day: dayFromWords(text) } : null),
|
||||
run: async ({ day }) => {
|
||||
const rows = await fetchBookingsForDay(day);
|
||||
return {
|
||||
headline: `${rows.length} order${rows.length === 1 ? '' : 's'} created ${describeDay(day)}.`,
|
||||
detail: rows.length ? `Statuses: ${summarizeStatuses(rows)}` : undefined,
|
||||
sourceCalls: [
|
||||
{ name: 'getBookings', target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`, status: 'complete', stats: `${rows.length} matched` }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
|
||||
|
||||
// Tries each intent in order; the first one whose `match` recognises the
|
||||
// text AND whose `run` resolves to a real answer wins. 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.
|
||||
export async function answerQuestion(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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user