mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat(performance-alerts): rules-engine-driven alerts, persisted as PerformanceAlert
Phase A+B of the alerts overhaul: - New PerformanceFactsProvider exposes agent.idleMinutes (from AgentSession), agent.busyMinutes, agent.totalCallsToday, agent.bookedCallsToday, agent.conversionPercent - Implement EscalateActionHandler (was a stub): persists a PerformanceAlert row, dedupes per agent+type+IST date so a 5-min cron can't spam, updates value if it changes - New PerformanceConsumer: setInterval every 5 min, reads on_schedule rules referencing agent.* facts, evaluates per agent, dispatches escalate actions - Two starter rules in hospital-starter.json: excessive-idle (>60min) and low-conversion (<15% with >10 calls today). NPS deferred — no source signal exists yet - New PerformanceAlertsController: GET /api/supervisor/performance-alerts (active list), POST /:id/dismiss, POST /dismiss-all - Rules engine now injects EscalateActionHandler via DI so the action has access to PlatformGraphqlService for persistence Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,94 @@
|
|||||||
// src/rules-engine/actions/escalate.action.ts
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
import type { ActionHandler, ActionResult } from '../types/action.types';
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
import type { RuleAction } from '../types/rule.types';
|
import type { RuleAction, EscalateActionParams } from '../types/rule.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a PerformanceAlert when a rule's escalate action fires.
|
||||||
|
*
|
||||||
|
* Dedupes by (agentId, alertType, IST date) — a single rule firing every
|
||||||
|
* 5 min should only produce ONE alert per day per agent until dismissed.
|
||||||
|
* If a row already exists for that key today and is not dismissed, the
|
||||||
|
* action is a no-op (returns the existing id). If the existing row was
|
||||||
|
* dismissed earlier today, we don't re-fire — supervisor explicitly
|
||||||
|
* acknowledged.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
export class EscalateActionHandler implements ActionHandler {
|
export class EscalateActionHandler implements ActionHandler {
|
||||||
type = 'escalate';
|
type = 'escalate';
|
||||||
|
private readonly logger = new Logger(EscalateActionHandler.name);
|
||||||
|
|
||||||
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
return { success: true, data: { stub: true, action: 'escalate' } };
|
|
||||||
|
async execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
const params = action.params as EscalateActionParams & { ruleId?: string; alertType?: string };
|
||||||
|
const agentId = context['agent.id'] as string | undefined;
|
||||||
|
const agentName = (context['agent.name'] as string | undefined) ?? '';
|
||||||
|
const valueRaw = context['_alertValue'];
|
||||||
|
const valueText = valueRaw != null ? String(valueRaw) : null;
|
||||||
|
|
||||||
|
if (!agentId) {
|
||||||
|
return { success: false, error: 'agent.id missing from facts' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertType = params.alertType ?? this.inferAlertType(params.message);
|
||||||
|
const severity = (params.severity ?? 'warning').toUpperCase(); // INFO | WARNING | CRITICAL
|
||||||
|
const today = this.todayIst();
|
||||||
|
|
||||||
|
// Dedupe: any non-dismissed alert today for this agent + type?
|
||||||
|
try {
|
||||||
|
const existing = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(first: 1, filter: {
|
||||||
|
agentId: { eq: "${agentId}" },
|
||||||
|
alertType: { eq: ${alertType} },
|
||||||
|
firedAt: { gte: "${today}T00:00:00+05:30", lte: "${today}T23:59:59+05:30" }
|
||||||
|
}) { edges { node { id dismissedAt value } } } }`,
|
||||||
|
);
|
||||||
|
const existingNode = existing?.performanceAlerts?.edges?.[0]?.node;
|
||||||
|
if (existingNode) {
|
||||||
|
// Already fired today. If value changed, update it; otherwise no-op.
|
||||||
|
if (!existingNode.dismissedAt && existingNode.value !== valueText) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: existingNode.id, data: { value: valueText } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { success: true, data: { id: existingNode.id, deduped: true, agentId, alertType } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.platform.query<any>(
|
||||||
|
`mutation($data: PerformanceAlertCreateInput!) { createPerformanceAlert(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
agentId,
|
||||||
|
alertType,
|
||||||
|
severity,
|
||||||
|
message: params.message ?? alertType,
|
||||||
|
value: valueText,
|
||||||
|
ruleId: params.ruleId ?? null,
|
||||||
|
firedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const id = created?.createPerformanceAlert?.id;
|
||||||
|
this.logger.log(`[ESCALATE] Created alert ${id} agent=${agentName ?? agentId} type=${alertType} value=${valueText}`);
|
||||||
|
return { success: true, data: { id, agentId, alertType, severity, message: params.message } };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[ESCALATE] Failed for agent=${agentId}: ${err?.message ?? err}`);
|
||||||
|
return { success: false, error: String(err?.message ?? err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferAlertType(message: string | undefined): string {
|
||||||
|
const m = (message ?? '').toLowerCase();
|
||||||
|
if (m.includes('idle')) return 'EXCESSIVE_IDLE';
|
||||||
|
if (m.includes('nps')) return 'LOW_NPS';
|
||||||
|
if (m.includes('conversion')) return 'LOW_CONVERSION';
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayIst(): string {
|
||||||
|
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||||
|
return ist.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/rules-engine/consumers/performance.consumer.ts
Normal file
114
src/rules-engine/consumers/performance.consumer.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { RulesEngineService } from '../rules-engine.service';
|
||||||
|
import { RulesStorageService } from '../rules-storage.service';
|
||||||
|
import { PerformanceFactsProvider } from '../facts/performance-facts.provider';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
const TICK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const KICKOFF_DELAY_MS = 90_000; // wait for boot to settle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates `on_schedule` performance rules every 5 minutes for every
|
||||||
|
* platform Agent. Facts come from PerformanceFactsProvider; matching
|
||||||
|
* rules dispatch the escalate action which persists a PerformanceAlert.
|
||||||
|
*
|
||||||
|
* Skips quietly when no scheduled performance rules are configured.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PerformanceConsumer implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(PerformanceConsumer.name);
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly engine: RulesEngineService,
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
private readonly facts: PerformanceFactsProvider,
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.runOnce().catch((err) => {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] First run failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, KICKOFF_DELAY_MS);
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.runOnce().catch((err) => {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] Tick failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, TICK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<{ agentsScanned: number; alertsFired: number }> {
|
||||||
|
// Storage.getByTrigger doesn't sub-discriminate on_schedule rules, so
|
||||||
|
// filter to only those that reference agent.* facts in their conditions.
|
||||||
|
// Anything else (e.g. SLA-breach rules over call.* facts) belongs to
|
||||||
|
// other consumers.
|
||||||
|
const allScheduled = await this.storage.getByTrigger('on_schedule');
|
||||||
|
const rules = allScheduled.filter((r) => this.referencesAgentFacts(r.conditions));
|
||||||
|
if (rules.length === 0) {
|
||||||
|
this.logger.debug('[PERF-CONSUMER] No agent-fact on_schedule rules — skipping');
|
||||||
|
return { agentsScanned: 0, alertsFired: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await this.fetchAgents();
|
||||||
|
if (agents.length === 0) return { agentsScanned: 0, alertsFired: 0 };
|
||||||
|
|
||||||
|
let alertsFired = 0;
|
||||||
|
for (const agent of agents) {
|
||||||
|
try {
|
||||||
|
const factContext = await this.facts.resolveFacts({ agentId: agent.id, agentName: agent.name });
|
||||||
|
|
||||||
|
// Each rule's escalate action needs to know which fact value
|
||||||
|
// to surface as the alert's value (e.g. "65m" for idle).
|
||||||
|
// Inject _alertValue per-rule below.
|
||||||
|
for (const rule of rules) {
|
||||||
|
const ruleFacts = { ...factContext };
|
||||||
|
const valueFact = (rule.action.params as any)?.valueFact as string | undefined;
|
||||||
|
if (valueFact && ruleFacts[valueFact] != null) {
|
||||||
|
ruleFacts['_alertValue'] = ruleFacts[valueFact];
|
||||||
|
}
|
||||||
|
const result = await this.engine.evaluate('on_schedule', 'performance', ruleFacts);
|
||||||
|
alertsFired += result.results.filter((r: any) => r.success && !r.data?.deduped).length;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] Eval failed for agent=${agent.id}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alertsFired > 0) {
|
||||||
|
this.logger.log(`[PERF-CONSUMER] Tick complete — agents=${agents.length} alertsFired=${alertsFired}`);
|
||||||
|
}
|
||||||
|
return { agentsScanned: agents.length, alertsFired };
|
||||||
|
}
|
||||||
|
|
||||||
|
private referencesAgentFacts(group: any): boolean {
|
||||||
|
if (!group) return false;
|
||||||
|
const items = group.all ?? group.any ?? [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.all || item.any) {
|
||||||
|
if (this.referencesAgentFacts(item)) return true;
|
||||||
|
} else if (typeof item.fact === 'string' && item.fact.startsWith('agent.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAgents(): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name } } } }`,
|
||||||
|
);
|
||||||
|
return (data?.agents?.edges ?? []).map((e: any) => e.node);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF-CONSUMER] Agent fetch failed: ${err}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves per-agent performance facts for the rules engine.
|
||||||
|
* Used by the PerformanceConsumer to evaluate alert rules every 5 min.
|
||||||
|
*
|
||||||
|
* Facts exposed:
|
||||||
|
* - agent.idleMinutes — from today's AgentSession.idleTimeS
|
||||||
|
* - agent.busyMinutes — from AgentSession.busyTimeS
|
||||||
|
* - agent.totalCallsToday — count of Calls started today
|
||||||
|
* - agent.bookedCallsToday — count of Calls today with disposition=APPOINTMENT_BOOKED
|
||||||
|
* - agent.conversionPercent — bookedCallsToday / totalCallsToday × 100
|
||||||
|
* - agent.id, agent.name — for routing alerts back to the right agent
|
||||||
|
*
|
||||||
|
* NPS deferred — no source signal exists yet.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PerformanceFactsProvider implements FactProvider {
|
||||||
|
name = 'performance';
|
||||||
|
private readonly logger = new Logger(PerformanceFactsProvider.name);
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param entityData { agentId: string, agentName?: string }
|
||||||
|
*/
|
||||||
|
async resolveFacts(entityData: { agentId: string; agentName?: string }): Promise<Record<string, FactValue>> {
|
||||||
|
const agentId = entityData.agentId;
|
||||||
|
const today = this.todayIst();
|
||||||
|
|
||||||
|
const session = await this.fetchTodaySession(agentId, today);
|
||||||
|
const callTotals = await this.fetchTodayCallTotals(agentId, today);
|
||||||
|
|
||||||
|
const idleMinutes = Math.round((session?.idleTimeS ?? 0) / 60);
|
||||||
|
const busyMinutes = Math.round((session?.busyTimeS ?? 0) / 60);
|
||||||
|
const conversionPercent = callTotals.total > 0
|
||||||
|
? Math.round((callTotals.booked / callTotals.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'agent.id': agentId,
|
||||||
|
'agent.name': entityData.agentName ?? '',
|
||||||
|
'agent.idleMinutes': idleMinutes,
|
||||||
|
'agent.busyMinutes': busyMinutes,
|
||||||
|
'agent.totalCallsToday': callTotals.total,
|
||||||
|
'agent.bookedCallsToday': callTotals.booked,
|
||||||
|
'agent.conversionPercent': conversionPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayIst(): string {
|
||||||
|
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||||
|
return ist.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTodaySession(agentId: string, date: string): Promise<{ idleTimeS: number; busyTimeS: number } | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 1, filter: { agentId: { eq: "${agentId}" }, date: { eq: "${date}" } }) {
|
||||||
|
edges { node { idleTimeS busyTimeS } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||||
|
if (!node) return null;
|
||||||
|
return { idleTimeS: node.idleTimeS ?? 0, busyTimeS: node.busyTimeS ?? 0 };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF-FACTS] Session fetch failed for agent=${agentId}: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTodayCallTotals(agentId: string, date: string): Promise<{ total: number; booked: number }> {
|
||||||
|
const gte = `${date}T00:00:00+05:30`;
|
||||||
|
const lte = `${date}T23:59:59+05:30`;
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200, filter: {
|
||||||
|
agentId: { eq: "${agentId}" },
|
||||||
|
startedAt: { gte: "${gte}", lte: "${lte}" }
|
||||||
|
}) { edges { node { disposition } } } }`,
|
||||||
|
);
|
||||||
|
const edges = data?.calls?.edges ?? [];
|
||||||
|
const total = edges.length;
|
||||||
|
const booked = edges.filter((e: any) => e.node.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
return { total, booked };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF-FACTS] Call totals fetch failed for agent=${agentId}: ${err}`);
|
||||||
|
return { total: 0, booked: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
// src/rules-engine/rules-engine.module.ts
|
// src/rules-engine/rules-engine.module.ts
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { RulesEngineController } from './rules-engine.controller';
|
import { RulesEngineController } from './rules-engine.controller';
|
||||||
import { RulesEngineService } from './rules-engine.service';
|
import { RulesEngineService } from './rules-engine.service';
|
||||||
import { RulesStorageService } from './rules-storage.service';
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
import { WorklistConsumer } from './consumers/worklist.consumer';
|
import { WorklistConsumer } from './consumers/worklist.consumer';
|
||||||
|
import { PerformanceConsumer } from './consumers/performance.consumer';
|
||||||
|
import { EscalateActionHandler } from './actions/escalate.action';
|
||||||
|
import { PerformanceFactsProvider } from './facts/performance-facts.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
controllers: [RulesEngineController],
|
controllers: [RulesEngineController],
|
||||||
providers: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
providers: [
|
||||||
exports: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
RulesEngineService,
|
||||||
|
RulesStorageService,
|
||||||
|
WorklistConsumer,
|
||||||
|
PerformanceConsumer,
|
||||||
|
EscalateActionHandler,
|
||||||
|
PerformanceFactsProvider,
|
||||||
|
],
|
||||||
|
exports: [RulesEngineService, RulesStorageService, WorklistConsumer, PerformanceConsumer],
|
||||||
})
|
})
|
||||||
export class RulesEngineModule {}
|
export class RulesEngineModule {}
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ export class RulesEngineService {
|
|||||||
private readonly agentFacts = new AgentFactsProvider();
|
private readonly agentFacts = new AgentFactsProvider();
|
||||||
private readonly actionHandlers: Map<string, ActionHandler>;
|
private readonly actionHandlers: Map<string, ActionHandler>;
|
||||||
|
|
||||||
constructor(private readonly storage: RulesStorageService) {
|
constructor(
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
private readonly escalateHandler: EscalateActionHandler,
|
||||||
|
) {
|
||||||
this.actionHandlers = new Map([
|
this.actionHandlers = new Map([
|
||||||
['score', new ScoreActionHandler()],
|
['score', new ScoreActionHandler()],
|
||||||
['assign', new AssignActionHandler()],
|
['assign', new AssignActionHandler()],
|
||||||
['escalate', new EscalateActionHandler()],
|
['escalate', this.escalateHandler],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,51 @@
|
|||||||
"trigger": { "type": "on_schedule", "interval": "5m" },
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
"conditions": { "all": [{ "fact": "call.slaBreached", "operator": "equal", "value": true }, { "fact": "call.callbackStatus", "operator": "equal", "value": "PENDING_CALLBACK" }] },
|
"conditions": { "all": [{ "fact": "call.slaBreached", "operator": "equal", "value": true }, { "fact": "call.callbackStatus", "operator": "equal", "value": "PENDING_CALLBACK" }] },
|
||||||
"action": { "type": "escalate", "params": { "channel": "notification", "recipients": "supervisor", "message": "SLA breached — no callback attempted", "severity": "critical" } }
|
"action": { "type": "escalate", "params": { "channel": "notification", "recipients": "supervisor", "message": "SLA breached — no callback attempted", "severity": "critical" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "Excessive idle time",
|
||||||
|
"description": "Agent has been idle for more than the configured threshold today",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 2,
|
||||||
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
|
"conditions": { "all": [{ "fact": "agent.idleMinutes", "operator": "greaterThan", "value": 60 }] },
|
||||||
|
"action": {
|
||||||
|
"type": "escalate",
|
||||||
|
"params": {
|
||||||
|
"channel": "notification",
|
||||||
|
"recipients": "supervisor",
|
||||||
|
"message": "Excessive Idle Time",
|
||||||
|
"severity": "warning",
|
||||||
|
"alertType": "EXCESSIVE_IDLE",
|
||||||
|
"valueFact": "agent.idleMinutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "Low conversion rate",
|
||||||
|
"description": "Agent's conversion (booked/total) is below the workspace floor",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 3,
|
||||||
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
|
"conditions": {
|
||||||
|
"all": [
|
||||||
|
{ "fact": "agent.conversionPercent", "operator": "lessThan", "value": 15 },
|
||||||
|
{ "fact": "agent.totalCallsToday", "operator": "greaterThan", "value": 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "escalate",
|
||||||
|
"params": {
|
||||||
|
"channel": "notification",
|
||||||
|
"recipients": "supervisor",
|
||||||
|
"message": "Low Conversion",
|
||||||
|
"severity": "warning",
|
||||||
|
"alertType": "LOW_CONVERSION",
|
||||||
|
"valueFact": "agent.conversionPercent"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/supervisor/performance-alerts.controller.ts
Normal file
91
src/supervisor/performance-alerts.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Controller, Get, Post, Param, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read API for the supervisor notification bell. Returns active (non-
|
||||||
|
* dismissed) PerformanceAlert rows the rules engine has emitted.
|
||||||
|
*
|
||||||
|
* Frontend polls every 60s. Dismiss is per-alert.
|
||||||
|
*/
|
||||||
|
@Controller('api/supervisor/performance-alerts')
|
||||||
|
export class PerformanceAlertsController {
|
||||||
|
private readonly logger = new Logger(PerformanceAlertsController.name);
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(
|
||||||
|
first: 50,
|
||||||
|
filter: { dismissedAt: { is: NULL } },
|
||||||
|
orderBy: [{ firedAt: DescNullsLast }]
|
||||||
|
) {
|
||||||
|
edges { node {
|
||||||
|
id alertType severity message value ruleId firedAt
|
||||||
|
agent { id name }
|
||||||
|
} }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.performanceAlerts?.edges ?? [];
|
||||||
|
return {
|
||||||
|
alerts: edges.map((e: any) => {
|
||||||
|
const n = e.node;
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
agent: n.agent?.name ?? 'Unknown',
|
||||||
|
agentId: n.agent?.id ?? null,
|
||||||
|
type: this.toLabel(n.alertType),
|
||||||
|
severity: (n.severity ?? 'WARNING').toLowerCase(),
|
||||||
|
value: n.value ?? '',
|
||||||
|
message: n.message,
|
||||||
|
firedAt: n.firedAt,
|
||||||
|
ruleId: n.ruleId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/dismiss')
|
||||||
|
async dismiss(@Param('id') id: string) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id, data: { dismissedAt: new Date().toISOString() } },
|
||||||
|
);
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[ALERTS] Dismiss failed for ${id}: ${err?.message ?? err}`);
|
||||||
|
return { status: 'error', message: String(err?.message ?? err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLabel(alertType: string | null | undefined): string {
|
||||||
|
switch (alertType) {
|
||||||
|
case 'EXCESSIVE_IDLE': return 'Excessive Idle Time';
|
||||||
|
case 'LOW_NPS': return 'Low NPS';
|
||||||
|
case 'LOW_CONVERSION': return 'Low Conversion';
|
||||||
|
default: return alertType ?? 'Alert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('dismiss-all')
|
||||||
|
async dismissAll() {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(first: 100, filter: { dismissedAt: { is: NULL } }) { edges { node { id } } } }`,
|
||||||
|
);
|
||||||
|
const ids = (data?.performanceAlerts?.edges ?? []).map((e: any) => e.node.id);
|
||||||
|
let dismissed = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id, data: { dismissedAt: now } },
|
||||||
|
);
|
||||||
|
dismissed++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return { status: 'ok', dismissed };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { SupervisorController } from './supervisor.controller';
|
import { SupervisorController } from './supervisor.controller';
|
||||||
import { SupervisorBargeController } from './supervisor-barge.controller';
|
import { SupervisorBargeController } from './supervisor-barge.controller';
|
||||||
|
import { PerformanceAlertsController } from './performance-alerts.controller';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
import { AgentHistoryService } from './agent-history.service';
|
import { AgentHistoryService } from './agent-history.service';
|
||||||
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
||||||
@@ -12,7 +13,7 @@ import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.servic
|
|||||||
// — it causes a circular dependency via AuthModule.
|
// — it causes a circular dependency via AuthModule.
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||||
controllers: [SupervisorController, SupervisorBargeController],
|
controllers: [SupervisorController, SupervisorBargeController, PerformanceAlertsController],
|
||||||
providers: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
providers: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||||
exports: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
exports: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user