diff --git a/src/lib/agent/resolve.test.ts b/src/lib/agent/resolve.test.ts new file mode 100644 index 0000000..5c11cb7 --- /dev/null +++ b/src/lib/agent/resolve.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { resolveSite, type SiteRef } from "./resolve"; + +const SITES: SiteRef[] = [ + { id: "aaa111", name: "The Hills", projectCode: "C26/09" }, + { id: "bbb222", name: "Hillcrest Extension", projectCode: "C26/11" }, + { id: "ccc333", name: "N1 Plant Yard", projectCode: "C25/04" }, +]; + +describe("resolveSite", () => { + it("matches an exact record id", () => { + const r = resolveSite("bbb222", SITES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.site.id).toBe("bbb222"); + }); + + it("matches a project code case-insensitively", () => { + const r = resolveSite("c26/09", SITES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.site.id).toBe("aaa111"); + }); + + it("matches a full name case-insensitively", () => { + const r = resolveSite("the hills", SITES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.site.id).toBe("aaa111"); + }); + + it("ignores surrounding whitespace", () => { + const r = resolveSite(" The Hills ", SITES); + expect(r.kind).toBe("exact"); + }); + + it("falls back to a unique substring match", () => { + const r = resolveSite("n1 plant", SITES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.site.id).toBe("ccc333"); + }); + + it("reports ambiguity rather than guessing", () => { + const r = resolveSite("hill", SITES); + expect(r.kind).toBe("ambiguous"); + if (r.kind === "ambiguous") { + expect(r.matches.map((m) => m.id).sort()).toEqual(["aaa111", "bbb222"]); + } + }); + + it("prefers an exact name over a substring of another site", () => { + // "The Hills" is an exact name and also a substring of nothing else, + // but this pins the precedence explicitly. + const r = resolveSite("The Hills", SITES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.site.id).toBe("aaa111"); + }); + + it("returns none for no match", () => { + const r = resolveSite("Rivonia", SITES); + expect(r.kind).toBe("none"); + }); + + it("returns none for an empty query", () => { + const r = resolveSite(" ", SITES); + expect(r.kind).toBe("none"); + }); + + it("returns none when there are no sites", () => { + const r = resolveSite("anything", []); + expect(r.kind).toBe("none"); + }); +}); \ No newline at end of file diff --git a/src/lib/agent/resolve.ts b/src/lib/agent/resolve.ts new file mode 100644 index 0000000..6c74542 --- /dev/null +++ b/src/lib/agent/resolve.ts @@ -0,0 +1,53 @@ +/** + * 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(", "); +} \ No newline at end of file