Files
helix-engage-server/src/events/consumers/ai-insight.consumer.ts
saridsa2 695f119c2b feat: team module, multi-stage Dockerfile, doctor utils, AI config overhaul
- Team module: POST /api/team/members (in-place employee creation with
  temp password + Redis cache), PUT /api/team/members/:id, GET temp
  password endpoint. Uses signUpInWorkspace — no email invites.
- Dockerfile: rewritten as multi-stage build (builder + runtime) so
  native modules compile for target arch. Fixes darwin→linux crash.
- .dockerignore: exclude dist, node_modules, .env, .git, data/
- package-lock.json: regenerated against public npmjs.org (was
  pointing at localhost:4873 Verdaccio — broke docker builds)
- Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors
  helper for visit-slot-aware queries across 6 consumers
- AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped
  setup-state with workspace ID isolation, AI prompt defaults overhaul
- Agent config: camelCase field fix for SDK-synced workspaces
- Session service: workspace-scoped Redis key prefixing for setup state
- Recordings/supervisor/widget services: updated to use doctor-utils
  shared fragments instead of inline visitingHours queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:58 +05:30

126 lines
5.0 KiB
TypeScript

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';
import { AiConfigService } from '../../config/ai-config.service';
@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,
private aiConfig: AiConfigService,
) {
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
}
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: this.aiConfig.renderPrompt('callInsight', {
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
}),
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}`);
}
}
}