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:
parent
dee027aeb9
commit
15ecf1af05
2 changed files with 123 additions and 0 deletions
70
src/lib/agent/resolve.test.ts
Normal file
70
src/lib/agent/resolve.test.ts
Normal 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
53
src/lib/agent/resolve.ts
Normal 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(", ");
|
||||
}
|
||||
Loading…
Reference in a new issue