updates on the ui and updates agents
This commit is contained in:
@@ -165,7 +165,7 @@ export const AGENTS = [
|
||||
load: 0,
|
||||
blurb: 'Builds delivery routes from zones and available hubs. Ten-minute cache.',
|
||||
detail:
|
||||
'Sequences waypoints into routes using zone membership and hub availability. This is the piece the Go backend has no equivalent for — HubBatchAssign decides who gets a booking, never what order a rider runs their stops in.',
|
||||
'Sequences waypoints into routes using zone membership and hub availability. SIMULATION: its zones and traffic patterns are hard-coded in _init_zones()/_init_traffic_patterns() and it makes no backend or routing-API call at all. Real stop ordering is done by internal/routing against the Valhalla-backed Route Optimization API, not here. The registry records it with status simulation.',
|
||||
subscribes: [],
|
||||
publishes: 'Nothing on the bus.',
|
||||
api: 'No.',
|
||||
@@ -186,7 +186,7 @@ export const AGENTS = [
|
||||
load: 0,
|
||||
blurb: 'Hub operations — transit records, capacity, what is sitting where.',
|
||||
detail:
|
||||
'Tracks inventory at each hub and the transit records moving between them, and answers capacity questions about whether a hub can absorb more volume.',
|
||||
'Tracks inventory at each hub and the transit records moving between them, and answers capacity questions about whether a hub can absorb more volume. SIMULATION: its hubs are 8 hard-coded entries in _init_hubs() (DL-HUB-01 and friends, with invented connected_hubs graphs) — not the hubs table. The registry records it with status simulation.',
|
||||
subscribes: [],
|
||||
publishes: 'Nothing on the bus.',
|
||||
api: 'No.',
|
||||
@@ -207,7 +207,7 @@ export const AGENTS = [
|
||||
load: 0,
|
||||
blurb: 'Vehicles: status, capacity and live tracking.',
|
||||
detail:
|
||||
"Holds vehicle state and assignment, and answers which vehicle can carry a given load. It overlaps with the Go backend's own Vehicle model, which is the live source today.",
|
||||
"Holds vehicle state and assignment, and answers which vehicle can carry a given load. It overlaps with the Go backend's own Vehicle model, which is the live source today. SIMULATION: its fleet is 19 vehicles hard-coded in _init_fleet() across Delhi, Mumbai, Bangalore, Hyderabad, Pune and Kolkata — not the vehicles table, and not the cities Doormile serves. The registry records it with status simulation for this reason.",
|
||||
subscribes: [],
|
||||
publishes: 'Nothing on the bus.',
|
||||
api: 'No.',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// • 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';
|
||||
import { getMilers, buildMilerLookup, notifyRider, batchAssignBookings } from '@/api/doormile';
|
||||
|
||||
/** A stable, human-facing reference for a row. */
|
||||
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
|
||||
@@ -86,6 +86,9 @@ const executeNotifyRider = async (scope, message) => {
|
||||
|
||||
return {
|
||||
ok: notified.length > 0,
|
||||
// Same contract as executeAssignMiler: say whether this was partial rather
|
||||
// than leaving every caller to work it out from a different field.
|
||||
partial: notified.length > 0 && unreachable.length > 0,
|
||||
message: parts.join(' ') || 'Nothing was sent.',
|
||||
notified,
|
||||
unreachable,
|
||||
@@ -93,18 +96,86 @@ const executeNotifyRider = async (scope, message) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ---- assignMiler: deliberately NOT an executor --------------------------------
|
||||
// ---- assignMiler ------------------------------------------------------------
|
||||
//
|
||||
// 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.
|
||||
// This WAS review-only, and the note explaining why is worth keeping because
|
||||
// it names the actual constraint:
|
||||
//
|
||||
// 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.
|
||||
// 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. 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.
|
||||
//
|
||||
// The resolution was the backend decision that note called for:
|
||||
// POST /admin/bookings/batch-assign now runs the same solver as the hub route
|
||||
// (controllers/batchAssignService.go) under admin auth, so the console gets the
|
||||
// solver that PICKS riders rather than one that needs them named.
|
||||
//
|
||||
// ⚠ This commits. There is no preview/reconcile step on either batch-assign
|
||||
// route, so the proposal gate in the UI is the only thing between a finding and
|
||||
// a real assignment. canExecuteProposal must stay the gate, and the card must
|
||||
// keep requiring an explicit click — do not make this run automatically.
|
||||
const executeAssignMiler = async (scope) => {
|
||||
const rows = (scope || []).filter((r) => r?.bookingid);
|
||||
if (!rows.length) {
|
||||
return { ok: false, message: 'None of these findings carries a booking to assign.', sourceCalls: [] };
|
||||
}
|
||||
|
||||
const bookingIds = [...new Set(rows.map((r) => Number(r.bookingid)))];
|
||||
const target = 'POST /admin/bookings/batch-assign';
|
||||
|
||||
try {
|
||||
const res = await batchAssignBookings(bookingIds);
|
||||
// The backend reports per booking, and a partial success is a partial
|
||||
// success — the same rule executeNotifyRider follows. Reporting "assigned"
|
||||
// when four of nine landed sends a dispatcher away from five parcels that
|
||||
// still have nobody.
|
||||
const data = res?.data || res || {};
|
||||
const assigned = Number(data.assigned ?? 0);
|
||||
const skipped = Number(data.skipped ?? 0);
|
||||
const sequenced = Number(data.riderssequenced ?? 0);
|
||||
const reasons = (data.results || [])
|
||||
.filter((r) => !r?.assigned && r?.reason)
|
||||
.map((r) => `${r.bookingno || `#${r.bookingid}`} (${r.reason})`);
|
||||
|
||||
const parts = [];
|
||||
if (assigned) parts.push(`Assigned ${assigned} order${assigned === 1 ? '' : 's'}.`);
|
||||
if (skipped) parts.push(`Could not assign ${skipped}: ${reasons.join('; ') || 'no reason given'}.`);
|
||||
// Stop order needs ROUTE_OPTIMIZER_URL configured. Saying nothing when it
|
||||
// is zero would leave the operator assuming routes were ordered.
|
||||
if (assigned && !sequenced) parts.push('Stops were not sequenced (route optimiser unavailable).');
|
||||
else if (sequenced) parts.push(`Sequenced ${sequenced} rider${sequenced === 1 ? '' : 's'}.`);
|
||||
|
||||
return {
|
||||
ok: assigned > 0,
|
||||
// Declared, not inferred. reportActed used to detect a partial run by
|
||||
// looking for an `unreachable` array, which only the notify executor
|
||||
// returns — so a batch that assigned two of three was recorded as a
|
||||
// clean success. The message said "partial", the telemetry said "ok",
|
||||
// and the measurement the findings table exists for was wrong.
|
||||
partial: assigned > 0 && skipped > 0,
|
||||
message: parts.join(' ') || 'Nothing was assigned.',
|
||||
sourceCalls: [{
|
||||
name: 'batchAssignBookings',
|
||||
target,
|
||||
status: assigned > 0 ? 'complete' : 'error',
|
||||
stats: `${assigned} assigned, ${skipped} skipped, ${sequenced} sequenced`,
|
||||
...(assigned === 0 ? { errorMessage: reasons.join('; ') || 'no rider under capacity' } : {})
|
||||
}]
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: err?.message || 'The assignment did not complete.',
|
||||
sourceCalls: [{
|
||||
name: 'batchAssignBookings', target, status: 'error',
|
||||
errorMessage: err?.message || 'assignment failed'
|
||||
}]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ---- registry ---------------------------------------------------------------
|
||||
|
||||
@@ -127,8 +198,38 @@ const EXECUTORS = {
|
||||
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".
|
||||
),
|
||||
|
||||
// alert_low_battery_rider: the same endpoint notifyRider already uses, so
|
||||
// this is a message, not a new capability. The registry seed names
|
||||
// POST /admin/milers/:id/notify as its target and it was marked REVIEW ONLY
|
||||
// only because nothing had been wired to it.
|
||||
//
|
||||
// The tool key is snake_case here, unlike notifyRider above, because
|
||||
// RiderBatterySafetySkill.js:84 emits `alert_low_battery_rider` and this map
|
||||
// is keyed on the verb a finding actually carries — not on a convention.
|
||||
// Renaming the skill's verb to match would be the tidier fix and a wider
|
||||
// change; it is not worth touching a shipped skill's output for.
|
||||
alert_low_battery_rider: (finding) =>
|
||||
executeNotifyRider(
|
||||
finding?.proposal?.scope,
|
||||
'Low battery: please charge now, or report to the nearest hub if you cannot.'
|
||||
),
|
||||
|
||||
assignMiler: (finding) => executeAssignMiler(finding?.proposal?.scope),
|
||||
|
||||
// trigger_auto_dispatch: LateDispatchSkill proposes "auto-assign orders that
|
||||
// have waited too long for dispatch", which is exactly what batch-assign
|
||||
// does. seed.go marked it Target: "none yet" because no route picked riders
|
||||
// for an admin login; /admin/bookings/batch-assign now does, so this needed
|
||||
// no new capability — only the realisation that it is the same action under
|
||||
// a different name.
|
||||
//
|
||||
// Deliberately NOT given its own executor: two code paths assigning riders
|
||||
// would be a second definition of the same write, and the codebase already
|
||||
// carries three (internal/assignment's AI path, AssignMilerToBooking, and
|
||||
// the batch solver). One more would be the one that drifts.
|
||||
trigger_auto_dispatch: (finding) => executeAssignMiler(finding?.proposal?.scope)
|
||||
};
|
||||
|
||||
/** Whether a finding's proposal can actually be carried out. */
|
||||
|
||||
150
src/lib/assistant/agent/findingReport.js
Normal file
150
src/lib/assistant/agent/findingReport.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// ==============================|| Doormile AI — finding persistence ||============================== //
|
||||
//
|
||||
// Reports what the rule skills noticed to the backend, so it survives the
|
||||
// render that produced it.
|
||||
//
|
||||
// The eight skills run here, in the operator's browser, on a 60-second React
|
||||
// Query interval — and their output was discarded every cycle. Three questions
|
||||
// had no answer at all:
|
||||
//
|
||||
// • Has this booking been flagged before, and how many times?
|
||||
// • Which skills fire most, and which are ignored every single time?
|
||||
// • Did the proposal an operator carried out actually clear the finding?
|
||||
//
|
||||
// The third is the one worth having. AgentOperationsBanner already re-runs its
|
||||
// scan after executing a proposal precisely to see whether the finding
|
||||
// disappears — that evidence existed for one render and then vanished.
|
||||
//
|
||||
// ─── Rules this module follows ───────────────────────────────────────────────
|
||||
//
|
||||
// • It is FIRE-AND-FORGET and never throws. Reporting is observability; the
|
||||
// briefing must render identically whether or not the POST lands. A
|
||||
// dispatcher looking at a stalled parcel does not care that telemetry is
|
||||
// down, and must not be shown an error about it.
|
||||
//
|
||||
// • It sends the CLEARED set too. Only the console knows the full set it
|
||||
// evaluated, so only it can tell the backend which fingerprints are gone.
|
||||
// The backend cannot distinguish "resolved" from "the operator closed the
|
||||
// tab".
|
||||
//
|
||||
// • It sends no personal data. Scope goes as booking ids only — no names,
|
||||
// phones or addresses. The findings table is for measuring the skills, not
|
||||
// a second copy of the bookings.
|
||||
|
||||
import { reportFindings, reportFindingActed } from '@/api/doormile';
|
||||
|
||||
/**
|
||||
* The identity of a finding across polls.
|
||||
*
|
||||
* Deliberately only the skill and its scope's booking ids, sorted. NOT severity
|
||||
* and NOT counts: both drift while the underlying problem is unchanged (an
|
||||
* ageing SLA breach climbs from warning to critical), and including them would
|
||||
* make every escalation look like a brand-new finding and reset the "how long
|
||||
* has this been open" measurement — which is the single most useful thing the
|
||||
* table records.
|
||||
*
|
||||
* Sorted because scope order is not stable across scans.
|
||||
*/
|
||||
export const fingerprintOf = (finding) => {
|
||||
const skill = finding?.skillId || finding?.skill || 'unknown';
|
||||
// The PROPOSED ACTION is part of the identity.
|
||||
//
|
||||
// One skill legitimately raises several findings over the same booking set —
|
||||
// SlaGuardian emits both "notify the assigned riders" and "assign these
|
||||
// orders" for the same three parcels. Keyed on skill + scope alone those
|
||||
// collide, and because the fingerprint is the upsert key the second finding
|
||||
// silently overwrites the first: one of the two disappears from the table and
|
||||
// from every measurement built on it.
|
||||
//
|
||||
// Caught by running the real Exceptions page against a mock backend; the unit
|
||||
// tests used a distinct skill per fingerprint and never produced a collision.
|
||||
const tool = finding?.proposal?.tool || 'none';
|
||||
const ids = (finding?.proposal?.scope || [])
|
||||
.map((r) => r?.bookingid)
|
||||
.filter((id) => id != null)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
return `${skill}:${tool}:${ids.join(',')}`;
|
||||
};
|
||||
|
||||
const toItem = (finding) => ({
|
||||
skillid: finding?.skillId || finding?.skill || 'unknown',
|
||||
fingerprint: fingerprintOf(finding),
|
||||
severity: finding?.severity || 'info',
|
||||
title: String(finding?.title || finding?.headline || '').slice(0, 300),
|
||||
proposaltool: finding?.proposal?.tool || '',
|
||||
// Ids only. See the no-personal-data rule above.
|
||||
bookingids: (finding?.proposal?.scope || [])
|
||||
.map((r) => r?.bookingid)
|
||||
.filter((id) => id != null)
|
||||
.map(Number),
|
||||
tenantid: Number(localStorage.getItem('tenantid')) || null
|
||||
});
|
||||
|
||||
// Fingerprints seen on the previous scan, so this one can report what went
|
||||
// away. Module-level rather than component state: the banner remounts on
|
||||
// navigation and a remount must not look like every finding clearing at once.
|
||||
let previous = new Set();
|
||||
|
||||
/**
|
||||
* Report one scan's findings. Returns nothing and never rejects.
|
||||
*
|
||||
* Call AFTER the findings are rendered, not before — this must never sit in
|
||||
* front of what the operator sees.
|
||||
*/
|
||||
export const reportScan = async (findings) => {
|
||||
try {
|
||||
const items = (findings || []).map(toItem).filter((i) => i.fingerprint && i.skillid);
|
||||
const current = new Set(items.map((i) => i.fingerprint));
|
||||
const cleared = [...previous].filter((fp) => !current.has(fp));
|
||||
|
||||
// Nothing open and nothing cleared is the common case on a quiet board;
|
||||
// skip the round trip entirely.
|
||||
if (!items.length && !cleared.length) {
|
||||
previous = current;
|
||||
return;
|
||||
}
|
||||
|
||||
await reportFindings({ findings: items, cleared });
|
||||
previous = current;
|
||||
} catch {
|
||||
// Silent by design. A failed report is not something an operator can act
|
||||
// on, and the previous set is deliberately NOT updated — so the next scan
|
||||
// retries the same clear set rather than losing it.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Record that an operator carried out a proposal, and how it went.
|
||||
*
|
||||
* `result` mirrors what the executors actually return: a partial success is a
|
||||
* partial success. Flattening six-riders-notified-out-of-eight to "ok" would
|
||||
* lose exactly the distinction actions.js preserves.
|
||||
*/
|
||||
export const reportActed = async (finding, executionResult) => {
|
||||
try {
|
||||
const fp = fingerprintOf(finding);
|
||||
if (!fp) return;
|
||||
let result = 'failed';
|
||||
if (executionResult?.ok) {
|
||||
// `partial` is declared by the executor. The fallback covers an executor
|
||||
// that predates the field; without the declaration a batch-assign that
|
||||
// skipped a booking reported as a clean success, because `unreachable`
|
||||
// is a notify-only field.
|
||||
const partial = typeof executionResult.partial === 'boolean'
|
||||
? executionResult.partial
|
||||
: (Array.isArray(executionResult?.unreachable) && executionResult.unreachable.length > 0);
|
||||
result = partial ? 'partial' : 'ok';
|
||||
}
|
||||
await reportFindingActed(fp, result);
|
||||
} catch {
|
||||
// Same reasoning as reportScan: the action itself already happened and was
|
||||
// reported to the operator. Losing its telemetry must not surface as a
|
||||
// failure of the action.
|
||||
}
|
||||
};
|
||||
|
||||
/** Tests only — the module-level previous set would otherwise leak between them. */
|
||||
export const __resetForTests = () => {
|
||||
previous = new Set();
|
||||
};
|
||||
@@ -38,6 +38,29 @@ export function generateClientPassword(length = 14) {
|
||||
}
|
||||
|
||||
/** Client-side mirror of the server's validation, for inline errors. */
|
||||
/**
|
||||
* What a client ships. The SAME eight values the backend prices against
|
||||
* (constants.DeliveryCategories) — a tenant whose category is not a pricing
|
||||
* category cannot be priced, so this must not drift from that list.
|
||||
*/
|
||||
export const DELIVERY_CATEGORIES = [
|
||||
'General', 'Documents', 'Electronics', 'Clothing',
|
||||
'Fragile', 'Medical', 'Automotive', 'Food'
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether a return journey makes sense for what this client ships.
|
||||
*
|
||||
* Food is the exception: a meal that comes back is waste, not inventory.
|
||||
* Mirrors constants.ReverseLogisticsAllowed, which ENFORCES this in
|
||||
* InitiateConsignmentRTO — this copy only decides what the form shows, and
|
||||
* hiding a control is a courtesy, not a rule.
|
||||
*
|
||||
* Unknown or empty allows returns, matching the server: the field is new, and
|
||||
* new information must not withdraw a capability existing clients already use.
|
||||
*/
|
||||
export const reverseLogisticsAllowed = (category) => category !== 'Food';
|
||||
|
||||
export function validateOnboarding(form) {
|
||||
const errors = {};
|
||||
const phone = String(form.phone || '').replace(/\D/g, '');
|
||||
@@ -46,6 +69,12 @@ export function validateOnboarding(form) {
|
||||
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";
|
||||
// Required, matching the server. Not defaulted to General: the category sets
|
||||
// the client's pricing AND whether their parcels can be returned, so
|
||||
// guessing it decides both on the operator's behalf without asking.
|
||||
if (!DELIVERY_CATEGORIES.includes(String(form.deliverycategory || ''))) {
|
||||
errors.deliverycategory = 'Choose what this client delivers';
|
||||
}
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user