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:
parent
5aac22ba8f
commit
5cccff9a77
6 changed files with 315 additions and 0 deletions
67
src/lib/agent/people-match.test.ts
Normal file
67
src/lib/agent/people-match.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
101
src/lib/agent/people-match.ts
Normal file
101
src/lib/agent/people-match.ts
Normal 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" };
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue