over / src /lib /commitment /detector.ts
luguog's picture
Deploy mailbox-automation Next.js app
9646e24 verified
Raw
History Blame Contribute Delete
22.6 kB
/**
* Commitment Detector
*
* Scans processed email records for work requests and synthesizes
* CommitmentContract instances that the execution engine can act on.
* The detector is intentionally deterministic and keyword-driven so that
* its behavior is auditable and reproducible without an LLM round-trip.
*/
import { nanoid } from "nanoid";
import {
CommitmentContract,
AutonomyClass,
ProcessedEmailRecord,
RoleType,
} from "@/types";
// ─── Keyword dictionaries ──────────────────────────────────────────
/** Phrases that signal a work request when present in subject or body. */
const REQUEST_KEYWORDS: readonly string[] = [
"need",
"please prepare",
"by friday",
"by monday",
"send me",
"review",
"update",
"analyze",
"report",
"summarize",
"follow up",
"schedule",
"complete",
"draft",
"create",
];
/** Sender signals that indicate managerial authority. */
const AUTHORITY_SIGNALS: readonly string[] = [
"manager",
"director",
"vp",
"vice president",
"head of",
"lead",
"supervisor",
"principal",
];
/** Deadline phrases mapped to a relative offset in days from today. */
const DEADLINE_PHRASES: ReadonlyArray<{ phrase: RegExp; offsetDays: number | (() => number); label: string }> = [
{ phrase: /\basap\b/i, offsetDays: 1, label: "ASAP" },
{ phrase: /\burgent\b/i, offsetDays: 1, label: "urgent" },
{ phrase: /\btomorrow\b/i, offsetDays: 1, label: "tomorrow" },
{ phrase: /\bend of (the )?week\b/i, offsetDays: 5, label: "end of week" },
{ phrase: /\bby friday\b/i, offsetDays: () => nextWeekdayOffset(5), label: "by Friday" },
{ phrase: /\bby monday\b/i, offsetDays: () => nextWeekdayOffset(1), label: "by Monday" },
{ phrase: /\bby tuesday\b/i, offsetDays: () => nextWeekdayOffset(2), label: "by Tuesday" },
{ phrase: /\bby wednesday\b/i, offsetDays: () => nextWeekdayOffset(3), label: "by Wednesday" },
{ phrase: /\bby thursday\b/i, offsetDays: () => nextWeekdayOffset(4), label: "by Thursday" },
];
/** Permitted tools keyed by recipient role. */
const ROLE_TOOLS: Record<RoleType, string[]> = {
field_representative: [
"crm_lookup",
"territory_map",
"route_planner",
"call_planner",
"internal_report_writer",
],
regional_manager: [
"crm_lookup",
"performance_dashboard",
"team_report_writer",
"forecast_model",
"internal_email_drafter",
],
medical_affairs: [
"evidence_search",
"medical_information_writer",
"literature_index",
"internal_email_drafter",
],
market_access: [
"formulary_database",
"payer_analyzer",
"reimbursement_calculator",
"internal_report_writer",
],
compliance: [
"policy_search",
"audit_log_reader",
"compliance_checklist_writer",
"internal_email_drafter",
],
};
// ─── Helpers ───────────────────────────────────────────────────────
/**
* Returns the number of days from today until the next occurrence of the
* given weekday (0=Sunday ... 6=Saturday). If today is that weekday, the
* deadline is treated as 7 days out to avoid zero-day deadlines.
*/
function nextWeekdayOffset(targetWeekday: number): number {
const today = new Date();
const todayWeekday = today.getDay();
let offset = targetWeekday - todayWeekday;
if (offset <= 0) offset += 7;
return offset;
}
/** Format a Date as an ISO 8601 date string (YYYY-MM-DD). */
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** Add a number of days to today and return the resulting ISO date. */
function daysFromNow(offsetDays: number): string {
const d = new Date();
d.setDate(d.getDate() + offsetDays);
return toIsoDate(d);
}
/** Case-insensitive containment check. */
function contains(text: string, phrase: string): boolean {
return text.toLowerCase().includes(phrase.toLowerCase());
}
/** True when any of the phrases appear in the text. */
function containsAny(text: string, phrases: readonly string[]): boolean {
const lower = text.toLowerCase();
return phrases.some((p) => lower.includes(p.toLowerCase()));
}
// ─── Public API ────────────────────────────────────────────────────
/**
* Infer the recipient role from email subject and body content.
*
* The inference is keyword-driven and ordered from most specific to least
* specific so that compliance and medical-affairs requests are not
* misclassified as generic field-rep work.
*/
export function inferRole(subject: string, body: string): RoleType {
const text = `${subject} ${body}`.toLowerCase();
if (contains(text, "compliance") && contains(text, "review")) {
return "compliance";
}
if (contains(text, "medical information") && contains(text, "evidence")) {
return "medical_affairs";
}
if (contains(text, "formulary") && contains(text, "payer")) {
return "market_access";
}
if (contains(text, "team") && contains(text, "performance")) {
return "regional_manager";
}
if (contains(text, "territory")) {
return "field_representative";
}
// Default to field representative for generic field-team requests.
return "field_representative";
}
/**
* Extract a deadline reference from free text and return it as an ISO date
* string (YYYY-MM-DD). When no deadline is found, returns an empty string.
*
* Recognized forms:
* - Relative phrases: "ASAP", "urgent", "tomorrow", "end of week",
* "by Friday", "by Monday", ...
* - Absolute dates: "Aug 7", "August 7", "8/7", "2026-08-07".
*/
export function extractDeadline(text: string): string {
if (!text) return "";
// 1. Relative phrase matching.
for (const { phrase, offsetDays } of DEADLINE_PHRASES) {
if (phrase.test(text)) {
const resolved = typeof offsetDays === "function" ? offsetDays() : offsetDays;
return daysFromNow(resolved);
}
}
// 2. ISO date: 2026-08-07
const isoMatch = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
if (isoMatch) {
const [, y, m, d] = isoMatch;
return `${y}-${m}-${d}`;
}
// 3. Numeric date: 8/7 or 08/07 (assume current year)
const numericMatch = text.match(/\b(\d{1,2})\/(\d{1,2})\b/);
if (numericMatch) {
const [, monthStr, dayStr] = numericMatch;
const month = monthStr.padStart(2, "0");
const day = dayStr.padStart(2, "0");
const year = new Date().getFullYear();
return `${year}-${month}-${day}`;
}
// 4. Month-name date: "Aug 7" or "August 7"
const monthNameMatch = text.match(
/\b(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(\d{1,2})\b/i,
);
if (monthNameMatch) {
const monthMap: Record<string, string> = {
jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06",
jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12",
};
const monthKey = monthNameMatch[1].toLowerCase().slice(0, 3);
const month = monthMap[monthKey];
const day = monthNameMatch[2].padStart(2, "0");
const year = new Date().getFullYear();
return `${year}-${month}-${day}`;
}
return "";
}
/**
* Classify the autonomy level for a requested outcome.
*
* - Class 1: safe internal work (reports, summaries, reconciliation, routes)
* - Class 2: actions with external or system-of-record impact (external
* emails, CRM modifications, scheduling)
* - Class 3: actions requiring clinical, scientific, promotional, or
* financial judgment
* - Class 4: prohibited actions
*/
export function classifyAutonomy(
requestedOutcome: string,
externalSend: boolean,
): AutonomyClass {
const text = requestedOutcome.toLowerCase();
// Class 4: prohibited actions.
const prohibited = [
"off-label",
"bribe",
"kickback",
"prescribe",
"diagnose",
"fabricate",
"falsify",
];
if (containsAny(text, prohibited)) {
return 4;
}
// Class 3: clinical / scientific / promotional / financial judgment.
const class3 = [
"clinical",
"scientific",
"efficacy",
"safety",
"promotional claim",
"claim",
"financial authorization",
"authorize payment",
"budget approval",
"medical judgment",
];
if (containsAny(text, class3)) {
return 3;
}
// Class 2: external send or CRM / scheduling impact.
if (externalSend) {
return 2;
}
const class2 = [
"external email",
"send to hcp",
"contact hcp",
"crm",
"schedule meeting",
"book meeting",
"calendar invite",
"modify crm",
];
if (containsAny(text, class2)) {
return 2;
}
// Class 1: safe internal work.
const class1 = [
"report",
"summary",
"summarize",
"reconcile",
"reconciliation",
"route",
"route calculation",
"internal",
"dashboard",
"analysis",
"analyze",
];
if (containsAny(text, class1)) {
return 1;
}
// Default: if external send is involved but no higher signal, Class 2.
if (externalSend) return 2;
// Conservative default for unrecognized outcomes.
return 1;
}
/**
* Determine whether the sender appears to hold managerial authority based on
* their display name and the email subject.
*/
function isAuthoritySender(sender: string, subject: string): boolean {
return containsAny(sender, AUTHORITY_SIGNALS) || containsAny(subject, AUTHORITY_SIGNALS);
}
/**
* Extract a concise requested-outcome description from the subject and body.
* Prefers the subject when it is descriptive; otherwise pulls the first
* sentence of the body that contains a request keyword.
*/
function extractRequestedOutcome(subject: string, body: string): string {
const subjectTrimmed = subject.trim();
if (subjectTrimmed.length > 0 && containsAny(subjectTrimmed, REQUEST_KEYWORDS)) {
return subjectTrimmed;
}
const firstSentence = body.split(/[.\n!]/)[0]?.trim() ?? "";
if (firstSentence.length > 0) {
return firstSentence.slice(0, 240);
}
return subjectTrimmed || "Unspecified work request";
}
function extractEmailAddress(sender: string): string {
if (!sender) return "";
const match = sender.match(/<([^>]+)>/);
return match?.[1]?.trim() ?? "";
}
/**
* Infer mandatory outputs from the request text. These are the deliverables
* the requester explicitly expects.
*/
function inferMandatoryOutputs(text: string): string[] {
const lower = text.toLowerCase();
const outputs: string[] = [];
if (contains(lower, "report")) outputs.push("Written report");
if (contains(lower, "summary") || contains(lower, "summarize")) outputs.push("Executive summary");
if (contains(lower, "analysis") || contains(lower, "analyze")) outputs.push("Analysis document");
if (contains(lower, "brief") || contains(lower, "pre-call")) outputs.push("Pre-call brief");
if (contains(lower, "draft")) outputs.push("Draft document");
if (contains(lower, "schedule")) outputs.push("Scheduled meeting invite");
if (contains(lower, "review")) outputs.push("Review checklist");
if (contains(lower, "update")) outputs.push("Updated record/document");
if (contains(lower, "route") || contains(lower, "plan")) outputs.push("Route/plan document");
if (contains(lower, "formulary")) outputs.push("Formulary analysis");
if (contains(lower, "compliance")) outputs.push("Compliance review memo");
if (outputs.length === 0) outputs.push("Response to request");
return outputs;
}
/**
* Infer additional useful deliverables that go beyond the explicit ask but
* remain within the recipient's permitted scope.
*/
function inferAdditionalOutputs(text: string, role: RoleType): string[] {
const lower = text.toLowerCase();
const extras: string[] = [];
if (role === "field_representative") {
extras.push("Recommended next-best-action for territory");
if (contains(lower, "territory")) extras.push("Territory coverage map");
}
if (role === "regional_manager") {
extras.push("Team performance snapshot");
}
if (role === "medical_affairs") {
extras.push("Evidence quality assessment");
}
if (role === "market_access") {
extras.push("Payer landscape summary");
}
if (role === "compliance") {
extras.push("Risk mitigation recommendations");
}
if (contains(lower, "deadline") || contains(lower, "by ")) {
extras.push("Timeline with milestones");
}
return extras;
}
/**
* Infer blocking dependencies that could reduce completion probability.
*/
function inferDependencies(text: string): { description: string; blocksProbability: number }[] {
const lower = text.toLowerCase();
const deps: { description: string; blocksProbability: number }[] = [];
if (contains(lower, "attachment") || contains(lower, "attached")) {
deps.push({ description: "Attachment must be parsed and validated", blocksProbability: 0.2 });
}
if (contains(lower, "approval") || contains(lower, "approve")) {
deps.push({ description: "Manager approval required before execution", blocksProbability: 0.35 });
}
if (contains(lower, "data") || contains(lower, "crm")) {
deps.push({ description: "CRM/data source availability", blocksProbability: 0.15 });
}
if (contains(lower, "compliance")) {
deps.push({ description: "Compliance review gate", blocksProbability: 0.3 });
}
return deps;
}
/**
* Estimate completion probability (0.7-0.95) based on complexity signals.
*/
function estimateCompletionProbability(text: string, autonomyClass: AutonomyClass): number {
const lower = text.toLowerCase();
let probability = 0.9;
// Complexity reducers.
if (contains(lower, "complex") || contains(lower, "detailed")) probability -= 0.08;
if (contains(lower, "multiple") || contains(lower, "all")) probability -= 0.05;
if (contains(lower, "approval") || contains(lower, "approve")) probability -= 0.07;
if (contains(lower, "compliance")) probability -= 0.06;
// Higher autonomy classes carry more uncertainty.
if (autonomyClass >= 3) probability -= 0.05;
if (autonomyClass === 4) probability -= 0.05;
// Clamp to the required band.
return Math.max(0.7, Math.min(0.95, Number(probability.toFixed(2))));
}
/**
* Scan processed email records for work requests and return a list of
* CommitmentContract instances, one per detected request.
*/
export function detectCommitments(records: ProcessedEmailRecord[]): CommitmentContract[] {
const commitments: CommitmentContract[] = [];
for (const record of records) {
const subject = record.subject ?? "";
const body = record.extractedData?.summary ?? "";
const combinedText = `${subject} ${body}`;
// Skip records that do not contain any request signal.
if (!containsAny(combinedText, REQUEST_KEYWORDS)) {
continue;
}
const role = inferRole(subject, body);
const outcome = extractRequestedOutcome(subject, body);
const externalSend = false;
const autonomyClass = classifyAutonomy(outcome, externalSend);
const deadline = extractDeadline(combinedText) || daysFromNow(3);
const mandatoryOutputs = inferMandatoryOutputs(combinedText);
const inferredOutputs = inferAdditionalOutputs(combinedText, role);
const dependencies = inferDependencies(combinedText);
const completionProbability = estimateCompletionProbability(combinedText, autonomyClass);
const authority = isAuthoritySender(record.sender, subject);
const now = new Date().toISOString();
const contract: CommitmentContract = {
id: nanoid(),
emailId: record.emailId,
emailSubject: subject,
requester: record.sender,
requesterEmail: record.senderEmail || extractEmailAddress(record.sender) || "",
recipientRole: role,
authorityVerified: authority,
requestedOutcome: outcome,
deadline,
mandatoryOutputs,
inferredOutputs,
permittedTools: ROLE_TOOLS[role],
externalSendAllowed: externalSend,
autonomyClass,
completionProbability,
dependencies,
assumptions: [
"Request is directed at the inferred recipient role.",
"Standard content library and policy package apply.",
"No off-label or promotional claims are permitted.",
],
status: "detected",
auditEvents: [
{
timestamp: now,
event: "detected",
detail: `Commitment detected from email "${subject}" with autonomy class ${autonomyClass}.`,
},
],
detectedAt: now,
};
commitments.push(contract);
}
return commitments;
}
// ─── Sample data ───────────────────────────────────────────────────
/**
* Generate five realistic sample commitments for a Gilead field team.
* These mirror the kinds of requests a field representative, regional
* manager, medical affairs, market access, and compliance role would
* receive, and are useful for demos, tests, and seeding the UI.
*/
export function generateSampleCommitments(): CommitmentContract[] {
const now = new Date().toISOString();
const baseAssumptions = [
"Request is directed at the inferred recipient role.",
"Standard content library and policy package apply.",
"No off-label or promotional claims are permitted.",
];
const makeAudit = (subject: string, autonomyClass: AutonomyClass) => [
{
timestamp: now,
event: "detected",
detail: `Sample commitment generated for "${subject}" with autonomy class ${autonomyClass}.`,
},
];
// 1. Territory review request (field representative)
const territoryOutcome = "Prepare territory review report for Northern California this week";
const territoryRole: RoleType = "field_representative";
const territoryAutonomy = classifyAutonomy(territoryOutcome, false);
// 2. Pre-call brief request (field representative)
const briefOutcome = "Draft pre-call brief for Dr. Chen cardiology visit";
const briefRole: RoleType = "field_representative";
const briefAutonomy = classifyAutonomy(briefOutcome, false);
// 3. Formulary analysis request (market access)
const formularyOutcome = "Analyze formulary status for Biktarvy across top regional payers";
const formularyRole: RoleType = "market_access";
const formularyAutonomy = classifyAutonomy(formularyOutcome, false);
// 4. Medical information response (medical affairs)
const medInfoOutcome = "Prepare medical information response with evidence summary on Descovy";
const medInfoRole: RoleType = "medical_affairs";
const medInfoAutonomy = classifyAutonomy(medInfoOutcome, false);
// 5. Compliance review (compliance)
const complianceOutcome = "Compliance review of proposed HCP engagement materials";
const complianceRole: RoleType = "compliance";
const complianceAutonomy = classifyAutonomy(complianceOutcome, false);
const samples: Array<{
subject: string;
sender: string;
outcome: string;
role: RoleType;
autonomy: AutonomyClass;
deadline: string;
mandatory: string[];
inferred: string[];
dependencies: { description: string; blocksProbability: number }[];
probability: number;
}> = [
{
subject: "Territory review needed by Friday",
sender: "Sarah Martinez, Regional Manager",
outcome: territoryOutcome,
role: territoryRole,
autonomy: territoryAutonomy,
deadline: daysFromNow(nextWeekdayOffset(5)),
mandatory: ["Written report", "Territory coverage map"],
inferred: ["Recommended next-best-action for territory", "Timeline with milestones"],
dependencies: [
{ description: "CRM/data source availability", blocksProbability: 0.15 },
],
probability: 0.9,
},
{
subject: "Please prepare pre-call brief for Dr. Chen",
sender: "James Park, Field Lead",
outcome: briefOutcome,
role: briefRole,
autonomy: briefAutonomy,
deadline: daysFromNow(2),
mandatory: ["Pre-call brief"],
inferred: ["Recommended next-best-action for territory"],
dependencies: [],
probability: 0.92,
},
{
subject: "Formulary analysis request - Biktarvy payer coverage",
sender: "Dana Whitfield, Market Access Director",
outcome: formularyOutcome,
role: formularyRole,
autonomy: formularyAutonomy,
deadline: daysFromNow(5),
mandatory: ["Formulary analysis", "Analysis document"],
inferred: ["Payer landscape summary"],
dependencies: [
{ description: "Formulary database availability", blocksProbability: 0.2 },
],
probability: 0.85,
},
{
subject: "Medical information response needed - Descovy evidence",
sender: "Dr. Priya Nair, Medical Affairs Lead",
outcome: medInfoOutcome,
role: medInfoRole,
autonomy: medInfoAutonomy,
deadline: daysFromNow(3),
mandatory: ["Medical information response", "Evidence quality assessment"],
inferred: ["Evidence quality assessment"],
dependencies: [
{ description: "Literature index availability", blocksProbability: 0.15 },
],
probability: 0.88,
},
{
subject: "Compliance review of HCP engagement materials",
sender: "Robert Osei, Compliance Director",
outcome: complianceOutcome,
role: complianceRole,
autonomy: complianceAutonomy,
deadline: daysFromNow(4),
mandatory: ["Compliance review memo", "Review checklist"],
inferred: ["Risk mitigation recommendations"],
dependencies: [
{ description: "Compliance review gate", blocksProbability: 0.3 },
],
probability: 0.82,
},
];
return samples.map((s) => ({
id: nanoid(),
emailId: nanoid(),
emailSubject: s.subject,
requester: s.sender,
requesterEmail: s.sender,
recipientRole: s.role,
authorityVerified: isAuthoritySender(s.sender, s.subject),
requestedOutcome: s.outcome,
deadline: s.deadline,
mandatoryOutputs: s.mandatory,
inferredOutputs: s.inferred,
permittedTools: ROLE_TOOLS[s.role],
externalSendAllowed: false,
autonomyClass: s.autonomy,
completionProbability: s.probability,
dependencies: s.dependencies,
assumptions: baseAssumptions,
status: "detected" as const,
auditEvents: makeAudit(s.subject, s.autonomy),
detectedAt: now,
}));
}