mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Phase 1 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md).
Backend foundations to support the upcoming staff-portal Settings hub and
6-step setup wizard. No frontend in this phase.
New config services (mirroring ThemeService / WidgetConfigService):
- SetupStateService — tracks completion of 6 wizard steps; isWizardRequired()
drives the post-login redirect
- TelephonyConfigService — Ozonetel + Exotel + SIP, replaces 8 env vars,
seeds from env on first boot, masks secrets on GET,
'***masked***' sentinel on PUT means "keep existing"
- AiConfigService — provider, model, temperature, system prompt addendum;
API keys remain in env
New endpoints under /api/config:
- GET /api/config/setup-state returns state + wizardRequired flag
- PUT /api/config/setup-state/steps/:step mark step complete/incomplete
- POST /api/config/setup-state/dismiss dismiss wizard
- POST /api/config/setup-state/reset
- GET /api/config/telephony masked
- PUT /api/config/telephony
- POST /api/config/telephony/reset
- GET /api/config/ai
- PUT /api/config/ai
- POST /api/config/ai/reset
ConfigThemeModule is now @Global() so the new sidecar config services are
injectable from AuthModule, OzonetelAgentModule, MaintModule without creating
a circular dependency (ConfigThemeModule already imports AuthModule for
SessionService).
Migrated 11 env-var read sites to use the new services:
- ozonetel-agent.service: exotel API + ozonetel did/sipId via read-through getters
- ozonetel-agent.controller: defaultAgentId/Password/SipId via getters
- kookoo-ivr.controller: sipId/callerId via getters
- auth.controller: OZONETEL_AGENT_PASSWORD (login + logout)
- agent-config.service: sipDomain/wsPort/campaignName via getters
- maint.controller: forceReady + unlockAgent
- ai-provider: createAiModel and isAiConfigured refactored to pure factories
taking AiProviderOpts; no more ConfigService dependency
- widget-chat.service, recordings.service, ai-enrichment.service,
ai-chat.controller, ai-insight.consumer, call-assist.service: each builds
the AI model from AiConfigService.getConfig() + ConfigService API keys
Hot-reload guarantee: every consumer reads via a getter or builds per-call,
so admin updates take effect without sidecar restart. WidgetChatService
specifically rebuilds the model on each streamReply().
Bug fix bundled: dropped widget.json.hospitalName field (the original
duplicate that started this whole thread). WidgetConfigService now reads
brand.hospitalName from ThemeService at the 2 generateKey call sites.
Single source of truth for hospital name is workspace branding.
First-boot env seeding: TelephonyConfigService and AiConfigService both
copy their respective env vars into a fresh data/*.json on onModuleInit if
the file doesn't exist. Existing deployments auto-migrate without manual
intervention.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { generateText } from 'ai';
|
|
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 CallAssistService {
|
|
private readonly logger = new Logger(CallAssistService.name);
|
|
private readonly aiModel: LanguageModel | null;
|
|
private readonly platformApiKey: string;
|
|
|
|
constructor(
|
|
private config: ConfigService,
|
|
private platform: PlatformGraphqlService,
|
|
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'),
|
|
});
|
|
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
|
}
|
|
|
|
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> {
|
|
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
|
if (!authHeader) return 'No platform context available.';
|
|
|
|
try {
|
|
const parts: string[] = [];
|
|
|
|
if (leadId) {
|
|
const leadResult = await this.platform.queryWithAuth<any>(
|
|
`{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node {
|
|
id name contactName { firstName lastName }
|
|
contactPhone { primaryPhoneNumber }
|
|
source status interestedService
|
|
lastContacted contactAttempts
|
|
aiSummary aiSuggestedAction
|
|
} } } }`,
|
|
undefined, authHeader,
|
|
);
|
|
const lead = leadResult.leads.edges[0]?.node;
|
|
if (lead) {
|
|
const name = lead.contactName
|
|
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
|
: lead.name;
|
|
parts.push(`CALLER: ${name}`);
|
|
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`);
|
|
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
|
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`);
|
|
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
|
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
|
}
|
|
|
|
const apptResult = await this.platform.queryWithAuth<any>(
|
|
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
|
id scheduledAt status doctorName department reasonForVisit patientId
|
|
} } } }`,
|
|
undefined, authHeader,
|
|
);
|
|
const appts = apptResult.appointments.edges
|
|
.map((e: any) => e.node)
|
|
.filter((a: any) => a.patientId === leadId);
|
|
if (appts.length > 0) {
|
|
parts.push('\nPAST APPOINTMENTS:');
|
|
for (const a of appts) {
|
|
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
|
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`);
|
|
}
|
|
}
|
|
} else if (callerPhone) {
|
|
parts.push(`CALLER: Unknown (${callerPhone})`);
|
|
parts.push('No lead record found — this may be a new enquiry.');
|
|
}
|
|
|
|
const docResult = await this.platform.queryWithAuth<any>(
|
|
`{ doctors(first: 20) { edges { node {
|
|
fullName { firstName lastName } department specialty clinic { clinicName }
|
|
} } } }`,
|
|
undefined, authHeader,
|
|
);
|
|
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
|
if (docs.length > 0) {
|
|
parts.push('\nAVAILABLE DOCTORS:');
|
|
for (const d of docs) {
|
|
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
|
parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`);
|
|
}
|
|
}
|
|
|
|
return parts.join('\n') || 'No context available.';
|
|
} catch (err) {
|
|
this.logger.error(`Failed to load call context: ${err}`);
|
|
return 'Context loading failed.';
|
|
}
|
|
}
|
|
|
|
async getSuggestion(transcript: string, context: string): Promise<string> {
|
|
if (!this.aiModel || !transcript.trim()) return '';
|
|
|
|
try {
|
|
const { text } = await generateText({
|
|
model: this.aiModel,
|
|
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
|
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
|
|
|
${context}
|
|
|
|
RULES:
|
|
- Keep suggestions under 2 sentences
|
|
- Focus on actionable next steps the agent should take NOW
|
|
- If customer mentions a doctor or department, suggest available slots
|
|
- If customer wants to cancel or reschedule, note relevant appointment details
|
|
- If customer sounds upset, suggest empathetic response
|
|
- Do NOT repeat what the agent already knows`,
|
|
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
|
maxOutputTokens: 150,
|
|
});
|
|
return text;
|
|
} catch (err) {
|
|
this.logger.error(`AI suggestion failed: ${err}`);
|
|
return '';
|
|
}
|
|
}
|
|
}
|