feat(ask): load workers from lists, sheets, PDFs and photos

src/lib/agent/people-match.ts's matchPeople matches on employee number
first, then normalised full name (case/accent/spacing/hyphen
insensitive, so "André" == "Andre" and "Nkosi-Dlamini" == "Nkosi
Dlamini"), then surname + first initial as "likely" — ambiguous
surname+initial pairs report "none" rather than guess. The
match_workers tool wraps it: typed names, or a previously-read
attachment's sheet (name/employee-no/id-number columns picked by
header text). The prompt's worker-loading recipe wires it to
read_attachment -> match_workers -> propose_changes, refusing to
re-create an exact match.

Live check skipped: needs the full chat pipeline (thread persistence),
which needs ask_threads (not yet applied). Covered by unit tests,
including Afrikaans/Zulu name variants and the ambiguous-match case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-26 18:58:05 +02:00
parent 5aac22ba8f
commit 5cccff9a77
6 changed files with 315 additions and 0 deletions

View file

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

View file

@ -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<string, EmployeeRecord>();
const byName = new Map<string, EmployeeRecord>();
const bySurnameInitial = new Map<string, EmployeeRecord[]>();
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" };
});
}

View file

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

View file

@ -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<unknown> {
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:

View file

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

View file

@ -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.