feat(agent): add free-text site resolver

Humans say 'the Hills' or 'C26/09', not record ids. Ambiguity is
returned explicitly so a tool can ask rather than silently pick the
wrong contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-08-29 20:25:39 +02:00
parent dee027aeb9
commit 15ecf1af05
2 changed files with 123 additions and 0 deletions

View file

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

53
src/lib/agent/resolve.ts Normal file
View file

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