mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
- 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>
126 lines
5.0 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
}
|