MEGAINFRA-WEBAPP/src/lib/agent/resolve.ts
DEls 40bbbf8b5d feat(agent): add free-text vehicle resolver
Mirrors resolveSite: id, then QR code, then exact description, then
substring — and ambiguity is reported, never guessed. Picking the wrong
vehicle silently attributes fuel spend to the wrong machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 08:40:34 +02:00

103 lines
No EOL
3.5 KiB
TypeScript

/**
* Resolve a free-text site query to a site.
*
* Callers are humans (via an LLM) who say "the Hills" or "C26/09", not
* record ids. Ambiguity is returned explicitly so the tool can ask a
* follow-up question instead of silently picking the wrong contract —
* these numbers are money.
*/
export interface SiteRef {
id: string;
name: string;
projectCode: string;
}
export type ResolveSiteResult =
| { kind: "exact"; site: SiteRef }
| { kind: "ambiguous"; matches: SiteRef[] }
| { kind: "none" };
export function resolveSite(query: string, sites: SiteRef[]): ResolveSiteResult {
const q = query.trim().toLowerCase();
if (!q) return { kind: "none" };
// 1. Exact record id (case-sensitive — PB ids are lowercase alnum).
const byId = sites.find((s) => s.id === query.trim());
if (byId) return { kind: "exact", site: byId };
// 2. Exact project code.
const byCode = sites.filter((s) => s.projectCode.toLowerCase() === q);
if (byCode.length === 1) return { kind: "exact", site: byCode[0] };
if (byCode.length > 1) return { kind: "ambiguous", matches: byCode };
// 3. Exact name.
const byName = sites.filter((s) => s.name.toLowerCase() === q);
if (byName.length === 1) return { kind: "exact", site: byName[0] };
if (byName.length > 1) return { kind: "ambiguous", matches: byName };
// 4. Substring across name and code.
const bySubstring = sites.filter(
(s) =>
s.name.toLowerCase().includes(q) || s.projectCode.toLowerCase().includes(q),
);
if (bySubstring.length === 1) return { kind: "exact", site: bySubstring[0] };
if (bySubstring.length > 1) return { kind: "ambiguous", matches: bySubstring };
return { kind: "none" };
}
/** Human-readable disambiguation line for an ambiguous result. */
export function describeAmbiguity(matches: SiteRef[]): string {
return matches.map((m) => `${m.name} (${m.projectCode})`).join(", ");
}
export interface VehicleRef {
id: string;
description: string;
qrCodeId: string;
projectCode: string;
}
export type ResolveVehicleResult =
| { kind: "exact"; vehicle: VehicleRef }
| { kind: "ambiguous"; matches: VehicleRef[] }
| { kind: "none" };
/**
* Free-text vehicle lookup. Mirrors resolveSite: id, then QR code, then
* exact description, then substring — and ambiguity is reported, never
* guessed. Picking the wrong vehicle silently attributes fuel spend to
* the wrong machine.
*/
export function resolveVehicle(
query: string,
vehicles: VehicleRef[],
): ResolveVehicleResult {
const q = query.trim().toLowerCase();
if (!q) return { kind: "none" };
const byId = vehicles.find((v) => v.id === query.trim());
if (byId) return { kind: "exact", vehicle: byId };
const byQr = vehicles.filter((v) => v.qrCodeId.toLowerCase() === q);
if (byQr.length === 1) return { kind: "exact", vehicle: byQr[0] };
if (byQr.length > 1) return { kind: "ambiguous", matches: byQr };
const byDesc = vehicles.filter((v) => v.description.toLowerCase() === q);
if (byDesc.length === 1) return { kind: "exact", vehicle: byDesc[0] };
if (byDesc.length > 1) return { kind: "ambiguous", matches: byDesc };
const bySub = vehicles.filter((v) =>
v.description.toLowerCase().includes(q),
);
if (bySub.length === 1) return { kind: "exact", vehicle: bySub[0] };
if (bySub.length > 1) return { kind: "ambiguous", matches: bySub };
return { kind: "none" };
}
/** Human-readable disambiguation line for an ambiguous vehicle result. */
export function describeVehicleAmbiguity(matches: VehicleRef[]): string {
return matches.map((m) => `${m.description} (QR ${m.qrCodeId})`).join(", ");
}