diff --git a/src/rules-engine/actions/escalate.action.ts b/src/rules-engine/actions/escalate.action.ts index f562172..d2e0c86 100644 --- a/src/rules-engine/actions/escalate.action.ts +++ b/src/rules-engine/actions/escalate.action.ts @@ -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 { 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 { type = 'escalate'; + private readonly logger = new Logger(EscalateActionHandler.name); - async execute(_action: RuleAction, _context: Record): Promise { - return { success: true, data: { stub: true, action: 'escalate' } }; + constructor(private readonly platform: PlatformGraphqlService) {} + + async execute(action: RuleAction, context: Record): Promise { + 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( + `{ 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( + `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( + `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); } } diff --git a/src/rules-engine/consumers/performance.consumer.ts b/src/rules-engine/consumers/performance.consumer.ts new file mode 100644 index 0000000..1401b1f --- /dev/null +++ b/src/rules-engine/consumers/performance.consumer.ts @@ -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> { + try { + const data = await this.platform.query( + `{ 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 []; + } + } +} diff --git a/src/rules-engine/facts/performance-facts.provider.ts b/src/rules-engine/facts/performance-facts.provider.ts new file mode 100644 index 0000000..9135514 --- /dev/null +++ b/src/rules-engine/facts/performance-facts.provider.ts @@ -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> { + 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( + `{ 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( + `{ 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 }; + } + } +} diff --git a/src/rules-engine/rules-engine.module.ts b/src/rules-engine/rules-engine.module.ts index 9228289..80de90c 100644 --- a/src/rules-engine/rules-engine.module.ts +++ b/src/rules-engine/rules-engine.module.ts @@ -1,14 +1,26 @@ // src/rules-engine/rules-engine.module.ts import { Module } from '@nestjs/common'; +import { PlatformModule } from '../platform/platform.module'; import { RulesEngineController } from './rules-engine.controller'; import { RulesEngineService } from './rules-engine.service'; import { RulesStorageService } from './rules-storage.service'; 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({ + imports: [PlatformModule], controllers: [RulesEngineController], - providers: [RulesEngineService, RulesStorageService, WorklistConsumer], - exports: [RulesEngineService, RulesStorageService, WorklistConsumer], + providers: [ + RulesEngineService, + RulesStorageService, + WorklistConsumer, + PerformanceConsumer, + EscalateActionHandler, + PerformanceFactsProvider, + ], + exports: [RulesEngineService, RulesStorageService, WorklistConsumer, PerformanceConsumer], }) export class RulesEngineModule {} diff --git a/src/rules-engine/rules-engine.service.ts b/src/rules-engine/rules-engine.service.ts index 6b3df32..941d1d6 100644 --- a/src/rules-engine/rules-engine.service.ts +++ b/src/rules-engine/rules-engine.service.ts @@ -20,11 +20,14 @@ export class RulesEngineService { private readonly agentFacts = new AgentFactsProvider(); private readonly actionHandlers: Map; - constructor(private readonly storage: RulesStorageService) { + constructor( + private readonly storage: RulesStorageService, + private readonly escalateHandler: EscalateActionHandler, + ) { this.actionHandlers = new Map([ ['score', new ScoreActionHandler()], ['assign', new AssignActionHandler()], - ['escalate', new EscalateActionHandler()], + ['escalate', this.escalateHandler], ]); } diff --git a/src/rules-engine/templates/hospital-starter.json b/src/rules-engine/templates/hospital-starter.json index f7b15f6..107e406 100644 --- a/src/rules-engine/templates/hospital-starter.json +++ b/src/rules-engine/templates/hospital-starter.json @@ -84,6 +84,51 @@ "trigger": { "type": "on_schedule", "interval": "5m" }, "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" } } + }, + { + "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" + } + } } ] } diff --git a/src/supervisor/performance-alerts.controller.ts b/src/supervisor/performance-alerts.controller.ts new file mode 100644 index 0000000..1725a07 --- /dev/null +++ b/src/supervisor/performance-alerts.controller.ts @@ -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( + `{ 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( + `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( + `{ 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( + `mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`, + { id, data: { dismissedAt: now } }, + ); + dismissed++; + } catch {} + } + return { status: 'ok', dismissed }; + } +} diff --git a/src/supervisor/supervisor.module.ts b/src/supervisor/supervisor.module.ts index 4efd537..8e2a08e 100644 --- a/src/supervisor/supervisor.module.ts +++ b/src/supervisor/supervisor.module.ts @@ -3,6 +3,7 @@ import { PlatformModule } from '../platform/platform.module'; import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; import { SupervisorController } from './supervisor.controller'; import { SupervisorBargeController } from './supervisor-barge.controller'; +import { PerformanceAlertsController } from './performance-alerts.controller'; import { SupervisorService } from './supervisor.service'; import { AgentHistoryService } from './agent-history.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. @Module({ imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)], - controllers: [SupervisorController, SupervisorBargeController], + controllers: [SupervisorController, SupervisorBargeController, PerformanceAlertsController], providers: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService], exports: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService], })