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:
1 parent
ba6ba8360b
commit
2df921ca88
6 files changed
+89
-34
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -294,7 +294,8 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
|
||||
const parsed = updateWorkProductSchema.safeParse(body);
|
||||
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();
|
||||
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) => {
|
||||
const wpId = params.workProductId;
|
||||
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();
|
||||
server.json(res, 200, { deleted: true });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
|
||||
|
||||
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"],
|
||||
@@ -52,10 +53,10 @@ function scoreSkillAgentMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Executives need awareness of all domains
|
||||
const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"];
|
||||
const roleLower = agent.role.toLowerCase();
|
||||
if (executiveRoles.some((r) => roleLower.includes(r))) {
|
||||
// Executives need awareness of all domains. Use the shared leadership
|
||||
// keyword list with word-boundary matching so "lead" does not match
|
||||
// "cleaner" / "leaderboard" in agent role strings.
|
||||
if (roleMatchesLeadershipKeywords(agent.role, agent.title)) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ export class AppDatabase extends AppDatabaseBase {
|
||||
getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); }
|
||||
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); }
|
||||
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); }
|
||||
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); }
|
||||
unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); }
|
||||
listDocumentsForTask(taskId: string, companyId: string) { return this.documentsMod.listDocumentsForTask(taskId, companyId); }
|
||||
|
||||
@@ -211,20 +211,48 @@ export function buildDocumentMethods(
|
||||
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 sets: string[] = ["updated_at = ?"];
|
||||
const values: unknown[] = [ts];
|
||||
const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]);
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||
if (ALLOWED.has(col)) {
|
||||
const values: SQLInputValue[] = [ts];
|
||||
const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [
|
||||
["type", "type", (v) => (v as string) ?? null],
|
||||
["provider", "provider", (v) => (v as string) ?? null],
|
||||
["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} = ?`);
|
||||
values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null);
|
||||
values.push(coerce(patch[key]));
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -247,8 +275,11 @@ export function buildDocumentMethods(
|
||||
}));
|
||||
},
|
||||
|
||||
deleteWorkProduct(id: string, companyId: string): void {
|
||||
getDb().prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
deleteWorkProduct(id: string, companyId: string): number {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?")
|
||||
.run(id, companyId);
|
||||
return Number(result.changes ?? 0);
|
||||
},
|
||||
|
||||
// ── Task-Document Links ──
|
||||
|
||||
@@ -19,27 +19,15 @@ import {
|
||||
buildDepartmentSection,
|
||||
} from "./operating-instructions";
|
||||
import type { PromptContext } from "./index";
|
||||
|
||||
const LEADERSHIP_KEYWORDS = [
|
||||
"chief",
|
||||
"cto",
|
||||
"cmo",
|
||||
"coo",
|
||||
"cfo",
|
||||
"vp",
|
||||
"vice president",
|
||||
"head",
|
||||
"director",
|
||||
"manager",
|
||||
"lead",
|
||||
];
|
||||
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
|
||||
|
||||
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") {
|
||||
return true;
|
||||
}
|
||||
const text = `${ctx.agent.role} ${ctx.agent.title ?? ""}`.toLowerCase();
|
||||
return LEADERSHIP_KEYWORDS.some((keyword) => text.includes(keyword));
|
||||
return roleMatchesLeadershipKeywords(ctx.agent.role, ctx.agent.title ?? null);
|
||||
}
|
||||
|
||||
function buildSkillManifest(
|
||||
|
||||
Reference in new issue
Block a user