refactor(agents): share leadership role detector, use word-boundary match

P2-23: 'lead' in system-prompt.ts matched 'cleaner' / 'leaderboard' via
substring .includes(). Hoist LEADERSHIP_ROLE_KEYWORDS into a shared
agent-role-helpers.ts module; tokenize input on non-alphanum to require
whole-word match.

P3-54: Same keyword list was duplicated in automation-skills with drift
(missing 'ceo', different members). Both sites now call through the
shared helper.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 22:57:35 -04:00
1 parent ba6ba8360b
commit 2df921ca88
6 files changed
+89 -34

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
/**
* Role-classification helpers used across prompt construction and skill
* auto-assignment. Keeping a single keyword list here prevents drift
* between system-prompt.ts and automation-skills.ts.
*
* Matching uses word-boundary tokenization instead of substring search,
* so "lead" does not match "cleaner" or "leaderboard".
*/
export const LEADERSHIP_ROLE_KEYWORDS = [
"chief",
"cto",
"cmo",
"coo",
"cfo",
"ceo",
"vp",
"vice",
"president",
"head",
"director",
"manager",
"lead",
] as const;
function tokenize(text: string): string[] {
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}
export function roleMatchesLeadershipKeywords(role: string, title?: string | null): boolean {
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
return LEADERSHIP_ROLE_KEYWORDS.some((keyword) => tokens.has(keyword));
}
+4 -2
View File
@@ -294,7 +294,8 @@ export function registerFileRoutes(server: AgentApiServer) {
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; } if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
const parsed = updateWorkProductSchema.safeParse(body); const parsed = updateWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
db.updateWorkProduct(wpId, token.companyId, parsed.data); const changes = db.updateWorkProduct(wpId, token.companyId, parsed.data);
if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; }
onDomainChanged(); onDomainChanged();
server.json(res, 200, { updated: true }); server.json(res, 200, { updated: true });
}); });
@@ -302,7 +303,8 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => { server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => {
const wpId = params.workProductId; const wpId = params.workProductId;
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; } if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
db.deleteWorkProduct(wpId, token.companyId); const changes = db.deleteWorkProduct(wpId, token.companyId);
if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; }
onDomainChanged(); onDomainChanged();
server.json(res, 200, { deleted: true }); server.json(res, 200, { deleted: true });
}); });
+5 -4
View File
@@ -1,4 +1,5 @@
import type { AppDatabase } from "../database"; import type { AppDatabase } from "../database";
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = { const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = {
engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"], engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"],
@@ -52,10 +53,10 @@ function scoreSkillAgentMatch(
} }
} }
// Executives need awareness of all domains // Executives need awareness of all domains. Use the shared leadership
const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"]; // keyword list with word-boundary matching so "lead" does not match
const roleLower = agent.role.toLowerCase(); // "cleaner" / "leaderboard" in agent role strings.
if (executiveRoles.some((r) => roleLower.includes(r))) { if (roleMatchesLeadershipKeywords(agent.role, agent.title)) {
score += 1; score += 1;
} }
+2 -2
View File
@@ -93,9 +93,9 @@ export class AppDatabase extends AppDatabaseBase {
getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); } getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); }
listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); } listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); }
createWorkProduct(input: { companyId: string; taskId: string; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); } createWorkProduct(input: { companyId: string; taskId: string; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); }
updateWorkProduct(id: string, companyId: string, patch: Record<string, unknown>) { this.documentsMod.updateWorkProduct(id, companyId, patch); } updateWorkProduct(id: string, companyId: string, patch: Parameters<ReturnType<typeof import("./database/documents").buildDocumentMethods>["updateWorkProduct"]>[2]) { return this.documentsMod.updateWorkProduct(id, companyId, patch); }
listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); } listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); }
deleteWorkProduct(id: string, companyId: string) { this.documentsMod.deleteWorkProduct(id, companyId); } deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, companyId); }
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); } linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); }
unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); } unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); }
listDocumentsForTask(taskId: string, companyId: string) { return this.documentsMod.listDocumentsForTask(taskId, companyId); } listDocumentsForTask(taskId: string, companyId: string) { return this.documentsMod.listDocumentsForTask(taskId, companyId); }
+41 -10
View File
@@ -211,20 +211,48 @@ export function buildDocumentMethods(
return id; return id;
}, },
updateWorkProduct(id: string, companyId: string, patch: Record<string, unknown>): void { updateWorkProduct(
id: string,
companyId: string,
patch: {
type?: string;
provider?: string;
externalId?: string | null;
title?: string;
url?: string | null;
status?: string;
reviewState?: string;
isPrimary?: boolean;
summary?: string | null;
metadataJson?: string | null;
},
): number {
const ts = nowIso(); const ts = nowIso();
const sets: string[] = ["updated_at = ?"]; const sets: string[] = ["updated_at = ?"];
const values: unknown[] = [ts]; const values: SQLInputValue[] = [ts];
const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]); const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [
for (const [key, value] of Object.entries(patch)) { ["type", "type", (v) => (v as string) ?? null],
const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); ["provider", "provider", (v) => (v as string) ?? null],
if (ALLOWED.has(col)) { ["externalId", "external_id", (v) => (v as string | null) ?? null],
["title", "title", (v) => (v as string) ?? null],
["url", "url", (v) => (v as string | null) ?? null],
["status", "status", (v) => (v as string) ?? null],
["reviewState", "review_state", (v) => (v as string) ?? null],
["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
["summary", "summary", (v) => (v as string | null) ?? null],
["metadataJson", "metadata_json", (v) => (v as string | null) ?? null],
];
for (const [key, col, coerce] of map) {
if (key in patch) {
sets.push(`${col} = ?`); sets.push(`${col} = ?`);
values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null); values.push(coerce(patch[key]));
} }
} }
values.push(id, companyId); values.push(id, companyId);
getDb().prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`).run(...(values as SQLInputValue[])); const result = getDb()
.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`)
.run(...values);
return Number(result.changes ?? 0);
}, },
listWorkProductsForTask(taskId: string, companyId: string) { listWorkProductsForTask(taskId: string, companyId: string) {
@@ -247,8 +275,11 @@ export function buildDocumentMethods(
})); }));
}, },
deleteWorkProduct(id: string, companyId: string): void { deleteWorkProduct(id: string, companyId: string): number {
getDb().prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId); const result = getDb()
.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?")
.run(id, companyId);
return Number(result.changes ?? 0);
}, },
// ── Task-Document Links ── // ── Task-Document Links ──
+4 -16
View File
@@ -19,27 +19,15 @@ import {
buildDepartmentSection, buildDepartmentSection,
} from "./operating-instructions"; } from "./operating-instructions";
import type { PromptContext } from "./index"; import type { PromptContext } from "./index";
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
const LEADERSHIP_KEYWORDS = [
"chief",
"cto",
"cmo",
"coo",
"cfo",
"vp",
"vice president",
"head",
"director",
"manager",
"lead",
];
function isLeadershipRole(ctx: PromptContext): boolean { function isLeadershipRole(ctx: PromptContext): boolean {
// HR roles inherit leadership scope for manager-style prompts;
// other departments rely on role/title keyword tokenization.
if (ctx.agent.department === "hr") { if (ctx.agent.department === "hr") {
return true; return true;
} }
const text = `${ctx.agent.role} ${ctx.agent.title ?? ""}`.toLowerCase(); return roleMatchesLeadershipKeywords(ctx.agent.role, ctx.agent.title ?? null);
return LEADERSHIP_KEYWORDS.some((keyword) => text.includes(keyword));
} }
function buildSkillManifest( function buildSkillManifest(