mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: supervisor AI — 4 tools + dedicated system prompt
- get_agent_performance: call counts, conversion, NPS, threshold breaches - get_campaign_stats: lead counts, conversion per campaign - get_call_summary: aggregate stats by period with disposition breakdown - get_sla_breaches: missed calls past SLA threshold - Supervisor system prompt: unbiased, data-grounded, threshold-based - Context routing: supervisor/rules-engine/agent tool sets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,8 @@ export class AiChatController {
|
|||||||
// Rules engine context — use rules-specific system prompt
|
// Rules engine context — use rules-specific system prompt
|
||||||
if (ctx?.type === 'rules-engine') {
|
if (ctx?.type === 'rules-engine') {
|
||||||
systemPrompt = this.buildRulesSystemPrompt(ctx.currentConfig);
|
systemPrompt = this.buildRulesSystemPrompt(ctx.currentConfig);
|
||||||
|
} else if (ctx?.type === 'supervisor') {
|
||||||
|
systemPrompt = this.buildSupervisorSystemPrompt();
|
||||||
} else {
|
} else {
|
||||||
const kb = await this.buildKnowledgeBase(auth);
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
systemPrompt = this.buildSystemPrompt(kb);
|
systemPrompt = this.buildSystemPrompt(kb);
|
||||||
@@ -98,13 +100,187 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const platformService = this.platform;
|
const platformService = this.platform;
|
||||||
|
const isSupervisor = ctx?.type === 'supervisor';
|
||||||
|
|
||||||
const result = streamText({
|
// Supervisor tools — agent performance, campaign stats, team metrics
|
||||||
model: this.aiModel,
|
const supervisorTools = {
|
||||||
system: systemPrompt,
|
get_agent_performance: tool({
|
||||||
messages,
|
description: 'Get performance metrics for all agents or a specific agent. Returns call counts, conversion rates, idle time, NPS scores.',
|
||||||
stopWhen: stepCountIs(5),
|
inputSchema: z.object({
|
||||||
tools: {
|
agentName: z.string().optional().describe('Agent name to look up. Leave empty for all agents.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ agentName }) => {
|
||||||
|
const [callsData, leadsData, agentsData, followUpsData] = await Promise.all([
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ followUps(first: 100) { edges { node { id assignedAgent status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calls = callsData.calls.edges.map((e: any) => e.node);
|
||||||
|
const leads = leadsData.leads.edges.map((e: any) => e.node);
|
||||||
|
const agents = agentsData.agents.edges.map((e: any) => e.node);
|
||||||
|
const followUps = followUpsData.followUps.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
const agentMetrics = agents
|
||||||
|
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
||||||
|
.map((agent: any) => {
|
||||||
|
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const apptBooked = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||||
|
const pendingFollowUps = agentFollowUps.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE').length;
|
||||||
|
const conversionRate = totalCalls > 0 ? Math.round((apptBooked / totalCalls) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
totalCalls,
|
||||||
|
completed,
|
||||||
|
missed,
|
||||||
|
appointmentsBooked: apptBooked,
|
||||||
|
conversionRate: `${conversionRate}%`,
|
||||||
|
assignedLeads: agentLeads.length,
|
||||||
|
pendingFollowUps,
|
||||||
|
npsScore: agent.npsscore,
|
||||||
|
maxIdleMinutes: agent.maxidleminutes,
|
||||||
|
minNpsThreshold: agent.minnpsthreshold,
|
||||||
|
minConversionPercent: agent.minconversionpercent,
|
||||||
|
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
||||||
|
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { agents: agentMetrics, totalAgents: agentMetrics.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_campaign_stats: tool({
|
||||||
|
description: 'Get campaign performance stats — lead counts, conversion rates, sources.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const [campaignsData, leadsData] = await Promise.all([
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ campaigns(first: 20) { edges { node { id campaignName campaignStatus platform leadCount convertedCount budget { amountMicros } } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id campaignId status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const campaigns = campaignsData.campaigns.edges.map((e: any) => e.node);
|
||||||
|
const leads = leadsData.leads.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaigns: campaigns.map((c: any) => {
|
||||||
|
const campaignLeads = leads.filter((l: any) => l.campaignId === c.id);
|
||||||
|
const converted = campaignLeads.filter((l: any) => l.status === 'CONVERTED' || l.status === 'APPOINTMENT_SET').length;
|
||||||
|
return {
|
||||||
|
name: c.campaignName,
|
||||||
|
status: c.campaignStatus,
|
||||||
|
platform: c.platform,
|
||||||
|
totalLeads: campaignLeads.length,
|
||||||
|
converted,
|
||||||
|
conversionRate: campaignLeads.length > 0 ? `${Math.round((converted / campaignLeads.length) * 100)}%` : '0%',
|
||||||
|
budget: c.budget ? `₹${c.budget.amountMicros / 1_000_000}` : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_call_summary: tool({
|
||||||
|
description: 'Get aggregate call statistics — total calls, inbound/outbound split, missed call rate, average duration, disposition breakdown.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
period: z.string().optional().describe('Period: "today", "week", "month". Defaults to "week".'),
|
||||||
|
}),
|
||||||
|
execute: async ({ period }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 500, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const allCalls = data.calls.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
// Filter by period
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now);
|
||||||
|
if (period === 'today') start.setHours(0, 0, 0, 0);
|
||||||
|
else if (period === 'month') start.setDate(start.getDate() - 30);
|
||||||
|
else start.setDate(start.getDate() - 7); // default week
|
||||||
|
|
||||||
|
const calls = allCalls.filter((c: any) => c.startedAt && new Date(c.startedAt) >= start);
|
||||||
|
|
||||||
|
const total = calls.length;
|
||||||
|
const inbound = calls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||||
|
const outbound = total - inbound;
|
||||||
|
const missed = calls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
const completed = calls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalDuration = calls.reduce((sum: number, c: any) => sum + (c.durationSec ?? 0), 0);
|
||||||
|
const avgDuration = completed > 0 ? Math.round(totalDuration / completed) : 0;
|
||||||
|
|
||||||
|
const dispositions: Record<string, number> = {};
|
||||||
|
for (const c of calls) {
|
||||||
|
if (c.disposition) dispositions[c.disposition] = (dispositions[c.disposition] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: period ?? 'week',
|
||||||
|
total,
|
||||||
|
inbound,
|
||||||
|
outbound,
|
||||||
|
missed,
|
||||||
|
completed,
|
||||||
|
missedRate: total > 0 ? `${Math.round((missed / total) * 100)}%` : '0%',
|
||||||
|
avgDurationSeconds: avgDuration,
|
||||||
|
dispositions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_sla_breaches: tool({
|
||||||
|
description: 'Get calls/leads that have breached their SLA — items that were not handled within the expected timeframe.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackstatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const breached = data.calls.edges
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((c: any) => (c.sla ?? 0) > 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
breachedCount: breached.length,
|
||||||
|
items: breached.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
phone: c.callerNumber?.primaryPhoneNumber ?? 'Unknown',
|
||||||
|
slaPercent: c.sla,
|
||||||
|
missedAt: c.startedAt,
|
||||||
|
agent: c.agentName,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent tools — patient lookup, appointments, doctors
|
||||||
|
const agentTools = {
|
||||||
lookup_patient: tool({
|
lookup_patient: tool({
|
||||||
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
@@ -284,7 +460,14 @@ export class AiChatController {
|
|||||||
return { calls: data.calls.edges.map((e: any) => e.node) };
|
return { calls: data.calls.edges.map((e: any) => e.node) };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: this.aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
stopWhen: stepCountIs(5),
|
||||||
|
tools: isSupervisor ? supervisorTools : agentTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = result.toTextStreamResponse();
|
const response = result.toTextStreamResponse();
|
||||||
@@ -461,6 +644,27 @@ export class AiChatController {
|
|||||||
return this.knowledgeBase;
|
return this.knowledgeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildSupervisorSystemPrompt(): string {
|
||||||
|
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
||||||
|
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||||
|
|
||||||
|
## YOUR CAPABILITIES
|
||||||
|
You have access to tools that query real-time data:
|
||||||
|
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
|
||||||
|
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
|
||||||
|
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
|
||||||
|
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
|
||||||
|
|
||||||
|
## RULES
|
||||||
|
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
|
||||||
|
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
|
||||||
|
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
|
||||||
|
4. Be concise — supervisors want quick answers. Use bullet points.
|
||||||
|
5. When recommending actions, ground them in the data returned by tools.
|
||||||
|
6. If asked about trends, use the call summary tool with different periods.
|
||||||
|
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
|
||||||
|
}
|
||||||
|
|
||||||
private buildRulesSystemPrompt(currentConfig: any): string {
|
private buildRulesSystemPrompt(currentConfig: any): string {
|
||||||
const configJson = JSON.stringify(currentConfig, null, 2);
|
const configJson = JSON.stringify(currentConfig, null, 2);
|
||||||
return `You are an AI assistant helping a hospital supervisor configure the Rules Engine for their call center worklist.
|
return `You are an AI assistant helping a hospital supervisor configure the Rules Engine for their call center worklist.
|
||||||
|
|||||||
Reference in New Issue
Block a user