update on the agents and registry side
This commit is contained in:
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user