mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: event bus with Redpanda + AI insight consumer
- EventBusService: Kafka/Redpanda pub/sub with kafkajs, graceful fallback when broker unavailable - Topics: call.completed, call.missed, agent.state - AiInsightConsumer: on call.completed, fetches lead activity → OpenAI generates summary + suggested action → updates Lead entity on platform - Disposition endpoint emits call.completed event after Ozonetel dispose - EventsModule registered as @Global for cross-module injection - Redpanda container added to VPS docker-compose - Docker image rebuilt for linux/amd64 with kafkajs dependency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import { SearchModule } from './search/search.module';
|
||||
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||
import { MaintModule } from './maint/maint.module';
|
||||
import { RecordingsModule } from './recordings/recordings.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -36,6 +37,7 @@ import { RecordingsModule } from './recordings/recordings.module';
|
||||
SupervisorModule,
|
||||
MaintModule,
|
||||
RecordingsModule,
|
||||
EventsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
119
src/events/consumers/ai-insight.consumer.ts
Normal file
119
src/events/consumers/ai-insight.consumer.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { generateObject } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { EventBusService } from '../event-bus.service';
|
||||
import { Topics } from '../event-types';
|
||||
import type { CallCompletedEvent } from '../event-types';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import { createAiModel } from '../../ai/ai-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
@Injectable()
|
||||
export class AiInsightConsumer implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiInsightConsumer.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
|
||||
constructor(
|
||||
private eventBus: EventBusService,
|
||||
private platform: PlatformGraphqlService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.aiModel = createAiModel(config);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.eventBus.on(Topics.CALL_COMPLETED, (event: CallCompletedEvent) => this.handleCallCompleted(event));
|
||||
}
|
||||
|
||||
private async handleCallCompleted(event: CallCompletedEvent): Promise<void> {
|
||||
if (!event.leadId) {
|
||||
this.logger.debug('[AI-INSIGHT] No leadId — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.aiModel) {
|
||||
this.logger.debug('[AI-INSIGHT] No AI model configured — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`[AI-INSIGHT] Generating insight for lead ${event.leadId}`);
|
||||
|
||||
try {
|
||||
// Fetch lead + all activities
|
||||
const data = await this.platform.query<any>(
|
||||
`{ leads(filter: { id: { eq: "${event.leadId}" } }) { edges { node {
|
||||
id name contactName { firstName lastName }
|
||||
status source interestedService
|
||||
contactAttempts lastContacted
|
||||
} } } }`,
|
||||
);
|
||||
const lead = data?.leads?.edges?.[0]?.node;
|
||||
if (!lead) return;
|
||||
|
||||
const activityData = await this.platform.query<any>(
|
||||
`{ leadActivities(first: 20, filter: { leadId: { eq: "${event.leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||
edges { node { activityType summary occurredAt channel durationSec outcome } }
|
||||
} }`,
|
||||
);
|
||||
const activities = activityData?.leadActivities?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
const leadName = lead.contactName
|
||||
? `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim()
|
||||
: lead.name ?? 'Unknown';
|
||||
|
||||
// Build context
|
||||
const activitySummary = activities.map((a: any) =>
|
||||
`${a.activityType}: ${a.summary} (${a.occurredAt ?? 'unknown date'})`,
|
||||
).join('\n');
|
||||
|
||||
// Generate insight
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel,
|
||||
schema: z.object({
|
||||
summary: z.string().describe('2-3 sentence summary of this lead based on all their interactions'),
|
||||
suggestedAction: z.string().describe('One clear next action for the agent'),
|
||||
}),
|
||||
system: `You are a CRM assistant for Global Hospital Bangalore.
|
||||
Generate a brief, actionable insight about this lead based on their interaction history.
|
||||
Be specific — reference actual dates, dispositions, and patterns.
|
||||
If the lead has booked appointments, mention upcoming ones.
|
||||
If they keep calling about the same thing, note the pattern.`,
|
||||
prompt: `Lead: ${leadName}
|
||||
Status: ${lead.status ?? 'Unknown'}
|
||||
Source: ${lead.source ?? 'Unknown'}
|
||||
Interested in: ${lead.interestedService ?? 'Not specified'}
|
||||
Contact attempts: ${lead.contactAttempts ?? 0}
|
||||
Last contacted: ${lead.lastContacted ?? 'Never'}
|
||||
|
||||
Recent activity (newest first):
|
||||
${activitySummary || 'No activity recorded'}
|
||||
|
||||
Latest call:
|
||||
- Direction: ${event.direction}
|
||||
- Duration: ${event.durationSec}s
|
||||
- Disposition: ${event.disposition}
|
||||
- Notes: ${event.notes ?? 'None'}`,
|
||||
maxOutputTokens: 200,
|
||||
});
|
||||
|
||||
// Update lead with new AI insight
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: event.leadId,
|
||||
data: {
|
||||
aiSummary: object.summary,
|
||||
aiSuggestedAction: object.suggestedAction,
|
||||
lastContacted: new Date().toISOString(),
|
||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`[AI-INSIGHT] Updated lead ${event.leadId}: "${object.summary.substring(0, 60)}..."`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[AI-INSIGHT] Failed for lead ${event.leadId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/events/event-bus.service.ts
Normal file
114
src/events/event-bus.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Kafka, Producer, Consumer, EachMessagePayload } from 'kafkajs';
|
||||
import type { EventPayload } from './event-types';
|
||||
|
||||
type EventHandler = (payload: any) => Promise<void>;
|
||||
|
||||
@Injectable()
|
||||
export class EventBusService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EventBusService.name);
|
||||
private kafka: Kafka;
|
||||
private producer: Producer;
|
||||
private consumer: Consumer;
|
||||
private handlers = new Map<string, EventHandler[]>();
|
||||
private connected = false;
|
||||
|
||||
constructor() {
|
||||
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');
|
||||
this.kafka = new Kafka({
|
||||
clientId: 'helix-engage-sidecar',
|
||||
brokers,
|
||||
retry: { retries: 5, initialRetryTime: 1000 },
|
||||
logLevel: 1, // ERROR only
|
||||
});
|
||||
this.producer = this.kafka.producer();
|
||||
this.consumer = this.kafka.consumer({ groupId: 'helix-engage-workers' });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.producer.connect();
|
||||
await this.consumer.connect();
|
||||
this.connected = true;
|
||||
this.logger.log('Event bus connected (Kafka/Redpanda)');
|
||||
|
||||
// Subscribe to all topics we have handlers for
|
||||
// Handlers are registered by consumer modules during their onModuleInit
|
||||
// We start consuming after a short delay to let all handlers register
|
||||
setTimeout(() => this.startConsuming(), 2000);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Event bus not available (${err.message}) — running without events`);
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.connected) {
|
||||
await this.consumer.disconnect().catch(() => {});
|
||||
await this.producer.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async emit(topic: string, payload: EventPayload): Promise<void> {
|
||||
if (!this.connected) {
|
||||
this.logger.debug(`[EVENT] Skipped (not connected): ${topic}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.producer.send({
|
||||
topic,
|
||||
messages: [{ value: JSON.stringify(payload), timestamp: Date.now().toString() }],
|
||||
});
|
||||
this.logger.log(`[EVENT] Emitted: ${topic}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[EVENT] Failed to emit ${topic}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
on(topic: string, handler: EventHandler): void {
|
||||
const existing = this.handlers.get(topic) ?? [];
|
||||
existing.push(handler);
|
||||
this.handlers.set(topic, existing);
|
||||
this.logger.log(`[EVENT] Handler registered for: ${topic}`);
|
||||
}
|
||||
|
||||
private async startConsuming(): Promise<void> {
|
||||
if (!this.connected) return;
|
||||
|
||||
const topics = Array.from(this.handlers.keys());
|
||||
if (topics.length === 0) {
|
||||
this.logger.log('[EVENT] No handlers registered — skipping consumer');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const topic of topics) {
|
||||
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||
}
|
||||
|
||||
await this.consumer.run({
|
||||
eachMessage: async (payload: EachMessagePayload) => {
|
||||
const { topic, message } = payload;
|
||||
const handlers = this.handlers.get(topic) ?? [];
|
||||
if (handlers.length === 0 || !message.value) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message.value.toString());
|
||||
for (const handler of handlers) {
|
||||
await handler(data).catch(err =>
|
||||
this.logger.error(`[EVENT] Handler error on ${topic}: ${err.message}`),
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[EVENT] Parse error on ${topic}: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[EVENT] Consuming: ${topics.join(', ')}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[EVENT] Consumer failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/events/event-types.ts
Normal file
36
src/events/event-types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Event topic names
|
||||
export const Topics = {
|
||||
CALL_COMPLETED: 'call.completed',
|
||||
CALL_MISSED: 'call.missed',
|
||||
AGENT_STATE: 'agent.state',
|
||||
} as const;
|
||||
|
||||
// Event payloads
|
||||
export type CallCompletedEvent = {
|
||||
callId: string | null;
|
||||
ucid: string;
|
||||
agentId: string;
|
||||
callerPhone: string;
|
||||
direction: string;
|
||||
durationSec: number;
|
||||
disposition: string;
|
||||
leadId: string | null;
|
||||
notes: string | null;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type CallMissedEvent = {
|
||||
callId: string | null;
|
||||
callerPhone: string;
|
||||
leadId: string | null;
|
||||
leadName: string | null;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type AgentStateEvent = {
|
||||
agentId: string;
|
||||
state: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type EventPayload = CallCompletedEvent | CallMissedEvent | AgentStateEvent;
|
||||
12
src/events/events.module.ts
Normal file
12
src/events/events.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { EventBusService } from './event-bus.service';
|
||||
import { AiInsightConsumer } from './consumers/ai-insight.consumer';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [PlatformModule],
|
||||
providers: [EventBusService, AiInsightConsumer],
|
||||
exports: [EventBusService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
|
||||
@Controller('api/ozonetel')
|
||||
export class OzonetelAgentController {
|
||||
@@ -17,6 +19,7 @@ export class OzonetelAgentController {
|
||||
private readonly config: ConfigService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {
|
||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
@@ -161,6 +164,20 @@ export class OzonetelAgentController {
|
||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||
}
|
||||
|
||||
// Emit event for downstream processing (AI insights, metrics, etc.)
|
||||
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||
callId: null,
|
||||
ucid: body.ucid,
|
||||
agentId: this.defaultAgentId,
|
||||
callerPhone: body.callerPhone ?? '',
|
||||
direction: body.direction ?? 'INBOUND',
|
||||
durationSec: body.durationSec ?? 0,
|
||||
disposition: body.disposition,
|
||||
leadId: body.leadId ?? null,
|
||||
notes: body.notes ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
}).catch(() => {});
|
||||
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user