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:
2026-04-15 09:02:02 +05:30
parent 5b40f49b65
commit 8dcfa5a72f
8 changed files with 451 additions and 10 deletions

View 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 };
}
}

View File

@@ -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],
})