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>
This commit is contained in:
parent
410e2f6606
commit
40bbbf8b5d
2 changed files with 102 additions and 2 deletions
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSite, type SiteRef } from "./resolve";
|
||||
import {
|
||||
resolveSite,
|
||||
resolveVehicle,
|
||||
type SiteRef,
|
||||
type VehicleRef,
|
||||
} from "./resolve";
|
||||
|
||||
const SITES: SiteRef[] = [
|
||||
{ id: "aaa111", name: "The Hills", projectCode: "C26/09" },
|
||||
|
|
@ -67,4 +72,49 @@ describe("resolveSite", () => {
|
|||
const r = resolveSite("anything", []);
|
||||
expect(r.kind).toBe("none");
|
||||
});
|
||||
});
|
||||
});
|
||||
const VEHICLES: VehicleRef[] = [
|
||||
{ id: "v1", description: "Center Point Grader", qrCodeId: "1626", projectCode: "C26/09" },
|
||||
{ id: "v2", description: "Isuzu MY85HGGP", qrCodeId: "1936", projectCode: "C26/09" },
|
||||
{ id: "v3", description: "TLB centre point", qrCodeId: "2201", projectCode: "C26/09" },
|
||||
];
|
||||
|
||||
describe("resolveVehicle", () => {
|
||||
it("matches an exact record id", () => {
|
||||
const r = resolveVehicle("v2", VEHICLES);
|
||||
expect(r.kind).toBe("exact");
|
||||
if (r.kind === "exact") expect(r.vehicle.id).toBe("v2");
|
||||
});
|
||||
|
||||
it("matches a QR code", () => {
|
||||
const r = resolveVehicle("1626", VEHICLES);
|
||||
expect(r.kind).toBe("exact");
|
||||
if (r.kind === "exact") expect(r.vehicle.id).toBe("v1");
|
||||
});
|
||||
|
||||
it("matches a full description case-insensitively", () => {
|
||||
const r = resolveVehicle("center point grader", VEHICLES);
|
||||
expect(r.kind).toBe("exact");
|
||||
if (r.kind === "exact") expect(r.vehicle.id).toBe("v1");
|
||||
});
|
||||
|
||||
it("matches a unique substring", () => {
|
||||
const r = resolveVehicle("isuzu", VEHICLES);
|
||||
expect(r.kind).toBe("exact");
|
||||
if (r.kind === "exact") expect(r.vehicle.id).toBe("v2");
|
||||
});
|
||||
|
||||
it("reports ambiguity rather than guessing", () => {
|
||||
const r = resolveVehicle("point", VEHICLES);
|
||||
expect(r.kind).toBe("ambiguous");
|
||||
if (r.kind === "ambiguous") expect(r.matches.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns none for no match", () => {
|
||||
expect(resolveVehicle("helicopter", VEHICLES).kind).toBe("none");
|
||||
});
|
||||
|
||||
it("returns none for an empty query", () => {
|
||||
expect(resolveVehicle(" ", VEHICLES).kind).toBe("none");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,4 +50,54 @@ export function resolveSite(query: string, sites: SiteRef[]): ResolveSiteResult
|
|||
/** 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(", ");
|
||||
}
|
||||
Loading…
Reference in a new issue