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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"kafkajs": "^2.2.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
@@ -10569,6 +10570,15 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kafkajs": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "http://localhost:4873/kafkajs/-/kafkajs-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"kafkajs": "^2.2.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SearchModule } from './search/search.module';
|
|||||||
import { SupervisorModule } from './supervisor/supervisor.module';
|
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||||
import { MaintModule } from './maint/maint.module';
|
import { MaintModule } from './maint/maint.module';
|
||||||
import { RecordingsModule } from './recordings/recordings.module';
|
import { RecordingsModule } from './recordings/recordings.module';
|
||||||
|
import { EventsModule } from './events/events.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -36,6 +37,7 @@ import { RecordingsModule } from './recordings/recordings.module';
|
|||||||
SupervisorModule,
|
SupervisorModule,
|
||||||
MaintModule,
|
MaintModule,
|
||||||
RecordingsModule,
|
RecordingsModule,
|
||||||
|
EventsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { EventBusService } from '../events/event-bus.service';
|
||||||
|
import { Topics } from '../events/event-types';
|
||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
@@ -17,6 +19,7 @@ export class OzonetelAgentController {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
) {
|
) {
|
||||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||||
@@ -161,6 +164,20 @@ export class OzonetelAgentController {
|
|||||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
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' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user