mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +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:
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user