update on the agents and registry side
This commit is contained in:
92
src/lib/assistant/agent/AgentFactory.js
Normal file
92
src/lib/assistant/agent/AgentFactory.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// ==============================|| Doormile AI — Autonomous Agent Factory ||============================== //
|
||||
import { SkillRegistry } from '../skills/SkillRegistry';
|
||||
import { wallClockNow } from './signals';
|
||||
|
||||
const SEVERITY_RANK = { critical: 0, warning: 1, watch: 2 };
|
||||
|
||||
export class AgentFactory {
|
||||
/**
|
||||
* Compiles an Agent configuration from a collection of skills.
|
||||
*/
|
||||
static createAgent(skills = SkillRegistry.getActiveSkills()) {
|
||||
const activeSkills = Array.isArray(skills) ? skills : [];
|
||||
|
||||
// 1. Synthesize System Prompt
|
||||
const instructions = activeSkills
|
||||
.map((s) => `### ${s.name} (${s.category})\n${(s.instructions || '').trim()}`)
|
||||
.join('\n\n');
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Doormile Autonomous Logistics Fleet Orchestrator.
|
||||
You operate with the following active operational skill modules:
|
||||
|
||||
${instructions || 'Standard logistics routing and telemetry diagnostics.'}
|
||||
|
||||
Guiding Invariant:
|
||||
Read-only queries execute instantly. Mutating actions (reassignments, pings, OTP enforcement) MUST return human-in-the-loop proposals with clear blast radius.
|
||||
`.trim();
|
||||
|
||||
// 2. Aggregate Tools from active skills
|
||||
const tools = activeSkills.flatMap((s) => s.tools || []);
|
||||
const toolMap = new Map();
|
||||
tools.forEach((t) => toolMap.set(t.name, t));
|
||||
|
||||
// 3. Telemetry Evaluation Engine
|
||||
const evaluateTelemetry = (rows = [], now = wallClockNow()) => {
|
||||
const allFindings = [];
|
||||
|
||||
for (const skill of activeSkills) {
|
||||
if (typeof skill.evaluate === 'function') {
|
||||
// Extract current threshold values
|
||||
const rawThresholds = {};
|
||||
if (skill.effectiveThresholds) {
|
||||
Object.entries(skill.effectiveThresholds).forEach(([k, v]) => {
|
||||
rawThresholds[k] = v.currentValue;
|
||||
});
|
||||
} else if (skill.thresholds) {
|
||||
Object.entries(skill.thresholds).forEach(([k, v]) => {
|
||||
rawThresholds[k] = v.value;
|
||||
});
|
||||
}
|
||||
|
||||
const findings = skill.evaluate(rows, now, rawThresholds);
|
||||
if (Array.isArray(findings)) {
|
||||
allFindings.push(...findings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by severity (critical > warning > watch), then by affected row count descending
|
||||
return allFindings.sort(
|
||||
(a, b) =>
|
||||
(SEVERITY_RANK[a.severity] ?? 99) - (SEVERITY_RANK[b.severity] ?? 99) ||
|
||||
(b.count || 0) - (a.count || 0)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'Doormile Fleet Orchestrator',
|
||||
version: '2.0.0',
|
||||
activeSkillCount: activeSkills.length,
|
||||
skills: activeSkills,
|
||||
tools,
|
||||
toolMap,
|
||||
systemPrompt,
|
||||
evaluateTelemetry,
|
||||
executeSkillTool: async (toolName, args) => {
|
||||
const tool = toolMap.get(toolName);
|
||||
if (!tool || typeof tool.handler !== 'function') {
|
||||
return { success: false, error: `Skill tool "${toolName}" not found or has no handler.` };
|
||||
}
|
||||
return tool.handler(args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to build the default active agent runtime based on current SkillRegistry state.
|
||||
*/
|
||||
static synthesizeDefaultAgent() {
|
||||
return AgentFactory.createAgent(SkillRegistry.getActiveSkills());
|
||||
}
|
||||
}
|
||||
153
src/lib/assistant/agent/actions.js
Normal file
153
src/lib/assistant/agent/actions.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// ==============================|| Doormile AI — proposal executors ||============================== //
|
||||
//
|
||||
// The layer that turns a finding's proposal into a real API call.
|
||||
//
|
||||
// It exists as its own module because the skills deliberately have no API
|
||||
// access at all — not one of them imports an endpoint, and their tool handlers
|
||||
// only build proposal objects. That separation is worth keeping: a skill stays
|
||||
// a pure function of rows and thresholds, which is why all eight are testable
|
||||
// without a network. Execution is the part with consequences, so it lives in
|
||||
// one place where the failure modes can be handled once.
|
||||
//
|
||||
// Two rules, both learned the hard way in assignActions.js:
|
||||
//
|
||||
// • Never report an action that did not happen. A card that says "rider
|
||||
// notified" when the push returned 400 is worse than one that says nothing:
|
||||
// a dispatcher who believes a rider was pinged does not follow up.
|
||||
// • A partial success is a partial success. Notifying six riders out of eight
|
||||
// is reported as six out of eight, with the two failures named.
|
||||
|
||||
import { getMilers, buildMilerLookup, notifyRider } from '@/api/doormile';
|
||||
|
||||
/** A stable, human-facing reference for a row. */
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
/**
|
||||
* Rider pushes key off milerprofileid; findings carry mileruserid.
|
||||
*
|
||||
* These are two different small integers on the same record, and using the
|
||||
* wrong one fails silently in both directions — the notify 404s, or a different
|
||||
* rider is paged. buildMilerLookup is the same bridge the Orders page uses;
|
||||
* this must never grow a second one.
|
||||
*/
|
||||
const profileIdFor = (row, lookup) => lookup.byUserId.get(String(row?.userid))?.milerprofileid;
|
||||
|
||||
// ---- notifyRider ------------------------------------------------------------
|
||||
|
||||
const executeNotifyRider = async (scope, message) => {
|
||||
const rows = (scope || []).filter((r) => r?.userid);
|
||||
if (!rows.length) {
|
||||
return { ok: false, message: 'None of these parcels has an assigned rider to notify.', sourceCalls: [] };
|
||||
}
|
||||
|
||||
// One fetch for the whole batch. Per row would be an identical request each
|
||||
// time, and the lookup is the same for all of them.
|
||||
const milers = await getMilers();
|
||||
const lookup = buildMilerLookup(milers);
|
||||
|
||||
const sourceCalls = [];
|
||||
const notified = [];
|
||||
const unreachable = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const profileId = profileIdFor(row, lookup);
|
||||
|
||||
// No profile id means no push is possible. Recorded rather than skipped,
|
||||
// so the operator is not left assuming a phone buzzed.
|
||||
if (!profileId) {
|
||||
unreachable.push(`${ref(row)} (${row.ridername || 'rider'}: no device registered)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await notifyRider(profileId, 'Doormile Ops', message);
|
||||
notified.push(ref(row));
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${profileId}/notify`,
|
||||
status: 'complete',
|
||||
stats: `${ref(row)} → ${row.ridername || 'rider'}`
|
||||
});
|
||||
} catch (err) {
|
||||
unreachable.push(`${ref(row)} (${err?.message || 'push failed'})`);
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${profileId}/notify`,
|
||||
status: 'error',
|
||||
errorMessage: err?.message || 'notification failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (notified.length) parts.push(`Notified ${notified.length} rider${notified.length === 1 ? '' : 's'}.`);
|
||||
if (unreachable.length) parts.push(`Could not reach ${unreachable.length}: ${unreachable.join('; ')}.`);
|
||||
|
||||
return {
|
||||
ok: notified.length > 0,
|
||||
message: parts.join(' ') || 'Nothing was sent.',
|
||||
notified,
|
||||
unreachable,
|
||||
sourceCalls
|
||||
};
|
||||
};
|
||||
|
||||
// ---- assignMiler: deliberately NOT an executor --------------------------------
|
||||
//
|
||||
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
|
||||
// That route sits behind middlewares.HubStaffAuth, which refuses every token
|
||||
// whose role is not 6 (hub staff) — so from this console, where every login is
|
||||
// admin/manager/executive, it would 403 on every click. Shipping it would have
|
||||
// put a button on the Exceptions banner that could never succeed.
|
||||
//
|
||||
// Findings that propose `assignMiler` therefore render "Review only". The
|
||||
// admin route that could back it, POST /admin/bookings/:id/assign-miler, needs
|
||||
// a chosen rider per booking, which a finding does not pick. Wiring this is a
|
||||
// backend decision (an admin batch-assign route), not a console one.
|
||||
|
||||
// ---- registry ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Keyed on the `tool` a finding's proposal actually carries.
|
||||
*
|
||||
* Deliberately NOT the skills' declared tool names (ping_stalled_rider,
|
||||
* reassign_sla_critical_order, …). Those belong to a separate, currently unused
|
||||
* layer whose handlers only build proposals; the UI renders `proposal.tool`,
|
||||
* and that is what an operator's click has to resolve.
|
||||
*
|
||||
* Verbs with no entry here have no endpoint behind them yet. That is not an
|
||||
* error — the card renders them disabled and says "Review only" rather than
|
||||
* pretending.
|
||||
*/
|
||||
const EXECUTORS = {
|
||||
notifyRider: (finding) =>
|
||||
executeNotifyRider(
|
||||
finding?.proposal?.scope,
|
||||
finding?.severity === 'critical'
|
||||
? 'Urgent: this delivery needs attention now. Please check the app.'
|
||||
: 'Please check this delivery in the app when you can.'
|
||||
)
|
||||
// assignMiler: none — see the note above. Its findings render "Review only".
|
||||
};
|
||||
|
||||
/** Whether a finding's proposal can actually be carried out. */
|
||||
export const canExecuteProposal = (finding) => Boolean(EXECUTORS[finding?.proposal?.tool]);
|
||||
|
||||
/** Every verb that currently has a real executor behind it. */
|
||||
export const executableTools = () => Object.keys(EXECUTORS);
|
||||
|
||||
/**
|
||||
* Runs a finding's proposal.
|
||||
*
|
||||
* Throws when there is no executor rather than resolving quietly, so a caller
|
||||
* cannot mistake "nothing happened" for success. The UI is expected to have
|
||||
* checked canExecuteProposal first and disabled the control.
|
||||
*/
|
||||
export const executeProposal = async (finding) => {
|
||||
const run = EXECUTORS[finding?.proposal?.tool];
|
||||
if (!run) {
|
||||
throw new Error(`No executor for "${finding?.proposal?.tool}" — this proposal is review-only.`);
|
||||
}
|
||||
return run(finding);
|
||||
};
|
||||
112
src/lib/assistant/agent/briefing.js
Normal file
112
src/lib/assistant/agent/briefing.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// ==============================|| Doormile AI — the ops briefing ||============================== //
|
||||
//
|
||||
// Turns the findings from signals.js into the shape the assistant panel already
|
||||
// renders: a headline, a detail body, and a metric. Pure — it takes rows and an
|
||||
// instant and returns an object, so the whole briefing can be tested without a
|
||||
// network, a clock, or React.
|
||||
//
|
||||
// This is the agent's "explain" step. Every line it produces is traceable to a
|
||||
// rule in signals.js and to the rows that rule matched, because an operator who
|
||||
// cannot see why the agent believes something will not act on it — and an
|
||||
// unexplained instruction from a bot is exactly the thing CLAUDE.md §3 refuses
|
||||
// to ship.
|
||||
|
||||
import { AgentFactory } from './AgentFactory';
|
||||
import { ALL_CLEAR, wallClockNow } from './signals';
|
||||
import { toAgentRows } from './normalise';
|
||||
|
||||
const SEVERITY_LABEL = {
|
||||
critical: 'Needs attention now',
|
||||
warning: 'Worth looking at',
|
||||
watch: 'Keep an eye on'
|
||||
};
|
||||
|
||||
/**
|
||||
* A truncated scan is a floor, not a total.
|
||||
*
|
||||
* `/admin/bookings` caps pagesize, so a scan drains pages up to a budget and
|
||||
* reports whether it ran out. Every count built on a truncated scan says "at
|
||||
* least", because the alternative — stating a total that is quietly a subset —
|
||||
* is the exact failure the assistant's verifiability contract exists to
|
||||
* prevent. A missed breach is worse than an approximate one, so the briefing
|
||||
* still runs; it just stops claiming completeness.
|
||||
*/
|
||||
export const countPrefix = (truncated) => (truncated ? 'at least ' : '');
|
||||
|
||||
/** One finding rendered as a block of lines. */
|
||||
const renderFinding = (finding, truncated) => {
|
||||
const lines = [];
|
||||
lines.push(`${SEVERITY_LABEL[finding.severity]} — ${finding.title}`);
|
||||
lines.push(finding.why);
|
||||
|
||||
if (finding.detail?.length) {
|
||||
lines.push(...finding.detail.map((d) => ` • ${d}`));
|
||||
const shown = finding.detail.length;
|
||||
if (finding.count > shown) {
|
||||
lines.push(` • …and ${countPrefix(truncated)}${finding.count - shown} more`);
|
||||
}
|
||||
}
|
||||
|
||||
if (finding.proposal) {
|
||||
lines.push(` → ${finding.proposal.label} (${finding.proposal.blastRadius})`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full briefing.
|
||||
*
|
||||
* `scan` is the result of the assistant's own booking scan — `{ rows,
|
||||
* truncated }` — so the briefing inherits the same data the rest of the
|
||||
* catalog answers from and cannot disagree with it.
|
||||
*/
|
||||
export const buildBriefing = (scan, now = wallClockNow()) => {
|
||||
// Raw `/admin/bookings` rows in, agent rows out. Without this the rules read
|
||||
// undefined on every field and the briefing reports an all-clear board.
|
||||
const rows = toAgentRows(scan?.rows);
|
||||
const truncated = Boolean(scan?.truncated);
|
||||
|
||||
// The SAME engine the Exceptions banner runs, deliberately.
|
||||
//
|
||||
// This used to call a private set of rules in signals.js that duplicated five
|
||||
// of the skills and hardcoded their thresholds. The two drifted immediately —
|
||||
// signals.js flagged a stalled rider at 25 minutes while DoorstepStallSkill
|
||||
// used 20 — so the chat panel and the banner disagreed about the same parcel,
|
||||
// and a threshold moved in Agent Studio changed one surface but not the other.
|
||||
//
|
||||
// Going through the registry means there is one definition of every rule, one
|
||||
// place thresholds live, and the chat panel sees every enabled skill rather
|
||||
// than the five somebody happened to reimplement here.
|
||||
const findings = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(rows, now);
|
||||
|
||||
if (!findings.length) {
|
||||
return {
|
||||
headline: ALL_CLEAR,
|
||||
detail: truncated
|
||||
? 'Note: the booking scan hit its page budget, so this covers the most recent orders rather than every one.'
|
||||
: `Checked ${rows.length} open and recent bookings.`,
|
||||
findings,
|
||||
metric: { value: 0, label: 'issues found' }
|
||||
};
|
||||
}
|
||||
|
||||
const critical = findings.filter((f) => f.severity === 'critical');
|
||||
const affected = new Set(findings.flatMap((f) => f.rows.map((r) => r.bookingid))).size;
|
||||
|
||||
const headline = critical.length
|
||||
? `${critical.length} thing${critical.length === 1 ? '' : 's'} need${critical.length === 1 ? 's' : ''} attention now, across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`
|
||||
: `Nothing critical, but ${findings.length} thing${findings.length === 1 ? '' : 's'} worth looking at across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`;
|
||||
|
||||
const body = findings.map((f) => renderFinding(f, truncated)).join('\n\n');
|
||||
const footer = truncated
|
||||
? '\n\nThe booking scan hit its page budget, so these counts are a floor — there may be more.'
|
||||
: '';
|
||||
|
||||
return {
|
||||
headline,
|
||||
detail: `${body}${footer}`,
|
||||
findings,
|
||||
metric: { value: findings.length, label: findings.length === 1 ? 'issue found' : 'issues found' }
|
||||
};
|
||||
};
|
||||
53
src/lib/assistant/agent/normalise.js
Normal file
53
src/lib/assistant/agent/normalise.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ==============================|| Doormile AI — scan row adapter ||============================== //
|
||||
//
|
||||
// The assistant's booking scan returns RAW rows straight off `/admin/bookings`
|
||||
// — `status`, `createdat`, `assignedmileruserid`, `serviceoptions[0]` — not the
|
||||
// normalised delivery rows the Deliveries page renders. The two shapes share
|
||||
// almost no field names, so the agent's rules cannot read a scan row directly.
|
||||
//
|
||||
// This adapter is the seam. It exists as its own file because getting it wrong
|
||||
// fails silently in the worst possible way: every rule reads `undefined`, every
|
||||
// rule matches nothing, and the agent cheerfully reports "nothing needs
|
||||
// attention" on a board that is on fire. That is precisely the failure mode
|
||||
// CLAUDE.md §3 exists to prevent, and it is invisible without a test.
|
||||
//
|
||||
// The status derivation deliberately goes through the console's own
|
||||
// `mapBookingStatusToDeliveryStatus` rather than a local copy — the same
|
||||
// function the Deliveries page's table calls. That is the house rule (never
|
||||
// hand-roll a second data path) and it is also what guarantees the agent and
|
||||
// the screen can never disagree about what state a parcel is in.
|
||||
|
||||
import { mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries';
|
||||
|
||||
/** The rider's arrival stamp, which the API has spelled several ways. */
|
||||
const reachedAtOf = (b) => b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
|
||||
|
||||
/** Rider display name, from whichever join carried it. */
|
||||
const riderNameOf = (b) =>
|
||||
b.milername || b.ridername || b.assignedmilername || b.miler?.name || undefined;
|
||||
|
||||
/**
|
||||
* One raw booking row → the shape signals.js reads.
|
||||
*
|
||||
* Every field is derived, never invented: a raw row that carries no service
|
||||
* option yields `expecteddeliverytime: undefined`, and the SLA rules skip it
|
||||
* rather than treating a missing promise as a kept or broken one.
|
||||
*/
|
||||
export const toAgentRow = (b) => ({
|
||||
bookingid: b.bookingid ?? b.id,
|
||||
orderid: b.bookingno || (b.bookingid ? `#${b.bookingid}` : '—'),
|
||||
|
||||
// Booking status, consignment status and reachedat resolved by the console's
|
||||
// own mapper — including the Converted_To_Consignment handoff, where the
|
||||
// lifecycle moves onto the consignment and the booking's status freezes.
|
||||
orderstatus: mapBookingStatusToDeliveryStatus(b),
|
||||
|
||||
orderdate: b.createdat || b.orderdate || b.updatedat,
|
||||
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
|
||||
reachedat: reachedAtOf(b),
|
||||
userid: b.assignedmileruserid ?? b.mileruserid,
|
||||
ridername: riderNameOf(b)
|
||||
});
|
||||
|
||||
/** A whole scan's rows, adapted. Non-array input yields an empty list. */
|
||||
export const toAgentRows = (rows) => (Array.isArray(rows) ? rows.map(toAgentRow) : []);
|
||||
44
src/lib/assistant/agent/signals.js
Normal file
44
src/lib/assistant/agent/signals.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// ==============================|| Doormile AI — shared agent primitives ||============================== //
|
||||
//
|
||||
// What is left after the rules moved out.
|
||||
//
|
||||
// This file used to hold its own copy of five detection rules — breached SLA,
|
||||
// at-risk SLA, aging unassigned work, doorstep stalls, rider saturation — each
|
||||
// with a hardcoded threshold in a local THRESHOLDS object. Every one of those
|
||||
// rules already existed as a skill in skills/definitions/, with the same
|
||||
// finding id and a threshold an operator can tune in Agent Studio.
|
||||
//
|
||||
// Two implementations of one rule is a bug with a delay fuse, and this one had
|
||||
// already fired: signals.js flagged a doorstep stall at 25 minutes while
|
||||
// DoorstepStallSkill used 20, so the chat panel and the Exceptions banner
|
||||
// disagreed about the same rider, out of the box, before anyone touched a
|
||||
// slider. Worse, the hardcoded copy could not see the registry at all — tuning
|
||||
// a threshold moved the banner and left the chat panel where it was.
|
||||
//
|
||||
// The rules now live in exactly one place: skills/definitions/. Everything that
|
||||
// needs findings goes through AgentFactory, which reads the registry, so there
|
||||
// is one definition per rule and one place a threshold can be changed.
|
||||
//
|
||||
// What remains here is the small shared vocabulary that is genuinely common to
|
||||
// every consumer and belongs to none of them.
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* "Now", in the same form every Doormile timestamp is stored in.
|
||||
*
|
||||
* NOT `new Date().toISOString()`. That returns UTC, and parseDoormileTimestamp
|
||||
* deliberately strips any zone marker and reads the remaining digits as IST
|
||||
* wall-clock — so an ISO string would hand the rules a clock running 5h30m
|
||||
* slow, and every parcel less than five and a half hours overdue would look
|
||||
* like it was still in the future. The agent would report an all-clear board
|
||||
* through most of a working day.
|
||||
*
|
||||
* This was a live bug. It passed every unit test, because the tests all passed
|
||||
* `now` explicitly and only the default was wrong.
|
||||
*/
|
||||
export const wallClockNow = () => dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
/** Shown when every enabled skill returns no findings. */
|
||||
export const ALL_CLEAR =
|
||||
'Nothing needs attention right now — no breached promises, no stalled riders, and no aging unassigned work.';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getTenantLocations
|
||||
} from 'pages/api/doormileApi';
|
||||
import { getalltenants, getallridersummary } from 'pages/api/api';
|
||||
import { buildBriefing } from '@/lib/assistant/agent/briefing';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
@@ -745,6 +746,48 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
|
||||
const MULTI_SPLIT = /\band\b|,|\+|&/i;
|
||||
|
||||
const INTENTS = [
|
||||
{
|
||||
// The ops briefing: every monitoring skill enabled in the agent registry,
|
||||
// run over the same booking scan the rest of the catalog answers from —
|
||||
// the SAME engine as the Exceptions banner (AgentFactory), so the chat and
|
||||
// the banner can never disagree about a parcel. Ported from
|
||||
// feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
|
||||
//
|
||||
// FIRST on purpose: later intents match bare "orders"/"riders", which
|
||||
// appear in most phrasings of a sweep.
|
||||
//
|
||||
// Narrower than the branch on purpose. The branch also claimed
|
||||
// "late/overdue/delayed orders", which would have stolen the existing
|
||||
// delayed-orders answer (a count on the Orders taxonomy) and replaced it
|
||||
// with a briefing. Only an explicit request for a sweep lands here.
|
||||
id: 'opsBriefing',
|
||||
label: 'What needs attention right now — e.g. "what needs attention", "anything going wrong", "ops check"',
|
||||
match: (text) => {
|
||||
const t = String(text).toLowerCase();
|
||||
const asksForSweep =
|
||||
/\b(what|anything|any)\b.{0,24}\b(needs?\s+(my\s+)?attention|going\s+wrong|at\s+risk|urgent|on\s+fire)\b/.test(t) ||
|
||||
/\b(ops|operations|status|health|daily)\s+(check|sweep|briefing|brief)\b/.test(t) ||
|
||||
/\bwhat\s+should\s+i\s+(do|look\s+at)\b/.test(t);
|
||||
return asksForSweep ? {} : null;
|
||||
},
|
||||
run: async () => {
|
||||
const scan = await scanBookings();
|
||||
const briefing = buildBriefing(scan);
|
||||
return {
|
||||
headline: briefing.headline,
|
||||
detail: briefing.detail,
|
||||
metric: briefing.metric,
|
||||
sourceCalls: [
|
||||
scanCall(
|
||||
scan,
|
||||
briefing.findings.length
|
||||
? `${briefing.findings.length} finding${briefing.findings.length === 1 ? '' : 's'} over ${scan.rows.length} rows`
|
||||
: `no issues over ${scan.rows.length} rows`
|
||||
)
|
||||
]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
|
||||
// contains "orders", so createBulkOrders and createOrder would otherwise
|
||||
|
||||
123
src/lib/assistant/skills/SkillRegistry.js
Normal file
123
src/lib/assistant/skills/SkillRegistry.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// ==============================|| Doormile AI — Skill Registry ||============================== //
|
||||
//
|
||||
// The console's eight monitoring skills: their RULES live in code
|
||||
// (./definitions), their SETTINGS — enabled, thresholds — live in the backend
|
||||
// agent registry (/admin/ai/skills, edited in Settings → Skills & Tools). This
|
||||
// class joins the two.
|
||||
//
|
||||
// On the feat/agentic-ops-layer branch these settings were kept in the
|
||||
// browser's localStorage, so a threshold tuned on one machine changed nothing
|
||||
// on any other, and two operators could look at the same board through
|
||||
// different rules. Ported onto main in Phase 3 of docs/agent-platform-plan.md,
|
||||
// the settings come from the registry instead, and nothing is stored locally.
|
||||
//
|
||||
// Until the registry answers (first load, a network error, a partner login
|
||||
// that is not allowed to read it) every skill runs on its code defaults, and
|
||||
// `getSource()` says so, so the banner can tell the operator which settings it
|
||||
// is running on rather than implying they are the tuned ones.
|
||||
|
||||
import { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
|
||||
import { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
|
||||
import { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
|
||||
import { HighValueCodSkill } from './definitions/HighValueCodSkill';
|
||||
import { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
|
||||
import { HubCongestionSkill } from './definitions/HubCongestionSkill';
|
||||
import { LateDispatchSkill } from './definitions/LateDispatchSkill';
|
||||
import { CashExposureSkill } from './definitions/CashExposureSkill';
|
||||
|
||||
export const DEFAULT_SKILLS = [
|
||||
SlaGuardianSkill,
|
||||
DoorstepStallSkill,
|
||||
FleetBalancerSkill,
|
||||
HighValueCodSkill,
|
||||
RiderBatterySafetySkill,
|
||||
HubCongestionSkill,
|
||||
LateDispatchSkill,
|
||||
CashExposureSkill
|
||||
];
|
||||
|
||||
/** A stored value outside the definition's range falls back to its default. */
|
||||
const inRange = (config, v) =>
|
||||
typeof v === 'number' && Number.isFinite(v) && v >= config.min && v <= config.max;
|
||||
|
||||
class SkillRegistryClass {
|
||||
constructor() {
|
||||
this.skills = new Map();
|
||||
this.listeners = new Set();
|
||||
this.config = {}; // { [skillId]: { enabled: boolean, thresholds: { [key]: number } } }
|
||||
this.source = 'defaults';
|
||||
DEFAULT_SKILLS.forEach((skill) => this.skills.set(skill.id, skill));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the registry's settings. `rows` is the /admin/ai/skills response;
|
||||
* rows for skills this console does not implement are ignored.
|
||||
*/
|
||||
applyRegistryConfig(rows) {
|
||||
const next = {};
|
||||
(Array.isArray(rows) ? rows : []).forEach((row) => {
|
||||
if (!row || !this.skills.has(row.skillid)) return;
|
||||
next[row.skillid] = {
|
||||
enabled: Boolean(row.enabled),
|
||||
thresholds: row.thresholds && typeof row.thresholds === 'object' ? row.thresholds : {}
|
||||
};
|
||||
});
|
||||
this.config = next;
|
||||
this.source = 'registry';
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Forget the registry's settings and run on code defaults. */
|
||||
clearRegistryConfig() {
|
||||
if (this.source === 'defaults' && !Object.keys(this.config).length) return;
|
||||
this.config = {};
|
||||
this.source = 'defaults';
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** 'registry' when running on the tuned settings, 'defaults' otherwise. */
|
||||
getSource() {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
getAllSkills() {
|
||||
return Array.from(this.skills.values()).map((skill) => {
|
||||
const cfg = this.config[skill.id];
|
||||
const enabled = cfg ? cfg.enabled : (skill.defaultEnabled ?? true);
|
||||
|
||||
const effectiveThresholds = {};
|
||||
Object.entries(skill.thresholds || {}).forEach(([key, config]) => {
|
||||
const stored = cfg?.thresholds?.[key];
|
||||
effectiveThresholds[key] = { ...config, currentValue: inRange(config, stored) ? stored : config.value };
|
||||
});
|
||||
|
||||
return { ...skill, enabled, effectiveThresholds };
|
||||
});
|
||||
}
|
||||
|
||||
getActiveSkills() {
|
||||
return this.getAllSkills().filter((s) => s.enabled);
|
||||
}
|
||||
|
||||
getSkill(skillId) {
|
||||
return this.getAllSkills().find((s) => s.id === skillId);
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
notify() {
|
||||
const all = this.getAllSkills();
|
||||
this.listeners.forEach((fn) => {
|
||||
try {
|
||||
fn(all);
|
||||
} catch {
|
||||
// One listener failing must not stop the others hearing the change.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const SkillRegistry = new SkillRegistryClass();
|
||||
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal file
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// ==============================|| Skill: Cash Exposure Agent ||============================== //
|
||||
//
|
||||
// Monitors the total live COD (Cash on Delivery) cash a single rider is carrying
|
||||
// across ALL their active orders. When total exposure exceeds the safe limit,
|
||||
// proposes a mandatory cash handoff stop at the nearest hub.
|
||||
//
|
||||
// This is distinct from HighValueCodSkill, which monitors per-order value.
|
||||
// This skill monitors per-rider TOTAL cash accumulation across their full run.
|
||||
|
||||
const ACTIVE_COD_STATUSES = new Set(['accepted', 'picked', 'active', 'arrived']);
|
||||
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
export const CashExposureSkill = {
|
||||
id: 'skill_cash_exposure',
|
||||
name: 'Cash Exposure Agent',
|
||||
description: 'Monitors total live COD cash per rider across all active orders. Flags when a rider carries unsafe cash levels and proposes hub handoff.',
|
||||
category: 'loss_prevention',
|
||||
icon: 'ShieldCheck',
|
||||
// OFF by default (2026-09-29): the rows this rule reads carry no COD amount per order: /admin/bookings does not preload bookingpayments.
|
||||
// Enabled, it would read undefined on every row and report an all-clear board —
|
||||
// the silent failure normalise.js warns about. Turn it on only once the data exists.
|
||||
dataGap: 'no COD amount per order: /admin/bookings does not preload bookingpayments',
|
||||
defaultEnabled: false,
|
||||
|
||||
thresholds: {
|
||||
maxCashPerRider: {
|
||||
label: 'Max Safe Cash Per Rider',
|
||||
description: 'Maximum total COD cash (₹) a single rider should carry simultaneously across all active orders.',
|
||||
value: 10000,
|
||||
min: 2000,
|
||||
max: 50000,
|
||||
step: 1000,
|
||||
unit: '₹'
|
||||
},
|
||||
warningCashPercent: {
|
||||
label: 'Warning Threshold (%)',
|
||||
description: 'Percentage of max cash limit at which to issue a warning (before it becomes critical).',
|
||||
value: 75,
|
||||
min: 50,
|
||||
max: 95,
|
||||
step: 5,
|
||||
unit: '%'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- A rider carrying excessive COD cash is a major financial risk if the phone dies, the rider is delayed, or there is a dispute.
|
||||
- Flag riders above the cash threshold for a mandatory hub cash handoff before continuing deliveries.
|
||||
- Critical: stop new COD orders being assigned to flagged riders until handoff is confirmed.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'enforce_cash_handoff',
|
||||
description: 'Proposes a mandatory cash handoff stop at the nearest hub for an overexposed rider.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
milerId: { type: 'number', description: 'Rider user ID carrying excessive cash' },
|
||||
totalCash: { type: 'number', description: 'Total COD cash the rider currently holds (₹)' },
|
||||
hubId: { type: 'string', description: 'Nearest hub for handoff' }
|
||||
},
|
||||
required: ['milerId', 'totalCash']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'cash_handoff',
|
||||
milerId: args.milerId,
|
||||
totalCash: args.totalCash,
|
||||
hubId: args.hubId || 'nearest',
|
||||
blastRadius: `Reroutes rider to nearest hub for cash handoff of ₹${args.totalCash?.toLocaleString('en-IN')}. Pauses new COD order assignments to this rider until confirmed.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], _now, currentThresholds = {}) => {
|
||||
const maxCash = currentThresholds.maxCashPerRider ?? 10000;
|
||||
const warningPct = (currentThresholds.warningCashPercent ?? 75) / 100;
|
||||
const warningLimit = maxCash * warningPct;
|
||||
const findings = [];
|
||||
|
||||
// Group active COD orders by rider
|
||||
const byRider = {};
|
||||
rows
|
||||
.filter((r) =>
|
||||
ACTIVE_COD_STATUSES.has(String(r?.orderstatus || '').toLowerCase()) &&
|
||||
r?.paymenttype &&
|
||||
String(r.paymenttype).toLowerCase().includes('cod') &&
|
||||
r?.userid
|
||||
)
|
||||
.forEach((r) => {
|
||||
const uid = r.userid;
|
||||
if (!byRider[uid]) {
|
||||
byRider[uid] = { riderId: uid, riderName: r.ridername || r.miler_name || `Rider ${uid}`, orders: [] };
|
||||
}
|
||||
byRider[uid].orders.push(r);
|
||||
});
|
||||
|
||||
const criticalRiders = [];
|
||||
const warningRiders = [];
|
||||
|
||||
Object.values(byRider).forEach((riderData) => {
|
||||
const total = riderData.orders.reduce((sum, r) => {
|
||||
const amt = parseFloat(r.cod_amount || r.orderamount || r.totalamount || 0);
|
||||
return sum + (isNaN(amt) ? 0 : amt);
|
||||
}, 0);
|
||||
|
||||
riderData.totalCash = total;
|
||||
|
||||
if (total >= maxCash) {
|
||||
criticalRiders.push(riderData);
|
||||
} else if (total >= warningLimit) {
|
||||
warningRiders.push(riderData);
|
||||
}
|
||||
});
|
||||
|
||||
criticalRiders.sort((a, b) => b.totalCash - a.totalCash);
|
||||
warningRiders.sort((a, b) => b.totalCash - a.totalCash);
|
||||
|
||||
if (criticalRiders.length) {
|
||||
findings.push({
|
||||
id: 'cash_exposure_critical',
|
||||
skillId: 'skill_cash_exposure',
|
||||
severity: 'critical',
|
||||
title: `${criticalRiders.length} rider${criticalRiders.length === 1 ? '' : 's'} carrying ≥₹${maxCash.toLocaleString('en-IN')} in COD cash`,
|
||||
why: `Total COD cash per rider exceeds the safe limit of ₹${maxCash.toLocaleString('en-IN')}. Immediate hub handoff required.`,
|
||||
count: criticalRiders.length,
|
||||
rows: criticalRiders.flatMap((rd) => rd.orders),
|
||||
detail: criticalRiders.slice(0, 8).map(
|
||||
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} active COD order${rd.orders.length === 1 ? '' : 's'}`
|
||||
),
|
||||
proposal: {
|
||||
label: 'Enforce cash handoff at hub',
|
||||
tool: 'enforce_cash_handoff',
|
||||
scope: criticalRiders.flatMap((rd) => rd.orders),
|
||||
blastRadius: 'Reroutes affected riders to nearest hub for mandatory cash handoff. New COD assignments paused.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (warningRiders.length) {
|
||||
findings.push({
|
||||
id: 'cash_exposure_warning',
|
||||
skillId: 'skill_cash_exposure',
|
||||
severity: 'warning',
|
||||
title: `${warningRiders.length} rider${warningRiders.length === 1 ? '' : 's'} approaching cash limit (>${Math.round(warningPct * 100)}% of ₹${maxCash.toLocaleString('en-IN')})`,
|
||||
why: `Total COD cash is approaching the safe threshold. Plan a hub handoff stop for these riders.`,
|
||||
count: warningRiders.length,
|
||||
rows: warningRiders.flatMap((rd) => rd.orders),
|
||||
detail: warningRiders.slice(0, 8).map(
|
||||
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} orders`
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
};
|
||||
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal file
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// ==============================|| Skill: Doorstep Stall Rescuer ||============================== //
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
const minutesBetween = (from, to) => {
|
||||
const a = parseDoormileTimestamp(from);
|
||||
const b = parseDoormileTimestamp(to);
|
||||
if (!a.isValid() || !b.isValid()) return null;
|
||||
return b.diff(a, 'minute');
|
||||
};
|
||||
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
export const DoorstepStallSkill = {
|
||||
id: 'skill_doorstep_stall',
|
||||
name: 'Doorstep Stall Rescuer',
|
||||
description: 'Flags riders who stamped arrival at consignee doorstep but have been stalled with no progress.',
|
||||
category: 'rider_operations',
|
||||
icon: 'MapPinOff',
|
||||
defaultEnabled: true,
|
||||
|
||||
thresholds: {
|
||||
arrivedStalledMin: {
|
||||
label: 'Doorstep Stall Timeout',
|
||||
description: 'Minutes after arriving at customer location before flagging as stalled.',
|
||||
value: 20,
|
||||
min: 10,
|
||||
max: 60,
|
||||
step: 5,
|
||||
unit: 'min'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- When a rider is stalled at a delivery location, suggest verifying customer contact or initiating automated phone verification.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'ping_stalled_rider',
|
||||
description: 'Sends an in-app ping to check if the rider needs help with gate access or customer contact.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
milerId: { type: 'number', description: 'Miler ID to reach' },
|
||||
message: { type: 'string', description: 'Optional prompt text' }
|
||||
},
|
||||
required: ['milerId']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'notify_miler',
|
||||
milerId: args.milerId,
|
||||
blastRadius: `Pings rider #${args.milerId} with doorstep assistance prompt.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], now, currentThresholds = {}) => {
|
||||
const stallLimit = currentThresholds.arrivedStalledMin ?? 20;
|
||||
|
||||
const stalled = rows
|
||||
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'arrived' && r.reachedat)
|
||||
.map((r) => ({ row: r, waitingMin: minutesBetween(r.reachedat, now) }))
|
||||
.filter((m) => m.waitingMin !== null && m.waitingMin >= stallLimit)
|
||||
.sort((a, b) => b.waitingMin - a.waitingMin);
|
||||
|
||||
if (!stalled.length) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'stalled_at_door',
|
||||
skillId: 'skill_doorstep_stall',
|
||||
severity: 'critical',
|
||||
title: `${stalled.length} rider${stalled.length === 1 ? '' : 's'} stalled at door for > ${stallLimit}m`,
|
||||
why: 'Rider app reported arrival but delivery confirmation has not completed. Possible gate, address, or absent consignee issue.',
|
||||
count: stalled.length,
|
||||
rows: stalled.map((m) => m.row),
|
||||
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — ${m.row.ridername || 'Rider'} waiting ${m.waitingMin} min`),
|
||||
proposal: {
|
||||
label: 'Message waiting riders & support',
|
||||
tool: 'notifyRider',
|
||||
scope: stalled.filter((m) => m.row.userid).map((m) => m.row),
|
||||
blastRadius: 'Sends an assistance check-in to stalled delivery riders.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal file
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// ==============================|| Skill: Fleet Load Balancer ||============================== //
|
||||
|
||||
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
|
||||
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
|
||||
|
||||
export const FleetBalancerSkill = {
|
||||
id: 'skill_fleet_balancer',
|
||||
name: 'Fleet Load Balancer',
|
||||
description: 'Prevents rider overload by flagging riders at maximum active parcel capacity while queue work waits.',
|
||||
category: 'fleet_optimization',
|
||||
icon: 'Scale',
|
||||
defaultEnabled: true,
|
||||
|
||||
thresholds: {
|
||||
riderActiveCap: {
|
||||
label: 'Rider Active Capacity Cap',
|
||||
description: 'Maximum concurrent active orders a single rider should carry simultaneously.',
|
||||
value: 3,
|
||||
min: 1,
|
||||
max: 6,
|
||||
step: 1,
|
||||
unit: 'orders'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- Keep rider concurrent workload at or below the specified active cap to prevent cumulative delivery delays.
|
||||
- Proactively redistribute un-picked orders from overloaded riders to available nearby fleet.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'balance_rider_load',
|
||||
description: 'Reallocates queued unpicked orders from a saturated rider to an available rider.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fromMilerId: { type: 'number', description: 'Overloaded miler ID' },
|
||||
toMilerId: { type: 'number', description: 'Available miler ID' }
|
||||
},
|
||||
required: ['fromMilerId', 'toMilerId']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'rebalance_workload',
|
||||
fromMilerId: args.fromMilerId,
|
||||
toMilerId: args.toMilerId,
|
||||
blastRadius: `Transfers queued parcels from Rider #${args.fromMilerId} to Rider #${args.toMilerId}.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], now, currentThresholds = {}) => {
|
||||
const activeCap = currentThresholds.riderActiveCap ?? 3;
|
||||
const safeRows = Array.isArray(rows) ? rows : [];
|
||||
|
||||
const unassignedCount = safeRows.filter((r) => String(r?.orderstatus || '').toLowerCase() === 'pending').length;
|
||||
const byRider = new Map();
|
||||
|
||||
safeRows.filter((r) => isOpen(r) && r.userid).forEach((r) => {
|
||||
const key = String(r.userid);
|
||||
if (!byRider.has(key)) byRider.set(key, { riderId: r.userid, name: r.ridername || `Rider ${r.userid}`, rows: [] });
|
||||
byRider.get(key).rows.push(r);
|
||||
});
|
||||
|
||||
const saturated = [...byRider.values()]
|
||||
.filter((entry) => entry.rows.length >= activeCap)
|
||||
.sort((a, b) => b.rows.length - a.rows.length);
|
||||
|
||||
if (!saturated.length || unassignedCount === 0) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'rider_saturation',
|
||||
skillId: 'skill_fleet_balancer',
|
||||
severity: 'watch',
|
||||
title: `${saturated.length} rider${saturated.length === 1 ? ' is' : 's are'} at capacity (≥${activeCap} jobs) while orders queue`,
|
||||
why: `Riders carrying ${activeCap}+ in-flight jobs are capped and will not receive newly queued orders.`,
|
||||
count: saturated.length,
|
||||
rows: saturated.flatMap((entry) => entry.rows),
|
||||
detail: saturated.slice(0, 8).map((entry) => `${entry.name} — ${entry.rows.length} concurrent orders`)
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal file
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// ==============================|| Skill: High-Value COD Escort ||============================== //
|
||||
|
||||
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
|
||||
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
export const HighValueCodSkill = {
|
||||
id: 'skill_high_value_cod',
|
||||
name: 'High-Value Cash Guardian',
|
||||
description: 'Audits large Cash-on-Delivery consignments to prevent loss, fraud, or unverified deliveries.',
|
||||
category: 'loss_prevention',
|
||||
icon: 'ShieldCheck',
|
||||
// OFF by default (2026-09-29): the rows this rule reads carry no payment amount or payment mode: /admin/bookings does not preload bookingpayments.
|
||||
// Enabled, it would read undefined on every row and report an all-clear board —
|
||||
// the silent failure normalise.js warns about. Turn it on only once the data exists.
|
||||
dataGap: 'no payment amount or payment mode: /admin/bookings does not preload bookingpayments',
|
||||
defaultEnabled: false,
|
||||
|
||||
thresholds: {
|
||||
codRiskThresholdAmount: {
|
||||
label: 'High-Value COD Threshold (₹)',
|
||||
description: 'Cash-on-Delivery amount above which an order is classified as high-risk cash asset.',
|
||||
value: 3000,
|
||||
min: 1000,
|
||||
max: 20000,
|
||||
step: 500,
|
||||
unit: '₹'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- High-value COD consignments must require OTP verification at the doorstep and verified senior rider assignment.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'enforce_otp_verification',
|
||||
description: 'Flags an order as strictly requiring consignee OTP delivery confirmation before handover.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bookingId: { type: 'number', description: 'Target booking ID' }
|
||||
},
|
||||
required: ['bookingId']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'require_otp',
|
||||
bookingId: args.bookingId,
|
||||
blastRadius: `Enables mandatory OTP confirmation on mobile app for parcel #${args.bookingId}.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], now, currentThresholds = {}) => {
|
||||
const minAmount = currentThresholds.codRiskThresholdAmount ?? 3000;
|
||||
const safeRows = Array.isArray(rows) ? rows : [];
|
||||
|
||||
const highValueCod = safeRows.filter((r) => {
|
||||
if (!isOpen(r)) return false;
|
||||
const isCod = String(r.paymenttype || r.payment_mode || '').toLowerCase().includes('cod') ||
|
||||
String(r.paymentstatus || '').toLowerCase() === 'pending';
|
||||
const amount = Number(r.orderamount || r.totalamount || r.price || 0);
|
||||
return isCod && amount >= minAmount;
|
||||
});
|
||||
|
||||
if (!highValueCod.length) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'high_value_cod_alert',
|
||||
skillId: 'skill_high_value_cod',
|
||||
severity: 'watch',
|
||||
title: `${highValueCod.length} high-value COD consignment${highValueCod.length === 1 ? '' : 's'} (≥ ₹${minAmount.toLocaleString()}) active`,
|
||||
why: `Large cash collection in transit requires mandatory OTP handover verification.`,
|
||||
count: highValueCod.length,
|
||||
rows: highValueCod,
|
||||
detail: highValueCod.slice(0, 8).map((r) => {
|
||||
const amt = Number(r.orderamount || r.totalamount || r.price || 0);
|
||||
return `${ref(r)} — ₹${amt.toLocaleString()} (${r.orderstatus || 'active'})`;
|
||||
}),
|
||||
proposal: {
|
||||
label: 'Verify OTP requirements',
|
||||
tool: 'enforce_otp_verification',
|
||||
scope: highValueCod,
|
||||
blastRadius: 'Sets strict OTP delivery guard on high-value cash deliveries.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal file
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// ==============================|| Skill: Hub Congestion Agent ||============================== //
|
||||
//
|
||||
// Detects parcels that have been sitting at a hub for too long without
|
||||
// being picked up by a rider. These represent blocked capacity at the hub
|
||||
// and direct SLA risk for the customer. This is critical for hub operations
|
||||
|
||||
|
||||
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
const HUB_STATUSES = new Set(['pending', 'accepted']); // at hub, not yet picked
|
||||
|
||||
const minutesBetween = (from, to) => {
|
||||
const a = parseDoormileTimestamp(from);
|
||||
const b = parseDoormileTimestamp(to);
|
||||
if (!a.isValid() || !b.isValid()) return null;
|
||||
return b.diff(a, 'minute');
|
||||
};
|
||||
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
export const HubCongestionSkill = {
|
||||
id: 'skill_hub_congestion',
|
||||
name: 'Hub Congestion Agent',
|
||||
description: 'Detects parcels sitting at a hub without rider pickup beyond the dwell threshold. Dispatches nearest idle rider.',
|
||||
category: 'sla_management',
|
||||
icon: 'ShieldAlert',
|
||||
defaultEnabled: true,
|
||||
|
||||
thresholds: {
|
||||
hubDwellMinutes: {
|
||||
label: 'Hub Dwell Threshold',
|
||||
description: 'Minutes a parcel can sit at hub without a rider pickup before raising a congestion alert.',
|
||||
value: 45,
|
||||
min: 15,
|
||||
max: 120,
|
||||
step: 5,
|
||||
unit: 'min'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- Parcels sitting at a hub beyond the dwell threshold block capacity for the next batch.
|
||||
- Identify the nearest idle rider in the same zone and dispatch immediately.
|
||||
- Prefer riders with the fewest active orders to avoid overloading.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'dispatch_hub_idle_parcels',
|
||||
description: 'Proposes dispatching a nearby idle rider to collect stalled hub parcels.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hubId: { type: 'string', description: 'Hub identifier where parcels are stalled' },
|
||||
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs to dispatch' },
|
||||
milerId: { type: 'number', description: 'Target idle rider user ID' }
|
||||
},
|
||||
required: ['bookingIds']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'dispatch_hub_parcels',
|
||||
hubId: args.hubId || 'unknown',
|
||||
bookingIds: args.bookingIds,
|
||||
milerId: args.milerId,
|
||||
blastRadius: `Dispatches rider to collect ${args.bookingIds?.length || 'stalled'} parcel(s) from hub. Updates parcel status to In-Transit.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], now, currentThresholds = {}) => {
|
||||
const dwellLimit = currentThresholds.hubDwellMinutes ?? 45;
|
||||
const findings = [];
|
||||
|
||||
const stalled = rows
|
||||
.filter((r) => HUB_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
|
||||
.map((r) => ({ row: r, dwellMin: minutesBetween(r.orderdate, now) }))
|
||||
.filter((m) => m.dwellMin !== null && m.dwellMin >= dwellLimit)
|
||||
.sort((a, b) => b.dwellMin - a.dwellMin);
|
||||
|
||||
if (stalled.length) {
|
||||
findings.push({
|
||||
id: 'hub_parcel_stalled',
|
||||
skillId: 'skill_hub_congestion',
|
||||
severity: 'critical',
|
||||
title: `${stalled.length} parcel${stalled.length === 1 ? '' : 's'} stalled at hub for >${dwellLimit}m`,
|
||||
why: 'Parcel has been at the hub beyond the acceptable dwell time with no rider assigned for pickup.',
|
||||
count: stalled.length,
|
||||
rows: stalled.map((m) => m.row),
|
||||
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — at hub for ${m.dwellMin} min`),
|
||||
proposal: {
|
||||
label: 'Dispatch idle rider to hub',
|
||||
tool: 'dispatch_hub_idle_parcels',
|
||||
scope: stalled.map((m) => m.row),
|
||||
blastRadius: 'Assigns nearest idle zone rider to collect and dispatch all stalled parcels.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
};
|
||||
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal file
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// ==============================|| Skill: Late Dispatch Agent ||============================== //
|
||||
//
|
||||
// Flags orders that have been accepted/created but not yet dispatched to a rider
|
||||
// within the acceptable dispatch window during business hours.
|
||||
// Late dispatch at creation cascades into late delivery at the door.
|
||||
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
const PENDING_DISPATCH_STATUSES = new Set(['pending']);
|
||||
|
||||
const minutesBetween = (from, to) => {
|
||||
const a = parseDoormileTimestamp(from);
|
||||
const b = parseDoormileTimestamp(to);
|
||||
if (!a.isValid() || !b.isValid()) return null;
|
||||
return b.diff(a, 'minute');
|
||||
};
|
||||
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
export const LateDispatchSkill = {
|
||||
id: 'skill_late_dispatch',
|
||||
name: 'Late Dispatch Agent',
|
||||
description: 'Flags orders accepted but sitting in queue without dispatch for too long. Triggers auto-assignment to prevent cascading delays.',
|
||||
category: 'sla_management',
|
||||
icon: 'ShieldAlert',
|
||||
defaultEnabled: true,
|
||||
|
||||
thresholds: {
|
||||
lateDispatchMinutes: {
|
||||
label: 'Dispatch Deadline',
|
||||
description: 'Minutes after order creation before flagging as late dispatch.',
|
||||
value: 30,
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 5,
|
||||
unit: 'min'
|
||||
},
|
||||
criticalDispatchMinutes: {
|
||||
label: 'Critical Dispatch Deadline',
|
||||
description: 'Minutes after order creation before escalating to critical alert.',
|
||||
value: 60,
|
||||
min: 30,
|
||||
max: 180,
|
||||
step: 10,
|
||||
unit: 'min'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- Orders waiting for dispatch beyond the threshold accumulate delay that can't be recovered at the doorstep.
|
||||
- Auto-assign to the nearest available rider immediately.
|
||||
- Prioritize orders with the earliest SLA deadlines when multiple are pending.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'trigger_auto_dispatch',
|
||||
description: 'Proposes auto-assigning late-pending orders to available riders in zone.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs requiring immediate dispatch' },
|
||||
reason: { type: 'string', description: 'Dispatch urgency reason' }
|
||||
},
|
||||
required: ['bookingIds']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'auto_dispatch',
|
||||
bookingIds: args.bookingIds,
|
||||
reason: args.reason || 'Late dispatch threshold exceeded',
|
||||
blastRadius: `Auto-assigns ${args.bookingIds?.length || 'late'} order(s) to nearest available zone riders. Rider notifications will be sent.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], now, currentThresholds = {}) => {
|
||||
const lateThreshold = currentThresholds.lateDispatchMinutes ?? 30;
|
||||
const criticalThreshold = currentThresholds.criticalDispatchMinutes ?? 60;
|
||||
const findings = [];
|
||||
|
||||
const allPending = rows
|
||||
.filter((r) => PENDING_DISPATCH_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
|
||||
.map((r) => ({ row: r, waitMin: minutesBetween(r.orderdate, now) }))
|
||||
.filter((m) => m.waitMin !== null && m.waitMin >= lateThreshold)
|
||||
.sort((a, b) => b.waitMin - a.waitMin);
|
||||
|
||||
const critical = allPending.filter((m) => m.waitMin >= criticalThreshold);
|
||||
const warning = allPending.filter((m) => m.waitMin >= lateThreshold && m.waitMin < criticalThreshold);
|
||||
|
||||
if (critical.length) {
|
||||
findings.push({
|
||||
id: 'late_dispatch_critical',
|
||||
skillId: 'skill_late_dispatch',
|
||||
severity: 'critical',
|
||||
title: `${critical.length} order${critical.length === 1 ? '' : 's'} critically late for dispatch (>${criticalThreshold}m)`,
|
||||
why: `Order has been sitting in queue for over ${criticalThreshold} minutes with no rider assigned. Delivery SLA is at severe risk.`,
|
||||
count: critical.length,
|
||||
rows: critical.map((m) => m.row),
|
||||
detail: critical.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min for dispatch`),
|
||||
proposal: {
|
||||
label: 'Auto-dispatch to available riders',
|
||||
tool: 'trigger_auto_dispatch',
|
||||
scope: critical.map((m) => m.row),
|
||||
blastRadius: 'Immediately assigns nearest available riders. Overrides manual queue priority.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (warning.length) {
|
||||
findings.push({
|
||||
id: 'late_dispatch_warning',
|
||||
skillId: 'skill_late_dispatch',
|
||||
severity: 'warning',
|
||||
title: `${warning.length} order${warning.length === 1 ? '' : 's'} approaching dispatch deadline (>${lateThreshold}m)`,
|
||||
why: `Order has been waiting ${lateThreshold}+ minutes for rider assignment. Act now to prevent SLA breach.`,
|
||||
count: warning.length,
|
||||
rows: warning.map((m) => m.row),
|
||||
detail: warning.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min`),
|
||||
proposal: {
|
||||
label: 'Dispatch now',
|
||||
tool: 'trigger_auto_dispatch',
|
||||
scope: warning.map((m) => m.row),
|
||||
blastRadius: 'Assigns available riders to pending orders based on zone proximity.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
// ==============================|| Skill: Rider Battery & Device Safety ||============================== //
|
||||
|
||||
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
|
||||
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
export const RiderBatterySafetySkill = {
|
||||
id: 'skill_rider_battery_safety',
|
||||
name: 'Rider Device & SOS Safety',
|
||||
description: 'Monitors rider device telemetry to prevent unreachability due to low battery during active deliveries.',
|
||||
category: 'rider_safety',
|
||||
icon: 'BatteryWarning',
|
||||
// OFF by default (2026-09-29): the rows this rule reads carry no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows.
|
||||
// Enabled, it would read undefined on every row and report an all-clear board —
|
||||
// the silent failure normalise.js warns about. Turn it on only once the data exists.
|
||||
dataGap: 'no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows',
|
||||
defaultEnabled: false,
|
||||
|
||||
thresholds: {
|
||||
criticalBatteryPercent: {
|
||||
label: 'Critical Battery Level (%)',
|
||||
description: 'Threshold below which rider device is considered at risk of shutdown.',
|
||||
value: 15,
|
||||
min: 5,
|
||||
max: 30,
|
||||
step: 5,
|
||||
unit: '%'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- When a rider has critical battery while carrying in-transit orders, advise charging or offloading remaining pending pickups.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'alert_low_battery_rider',
|
||||
description: 'Notifies rider to connect portable charger or report to nearest hub.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
milerId: { type: 'number', description: 'Target miler user ID' }
|
||||
},
|
||||
required: ['milerId']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'battery_alert',
|
||||
milerId: args.milerId,
|
||||
blastRadius: `Sends device power warning notification to Rider #${args.milerId}.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], now, currentThresholds = {}) => {
|
||||
const minBattery = currentThresholds.criticalBatteryPercent ?? 15;
|
||||
const safeRows = Array.isArray(rows) ? rows : [];
|
||||
|
||||
const atRisk = safeRows.filter((r) => {
|
||||
if (!isOpen(r) || !r.userid) return false;
|
||||
const battery = Number(r.battery_percentage || r.battery_level || r.battery || 100);
|
||||
return battery > 0 && battery <= minBattery;
|
||||
});
|
||||
|
||||
if (!atRisk.length) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'rider_critical_battery',
|
||||
skillId: 'skill_rider_battery_safety',
|
||||
severity: 'warning',
|
||||
title: `${atRisk.length} active delivery under low rider device battery (≤ ${minBattery}%)`,
|
||||
why: `Rider phone is nearing shutdown, risking lost GPS telemetry and customer communication failure.`,
|
||||
count: atRisk.length,
|
||||
rows: atRisk,
|
||||
detail: atRisk.slice(0, 8).map((r) => {
|
||||
const bat = r.battery_percentage || r.battery_level || r.battery || 'Low';
|
||||
return `${ref(r)} — ${r.ridername || 'Rider'} (${bat}% battery)`;
|
||||
}),
|
||||
proposal: {
|
||||
label: 'Send power alert',
|
||||
tool: 'alert_low_battery_rider',
|
||||
scope: atRisk,
|
||||
blastRadius: 'Sends charging prompt to rider.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal file
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal file
@@ -0,0 +1,157 @@
|
||||
// ==============================|| Skill: SLA Breach Guardian ||============================== //
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
|
||||
const IN_FLIGHT_STATUSES = new Set(['picked', 'active']);
|
||||
|
||||
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
|
||||
|
||||
const minutesBetween = (from, to) => {
|
||||
const a = parseDoormileTimestamp(from);
|
||||
const b = parseDoormileTimestamp(to);
|
||||
if (!a.isValid() || !b.isValid()) return null;
|
||||
return b.diff(a, 'minute');
|
||||
};
|
||||
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
|
||||
export const SlaGuardianSkill = {
|
||||
id: 'skill_sla_guardian',
|
||||
name: 'SLA Breach Guardian',
|
||||
description: 'Monitors promised customer delivery ETAs and flags both breached and imminent SLA violations.',
|
||||
category: 'sla_management',
|
||||
icon: 'ShieldAlert',
|
||||
defaultEnabled: true,
|
||||
|
||||
thresholds: {
|
||||
slaRiskWindowMin: {
|
||||
label: 'At-Risk Warning Window',
|
||||
description: 'Minutes before promised delivery window to flag an un-picked parcel as at-risk.',
|
||||
value: 45,
|
||||
min: 15,
|
||||
max: 90,
|
||||
step: 5,
|
||||
unit: 'min'
|
||||
},
|
||||
unassignedAgingMin: {
|
||||
label: 'Unassigned Aging Threshold',
|
||||
description: 'Minutes an order can remain pending with no assigned rider before raising an alarm.',
|
||||
value: 60,
|
||||
min: 15,
|
||||
max: 120,
|
||||
step: 5,
|
||||
unit: 'min'
|
||||
}
|
||||
},
|
||||
|
||||
instructions: `
|
||||
- When orders exceed promised delivery ETA or are within the risk window without being in flight, prioritize immediate reassignment.
|
||||
- Select nearby available riders with high historical on-time fulfillment rates.
|
||||
`,
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: 'reassign_sla_critical_order',
|
||||
description: 'Proposes transferring an SLA-at-risk order to an optimal nearby rider.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bookingId: { type: 'number', description: 'Booking ID to reassign' },
|
||||
targetMilerId: { type: 'number', description: 'Target miler user ID' },
|
||||
reason: { type: 'string', description: 'Justification' }
|
||||
},
|
||||
required: ['bookingId', 'targetMilerId']
|
||||
},
|
||||
handler: async (args) => ({
|
||||
success: true,
|
||||
proposal: {
|
||||
action: 'reassign_miler',
|
||||
bookingId: args.bookingId,
|
||||
milerId: args.targetMilerId,
|
||||
reason: args.reason || 'SLA risk mitigation',
|
||||
blastRadius: `Reassigns parcel #${args.bookingId} and updates customer ETA tracking.`
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
evaluate: (rows = [], now, currentThresholds = {}) => {
|
||||
const riskWindow = currentThresholds.slaRiskWindowMin ?? 45;
|
||||
const agingWindow = currentThresholds.unassignedAgingMin ?? 60;
|
||||
const findings = [];
|
||||
|
||||
// 1. Breached SLA
|
||||
const breached = rows
|
||||
.filter((r) => isOpen(r) && r.expecteddeliverytime)
|
||||
.map((r) => ({ row: r, overdueMin: minutesBetween(r.expecteddeliverytime, now) }))
|
||||
.filter((m) => m.overdueMin !== null && m.overdueMin > 0)
|
||||
.sort((a, b) => b.overdueMin - a.overdueMin);
|
||||
|
||||
if (breached.length) {
|
||||
findings.push({
|
||||
id: 'sla_breached',
|
||||
skillId: 'skill_sla_guardian',
|
||||
severity: 'critical',
|
||||
title: `${breached.length} ${breached.length === 1 ? 'parcel is' : 'parcels are'} past promised delivery time`,
|
||||
why: 'Estimated delivery time on service option has elapsed and parcel is not yet delivered.',
|
||||
count: breached.length,
|
||||
rows: breached.map((m) => m.row),
|
||||
detail: breached.slice(0, 8).map((m) => `${ref(m.row)} — ${m.overdueMin} min overdue, status: ${m.row.orderstatus}`),
|
||||
proposal: {
|
||||
label: 'Notify assigned riders',
|
||||
tool: 'notifyRider',
|
||||
scope: breached.filter((m) => m.row.userid).map((m) => m.row),
|
||||
blastRadius: 'Sends an urgent priority notification to the assigned rider.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. At-Risk SLA
|
||||
const atRisk = rows
|
||||
.filter((r) => isOpen(r) && r.expecteddeliverytime && !IN_FLIGHT_STATUSES.has(String(r.orderstatus).toLowerCase()))
|
||||
.map((r) => ({ row: r, dueInMin: minutesBetween(now, r.expecteddeliverytime) }))
|
||||
.filter((m) => m.dueInMin !== null && m.dueInMin > 0 && m.dueInMin <= riskWindow)
|
||||
.sort((a, b) => a.dueInMin - b.dueInMin);
|
||||
|
||||
if (atRisk.length) {
|
||||
findings.push({
|
||||
id: 'sla_at_risk',
|
||||
skillId: 'skill_sla_guardian',
|
||||
severity: 'warning',
|
||||
title: `${atRisk.length} parcel${atRisk.length === 1 ? '' : 's'} due within ${riskWindow}m and not yet moving`,
|
||||
why: 'Inside the delivery promise window but parcel pickup has not begun.',
|
||||
count: atRisk.length,
|
||||
rows: atRisk.map((m) => m.row),
|
||||
detail: atRisk.slice(0, 8).map((m) => `${ref(m.row)} — due in ${m.dueInMin} min, status: ${m.row.orderstatus}`)
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Unassigned Aging
|
||||
const aging = rows
|
||||
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'pending')
|
||||
.map((r) => ({ row: r, ageMin: minutesBetween(r.orderdate, now) }))
|
||||
.filter((m) => m.ageMin !== null && m.ageMin >= agingWindow)
|
||||
.sort((a, b) => b.ageMin - a.ageMin);
|
||||
|
||||
if (aging.length) {
|
||||
findings.push({
|
||||
id: 'unassigned_aging',
|
||||
skillId: 'skill_sla_guardian',
|
||||
severity: 'warning',
|
||||
title: `${aging.length} booking${aging.length === 1 ? '' : 's'} unassigned for > ${agingWindow}m`,
|
||||
why: 'Order has been sitting in queue with no assigned miler.',
|
||||
count: aging.length,
|
||||
rows: aging.map((m) => m.row),
|
||||
detail: aging.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.ageMin} min`),
|
||||
proposal: {
|
||||
label: 'Auto-assign available rider',
|
||||
tool: 'assignMiler',
|
||||
scope: aging.map((m) => m.row),
|
||||
blastRadius: 'Initiates dispatch matching across idle zone riders.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
};
|
||||
10
src/lib/assistant/skills/index.js
Normal file
10
src/lib/assistant/skills/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export { SkillRegistry, DEFAULT_SKILLS } from './SkillRegistry';
|
||||
export { useSkillRegistrySync } from './useSkillRegistrySync';
|
||||
export { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
|
||||
export { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
|
||||
export { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
|
||||
export { HighValueCodSkill } from './definitions/HighValueCodSkill';
|
||||
export { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
|
||||
export { HubCongestionSkill } from './definitions/HubCongestionSkill';
|
||||
export { LateDispatchSkill } from './definitions/LateDispatchSkill';
|
||||
export { CashExposureSkill } from './definitions/CashExposureSkill';
|
||||
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal file
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAiSkills } from '@/lib/doormileHooks';
|
||||
import { SkillRegistry } from './SkillRegistry';
|
||||
|
||||
/**
|
||||
* Keeps the in-memory SkillRegistry in step with the backend agent registry.
|
||||
*
|
||||
* Mounted once, in the console shell, so the Exceptions banner and the chat's
|
||||
* "what needs attention" briefing both run on the same, tuned settings. A
|
||||
* change saved in Settings → Skills & Tools invalidates the registry query,
|
||||
* this refetches, and every subscriber re-evaluates.
|
||||
*
|
||||
* `enabled` is false for partner-tenant logins: the registry is Doormile-staff
|
||||
* only (the backend answers 403), so they run on code defaults, which the
|
||||
* banner states.
|
||||
*/
|
||||
export function useSkillRegistrySync(enabled = true) {
|
||||
const { data, isError } = useAiSkills({ enabled, retry: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (data) SkillRegistry.applyRegistryConfig(data);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError || !enabled) SkillRegistry.clearRegistryConfig();
|
||||
}, [isError, enabled]);
|
||||
}
|
||||
75
src/lib/clientOnboarding.js
Normal file
75
src/lib/clientOnboarding.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Who may onboard a new client (create a tenant and its console login).
|
||||
*
|
||||
* The server is the real gate — POST /admin/clients/onboard answers 403 to
|
||||
* anyone outside CLIENT_ONBOARDING_OWNERS (default admin@doormile.com), and
|
||||
* only for Doormile staff with roleid 1. This list only decides whether the
|
||||
* console SHOWS the page and its links, so keep it matching the server's.
|
||||
*/
|
||||
const DEFAULT_OWNERS = 'admin@doormile.com';
|
||||
|
||||
export const CLIENT_ONBOARDING_OWNERS = String(import.meta.env?.VITE_CLIENT_ONBOARDING_OWNERS || DEFAULT_OWNERS)
|
||||
.split(',')
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* @param {any} user the signed-in console user (AuthContext)
|
||||
* @param {boolean} isClient true for a partner-tenant login
|
||||
*/
|
||||
export function canOnboardClients(user, isClient) {
|
||||
if (!user || isClient) return false;
|
||||
const email = String(user.email || '').trim().toLowerCase();
|
||||
const role = String(user.role || '').toLowerCase();
|
||||
return CLIENT_ONBOARDING_OWNERS.includes(email) && (role === 'admin' || String(user.roleid) === '1');
|
||||
}
|
||||
|
||||
/** A random password an operator can hand over: 14 chars, no look-alikes. */
|
||||
export function generateClientPassword(length = 14) {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#%&*';
|
||||
const bytes = new Uint32Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i += 1) out += chars[bytes[i] % chars.length];
|
||||
// Guarantee a digit and a letter, so a generated password is never all one class.
|
||||
if (!/\d/.test(out)) out = `${out.slice(0, -1)}7`;
|
||||
if (!/[a-z]/i.test(out)) out = `k${out.slice(1)}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Client-side mirror of the server's validation, for inline errors. */
|
||||
export function validateOnboarding(form) {
|
||||
const errors = {};
|
||||
const phone = String(form.phone || '').replace(/\D/g, '');
|
||||
if (String(form.companyname || '').trim().length < 2) errors.companyname = 'Enter the company name';
|
||||
if (String(form.contactname || '').trim().length < 2) errors.contactname = "Enter the contact person's name";
|
||||
if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(String(form.email || '').trim())) errors.email = 'Enter a valid email address';
|
||||
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = 'Enter a 10-digit mobile number';
|
||||
if (!form.applocationid) errors.applocationid = "Choose the client's operating city";
|
||||
const pw = String(form.password || '');
|
||||
if (pw.length < 8) errors.password = 'At least 8 characters';
|
||||
else if (pw.length > 72) errors.password = 'At most 72 characters';
|
||||
else if (pw.toLowerCase() === String(form.email || '').trim().toLowerCase() || pw === phone) {
|
||||
errors.password = 'Must not be the email or the phone number';
|
||||
}
|
||||
if (form.confirm !== pw) errors.confirm = 'The passwords do not match';
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why onboarding cannot run, from the failed GET /admin/clients/cities. The
|
||||
* page shows this instead of an empty city list: the likeliest cause is a
|
||||
* console pointed at an API that does not have the onboarding endpoints yet.
|
||||
*/
|
||||
export function onboardingUnavailableMessage(err) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
return 'This API server does not have client onboarding yet. The backend changes that add it need to be deployed first.';
|
||||
}
|
||||
if (status === 403) {
|
||||
return 'The server refused this login. Client onboarding is only for the admin@doormile.com account, signed in as Doormile staff.';
|
||||
}
|
||||
if (status === 401) return 'Your session has expired. Sign in again.';
|
||||
if (!err?.response) return 'Could not reach the API server. Check your connection and try again.';
|
||||
return err.response.data?.message || `The server answered ${status}. Try again shortly.`;
|
||||
}
|
||||
@@ -49,6 +49,11 @@ export const calculateDrivingRoute = async (origin, destination) => {
|
||||
const data = await response.json();
|
||||
if (data.routes && data.routes.length > 0) {
|
||||
const route = data.routes[0];
|
||||
// `distance` stays whole km: it is what pricing and bulk upload bill on,
|
||||
// and changing its precision would change invoices. `meters` is the
|
||||
// unrounded road length, for display only — a 120 m drop rounds to
|
||||
// 0 km here and must not be shown as "0 km" or floored to "1 km".
|
||||
const meters = Math.max(0, Math.round(route.distance));
|
||||
const distanceKm = Math.round(route.distance / 1000);
|
||||
const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null;
|
||||
// OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng]
|
||||
@@ -56,6 +61,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
|
||||
lastRouteDurationMin = durationMin;
|
||||
return {
|
||||
distance: Math.max(0, distanceKm),
|
||||
meters,
|
||||
minutes: durationMin,
|
||||
polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]],
|
||||
resolved: true
|
||||
@@ -81,6 +87,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
|
||||
lastRouteDurationMin = null;
|
||||
return {
|
||||
distance: aerialDistance,
|
||||
meters: Math.round(R * c * 1.3 * 1000),
|
||||
minutes: null,
|
||||
polyline: [[lat1, lon1], [lat2, lon2]],
|
||||
resolved: true
|
||||
@@ -95,6 +102,30 @@ export const calculateDrivingDistance = async (origin, destination) => {
|
||||
return res.distance;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a road length for display: metres under 1 km (to the nearest 10 m),
|
||||
* km with one decimal above it. Returns '—' when the length is unknown.
|
||||
* @param {number} meters
|
||||
*/
|
||||
export const formatRouteDistance = (meters) => {
|
||||
if (meters === null || meters === undefined) return '—';
|
||||
const m = Number(meters);
|
||||
if (!Number.isFinite(m) || m < 0) return '—';
|
||||
const tens = Math.round(m / 10) * 10;
|
||||
if (tens < 1000) return `${tens} m`;
|
||||
return `${Number((m / 1000).toFixed(1))} km`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a drive time for display. OSRM reports seconds; a sub-minute leg
|
||||
* rounds to 0 and must read "< 1 min", not fall through to a default.
|
||||
* @param {number|null} minutes
|
||||
*/
|
||||
export const formatRouteDuration = (minutes) => {
|
||||
if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return '—';
|
||||
return Number(minutes) < 1 ? '< 1 min' : `${minutes} mins`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates total charge based on distance and pricing tier
|
||||
*/
|
||||
|
||||
@@ -67,6 +67,14 @@ export const KEYS = {
|
||||
carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit],
|
||||
|
||||
appLocations: ['doormile', 'app-locations'],
|
||||
|
||||
// AI agent registry. One prefix, so a write invalidates agents, skills,
|
||||
// tools and the audit together — a skill toggle changes an agent's counts.
|
||||
aiRegistry: ['doormile', 'ai'],
|
||||
aiAgents: ['doormile', 'ai', 'agents'],
|
||||
aiSkills: ['doormile', 'ai', 'skills'],
|
||||
aiTools: ['doormile', 'ai', 'tools'],
|
||||
aiAudit: ['doormile', 'ai', 'audit'],
|
||||
};
|
||||
|
||||
/* ── Shared helpers ───────────────────────────────────────────────────────── */
|
||||
@@ -179,6 +187,23 @@ export const useTenant = (id, options) =>
|
||||
export const useCreateTenant = () =>
|
||||
useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' });
|
||||
|
||||
export const useOnboardedClients = (options) =>
|
||||
useQuery({ queryKey: [...KEYS.tenants, 'onboarded'], queryFn: api.getOnboardedClients, staleTime: 30_000, ...options });
|
||||
|
||||
export const useOnboardingCities = (options) =>
|
||||
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
|
||||
|
||||
// No toasts: the page shows its own success panel and inline errors.
|
||||
export const useOnboardClient = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.onboardClient,
|
||||
onSuccess: (result) => {
|
||||
if (result?.success !== false) queryClient.invalidateQueries({ queryKey: KEYS.tenants });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTenant = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ id, data }) => api.updateAdminTenant(id, data),
|
||||
@@ -792,3 +817,57 @@ export const useOrderPercentages = (startdate, enddate, options) =>
|
||||
staleTime: 30_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
/* ── AI agent registry (Settings → Skills & Tools) ────────────────────────── */
|
||||
|
||||
export const useAiAgents = (options) =>
|
||||
useQuery({ queryKey: KEYS.aiAgents, queryFn: api.getAiAgents, staleTime: 30_000, ...options });
|
||||
|
||||
export const useAiSkills = (options) =>
|
||||
useQuery({ queryKey: KEYS.aiSkills, queryFn: api.getAiSkills, staleTime: 30_000, ...options });
|
||||
|
||||
export const useAiTools = (options) =>
|
||||
useQuery({ queryKey: KEYS.aiTools, queryFn: api.getAiTools, staleTime: 5 * 60_000, ...options });
|
||||
|
||||
export const useUpdateAiSkill = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ id, patch }) => api.updateAiSkill(id, patch),
|
||||
invalidates: [KEYS.aiRegistry],
|
||||
successMessage: 'Skill updated',
|
||||
});
|
||||
|
||||
export const useCreateAiSkill = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: (skill) => api.createAiSkill(skill),
|
||||
invalidates: [KEYS.aiRegistry],
|
||||
successMessage: 'Skill registered',
|
||||
});
|
||||
|
||||
export const useUpdateAiAgent = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ id, patch }) => api.updateAiAgent(id, patch),
|
||||
invalidates: [KEYS.aiRegistry],
|
||||
successMessage: 'Agent updated',
|
||||
});
|
||||
|
||||
// Read-only, and it changes as agents work: refetched every minute while open.
|
||||
export const useAiInsights = (days = 7, options) =>
|
||||
useQuery({
|
||||
queryKey: [...KEYS.aiRegistry, 'insights', days],
|
||||
queryFn: () => api.getAiInsights(days),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
// A playground run. No toasts and no invalidation: it changes nothing, and the
|
||||
// Test tab shows its own result or error inline.
|
||||
export const useRunAiPlayground = () => useMutation({ mutationFn: api.runAiPlayground });
|
||||
|
||||
export const useAiDecisions = (params = {}, options) =>
|
||||
useQuery({
|
||||
queryKey: [...KEYS.aiRegistry, 'decisions', params],
|
||||
queryFn: () => api.getAiDecisions(params),
|
||||
staleTime: 30_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user