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>
103 lines
No EOL
3.5 KiB
TypeScript
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(", ");
|
|
} |