diff --git a/src/lib/agent/people-match.test.ts b/src/lib/agent/people-match.test.ts new file mode 100644 index 0000000..30e93d3 --- /dev/null +++ b/src/lib/agent/people-match.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { matchPeople, type EmployeeRecord } from "./people-match"; + +const existing: EmployeeRecord[] = [ + { id: "e1", name: "Jabulani Nkosi", employee_no: "E-901" }, + { id: "e2", name: "Sipho Dlamini", employee_no: "E-902" }, + { id: "e3", name: "André van der Merwe", employee_no: "E-903" }, + { id: "e4", name: "Nomvula Nkosi-Dlamini", employee_no: "E-904" }, + { id: "e5", name: "Thabo Zulu" }, + { id: "e6", name: "Thandi Zulu" }, +]; + +describe("matchPeople", () => { + it("matches on employee number first, even if the name differs", () => { + const [result] = matchPeople([{ name: "J. Nkosi", employeeNo: "e-901" }], existing); + expect(result.confidence).toBe("exact"); + expect(result.match?.id).toBe("e1"); + }); + + it("matches on normalised full name (case and spacing)", () => { + const [result] = matchPeople([{ name: " sipho dlamini " }], existing); + expect(result.confidence).toBe("exact"); + expect(result.match?.id).toBe("e2"); + }); + + it("matches an accented name regardless of accents typed or not", () => { + const [withAccent] = matchPeople([{ name: "André van der Merwe" }], existing); + const [withoutAccent] = matchPeople([{ name: "Andre van der Merwe" }], existing); + expect(withAccent.match?.id).toBe("e3"); + expect(withoutAccent.match?.id).toBe("e3"); + expect(withAccent.confidence).toBe("exact"); + expect(withoutAccent.confidence).toBe("exact"); + }); + + it("treats a hyphenated double surname the same as a spaced one", () => { + const [hyphenated] = matchPeople([{ name: "Nomvula Nkosi Dlamini" }], existing); + expect(hyphenated.match?.id).toBe("e4"); + expect(hyphenated.confidence).toBe("exact"); + }); + + it("falls back to surname + first initial as a likely match", () => { + const [result] = matchPeople([{ name: "J Nkosi" }], existing); + expect(result.confidence).toBe("likely"); + expect(result.match?.id).toBe("e1"); + }); + + it("never guesses between two people sharing a surname and initial", () => { + const [result] = matchPeople([{ name: "T Zulu" }], existing); + expect(result.confidence).toBe("none"); + expect(result.match).toBeNull(); + }); + + it("reports no match for a genuinely new person", () => { + const [result] = matchPeople([{ name: "TEST Someone New" }], existing); + expect(result.confidence).toBe("none"); + expect(result.match).toBeNull(); + }); + + it("never creates a worker who already matches exactly (confidence signal for the caller)", () => { + const results = matchPeople( + [{ name: "Jabulani Nkosi" }, { name: "TEST New Person" }], + existing, + ); + expect(results[0].confidence).toBe("exact"); + expect(results[1].confidence).toBe("none"); + }); +}); diff --git a/src/lib/agent/people-match.ts b/src/lib/agent/people-match.ts new file mode 100644 index 0000000..4928130 --- /dev/null +++ b/src/lib/agent/people-match.ts @@ -0,0 +1,101 @@ +/** + * Matches names from any list (a typed list, a spreadsheet column, OCR + * off a photo) against existing `employees`, so Ask can tell a genuinely + * new worker from one who's just spelled differently on the sheet. Pure. + */ +export interface IncomingPerson { + name: string; + employeeNo?: string; + idNumber?: string; +} + +export interface EmployeeRecord { + id: string; + name: string; + employee_no?: string; +} + +export interface MatchedPerson { + incoming: IncomingPerson; + match: EmployeeRecord | null; + confidence: "exact" | "likely" | "none"; +} + +/** + * Case/accent/spacing-insensitive normalisation. Collapses hyphens to + * spaces so "Nkosi-Dlamini" and "Nkosi Dlamini" compare equal — common + * across Afrikaans and Zulu double surnames — and strips diacritics + * (e.g. "André" -> "andre") so OCR/typing variance doesn't split an + * otherwise-identical name into two people. + */ +function normalise(name: string): string { + return name + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") // combining diacritical marks, post-NFD + .toLowerCase() + .replace(/[-_]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function normaliseEmployeeNo(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ""); +} + +/** Last space-separated part of a normalised name — the surname. */ +function surnameOf(normalised: string): string { + const parts = normalised.split(" ").filter(Boolean); + return parts[parts.length - 1] ?? ""; +} + +/** First character of the first part — the first initial. */ +function firstInitialOf(normalised: string): string { + const parts = normalised.split(" ").filter(Boolean); + return parts[0]?.[0] ?? ""; +} + +/** + * Matches on employee number first, then normalised full name, then + * surname + first initial as "likely". The first exact match wins; a + * "likely" match is offered but never auto-applied — the plan's own + * recipe asks the user before creating anything from a "likely" row. + */ +export function matchPeople( + incoming: IncomingPerson[], + existing: EmployeeRecord[], +): MatchedPerson[] { + const byEmployeeNo = new Map(); + const byName = new Map(); + const bySurnameInitial = new Map(); + + for (const e of existing) { + if (e.employee_no) byEmployeeNo.set(normaliseEmployeeNo(e.employee_no), e); + const n = normalise(e.name); + byName.set(n, e); + const key = `${surnameOf(n)}\u0000${firstInitialOf(n)}`; + const list = bySurnameInitial.get(key) ?? []; + list.push(e); + bySurnameInitial.set(key, list); + } + + return incoming.map((person) => { + if (person.employeeNo) { + const byNo = byEmployeeNo.get(normaliseEmployeeNo(person.employeeNo)); + if (byNo) return { incoming: person, match: byNo, confidence: "exact" }; + } + + const normalised = normalise(person.name); + const exact = byName.get(normalised); + if (exact) return { incoming: person, match: exact, confidence: "exact" }; + + const key = `${surnameOf(normalised)}\u0000${firstInitialOf(normalised)}`; + const candidates = bySurnameInitial.get(key) ?? []; + // Ambiguous (more than one same surname + initial) is safer reported + // as no match than guessed — the user is asked, not the coin flip. + if (candidates.length === 1 && surnameOf(normalised) && firstInitialOf(normalised)) { + return { incoming: person, match: candidates[0], confidence: "likely" }; + } + + return { incoming: person, match: null, confidence: "none" }; + }); +} diff --git a/src/lib/agent/tools-actions.test.ts b/src/lib/agent/tools-actions.test.ts index 006e8aa..3e660f7 100644 --- a/src/lib/agent/tools-actions.test.ts +++ b/src/lib/agent/tools-actions.test.ts @@ -20,6 +20,11 @@ vi.mock("./gateway", () => ({ proposeChanges: (...args: unknown[]) => proposeChangesMock(...args), })); +const loadEmployeesMock = vi.fn(); +vi.mock("./load", () => ({ + loadEmployees: (...args: unknown[]) => loadEmployeesMock(...args), +})); + const parseAttachmentMock = vi.fn(); const previewForModelMock = vi.fn<(...args: unknown[]) => unknown>((p: unknown) => p); vi.mock("./attachments", () => ({ @@ -281,3 +286,62 @@ describe("propose_changes", () => { expect(result).toMatchObject({ actionId: "log1", status: "awaiting_confirmation" }); }); }); + +describe("match_workers", () => { + beforeEach(() => { + requireSiteMock.mockReset(); + loadEmployeesMock.mockReset(); + requireSiteMock.mockResolvedValue({ ok: true, site: { id: "site1", name: "The Hills" } }); + loadEmployeesMock.mockResolvedValue([{ id: "e1", name: "Jabulani Nkosi", employee_no: "E-901" }]); + }); + + it("matches typed names against the site's employees", async () => { + const tools = actionTools(ctx); + const result = (await tools.find((t) => t.name === "match_workers")!.handler({ + names: ["Jabulani Nkosi", "TEST New Person"], + site: "The Hills", + })) as { site: string; matches: { confidence: string }[] }; + expect(result.site).toBe("The Hills"); + expect(result.matches[0].confidence).toBe("exact"); + expect(result.matches[1].confidence).toBe("none"); + expect(loadEmployeesMock).toHaveBeenCalledWith({ siteId: "site1" }); + }); + + it("relays an unknown site as an error", async () => { + requireSiteMock.mockResolvedValue({ ok: false, error: "No site matches" }); + const tools = actionTools(ctx); + const result = await tools + .find((t) => t.name === "match_workers")! + .handler({ names: ["A"], site: "Nowhere" }); + expect(result).toEqual({ error: "No site matches" }); + }); + + it("reads names from an attachment's sheet when no names are typed", async () => { + getOneMock.mockReset(); + fetchMock.mockReset(); + getOneMock.mockResolvedValue({ id: "a1", file: "workers.xlsx" }); + fetchMock.mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "application/octet-stream" }), + arrayBuffer: async () => new ArrayBuffer(4), + }); + parseAttachmentMock.mockResolvedValue({ + kind: "sheet", + sheets: [ + { + name: "Workers", + columns: ["Name", "Employee No"], + rows: [{ Name: "Jabulani Nkosi", "Employee No": "E-901" }, { Name: "TEST New", "Employee No": "" }], + rowCount: 2, + }, + ], + }); + const tools = actionTools(ctx); + const result = (await tools.find((t) => t.name === "match_workers")!.handler({ + attachmentId: "a1", + site: "The Hills", + })) as { matches: { name: string; confidence: string }[] }; + expect(result.matches.map((m) => m.name)).toEqual(["Jabulani Nkosi", "TEST New"]); + expect(result.matches[0].confidence).toBe("exact"); + }); +}); diff --git a/src/lib/agent/tools-actions.ts b/src/lib/agent/tools-actions.ts index 9ec4176..e7035fd 100644 --- a/src/lib/agent/tools-actions.ts +++ b/src/lib/agent/tools-actions.ts @@ -21,6 +21,8 @@ import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema"; import { QuerySpec, runQuery, getCachedResult } from "./query"; import type { ChartSpec, ChartKind, ValueFormat } from "@/lib/reports/chart-spec"; import { proposeChanges, type AgentOp } from "./gateway"; +import { matchPeople, type IncomingPerson, type EmployeeRecord } from "./people-match"; +import { loadEmployees } from "./load"; /** Fetches an ask_messages attachment row's stored file bytes fresh from PB. */ async function loadAttachmentFile( @@ -92,6 +94,62 @@ async function readAttachment(input: { * logic directly against PocketBase (rather than a same-process HTTP * call, which has no reliable base URL to call back into itself with). */ +/** Best-guess column pick for a name/employee-no/id-number sheet, by header text. */ +function findColumn(columns: string[], pattern: RegExp): string | undefined { + return columns.find((c) => pattern.test(c)); +} + +/** + * Wraps `matchPeople` with the actual source of incoming names (typed, + * or a previously-uploaded sheet) and the site's existing employees. + */ +async function matchWorkers(input: { + attachmentId?: string; + names?: string[]; + site: string; +}): Promise { + const siteResult = await requireSite(input.site); + if (!siteResult.ok) return { error: siteResult.error }; + + let incoming: IncomingPerson[]; + if (input.names && input.names.length > 0) { + incoming = input.names.map((name) => ({ name })); + } else if (input.attachmentId) { + const { bytes, fileName, mime } = await loadAttachmentFile(input.attachmentId); + const parsed = await parseAttachment(bytes, fileName, mime); + if (parsed.kind !== "sheet" || parsed.sheets.length === 0) { + return { error: "That attachment isn't a sheet of names — read it first with read_attachment." }; + } + const table = parsed.sheets[0]; + const nameCol = findColumn(table.columns, /name/i) ?? table.columns[0]; + const noCol = findColumn(table.columns, /emp(loyee)?.{0,3}no|staff.{0,3}no/i); + const idCol = findColumn(table.columns, /id.{0,3}(number|no)\b/i); + incoming = table.rows + .map((row) => ({ + name: String(row[nameCol] ?? "").trim(), + employeeNo: noCol && row[noCol] ? String(row[noCol]) : undefined, + idNumber: idCol && row[idCol] ? String(row[idCol]) : undefined, + })) + .filter((p) => p.name.length > 0); + } else { + return { error: "Provide either names or attachmentId." }; + } + + const employees = (await loadEmployees({ siteId: siteResult.site.id })) as unknown as EmployeeRecord[]; + const matched = matchPeople(incoming, employees); + + return { + site: siteResult.site.name, + matches: matched.map((m) => ({ + name: m.incoming.name, + employeeNo: m.incoming.employeeNo ?? null, + matchId: m.match?.id ?? null, + matchName: m.match?.name ?? null, + confidence: m.confidence, + })), + }; +} + async function sendToPoPipeline(input: { id: string; site: string; @@ -226,6 +284,18 @@ export function actionTools(ctx: AgentContext): AgentTool[] { handler: async (args) => readAttachment(args as { id: string; sheet?: string; offset?: number }), }, + { + name: "match_workers", + description: + "Matches names (typed, or from a previously read attachment's sheet) against the site's existing employees: exact (employee number or full name) or likely (surname + first initial) — never guessed when ambiguous. Use before proposing any worker creates so an exact match is updated, not duplicated.", + schema: { + attachmentId: z.string().optional(), + names: z.array(z.string().min(1)).optional(), + site: z.string().min(1), + }, + handler: async (args) => + matchWorkers(args as { attachmentId?: string; names?: string[]; site: string }), + }, { name: "query_records", description: diff --git a/src/lib/chat/system-prompt.test.ts b/src/lib/chat/system-prompt.test.ts index 90c93b6..7a9e031 100644 --- a/src/lib/chat/system-prompt.test.ts +++ b/src/lib/chat/system-prompt.test.ts @@ -30,6 +30,11 @@ describe("buildSystemPrompt", () => { expect(p).toContain("read_attachment"); expect(p).toContain("send_to_po_pipeline"); }); + it("carries the worker-loading recipe", () => { + const p = buildSystemPrompt(new Date()); + expect(p).toContain("match_workers"); + expect(p).toContain("Never create a worker whose row already matched exactly"); + }); it("embeds the formatted memories block when given", () => { const p = buildSystemPrompt(new Date(), "- [shared] The Hills works 45-hour weeks"); expect(p).toContain("- [shared] The Hills works 45-hour weeks"); diff --git a/src/lib/chat/system-prompt.ts b/src/lib/chat/system-prompt.ts index ac374a1..f961a8e 100644 --- a/src/lib/chat/system-prompt.ts +++ b/src/lib/chat/system-prompt.ts @@ -71,6 +71,14 @@ For any question the other tools don't answer directly, use \`query_records\` ## Files When the user attaches an Excel/CSV/PDF/photo/text file, read it with \`read_attachment\` before answering about it. If a PDF or photo is a purchase order or delivery note, offer to send it to the pipeline with \`send_to_po_pipeline\` rather than trying to read its line items yourself. +## Loading workers from a list +When the user gives a list of names for a site — typed, a spreadsheet, or a photo/PDF: +1. \`read_attachment\`, or use the typed names directly. +2. \`match_workers\` against that site. +3. Ask only for the missing required fields (site is already known); default \`worker_type\` from the user's own words ("casuals" -> casual, otherwise ask or default permanent). +4. \`propose_changes\` with creates for the new workers and updates (e.g. a site change) for existing ones. +5. Never create a worker whose row already matched exactly — a "likely" match is offered to the user, never assumed. + ## What you remember ${memoriesBlock || "Nothing yet."} When the user tells you a lasting preference, fact or rule, call \`remember\`. Don't store one-off questions or figures that change.