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:
DEls 2026-08-30 08:40:34 +02:00
parent 410e2f6606
commit 40bbbf8b5d
2 changed files with 102 additions and 2 deletions

View file

@ -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");
});
});

View file

@ -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(", ");
}