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>
This commit is contained in:
2026-04-10 08:37:58 +05:30
parent eacfce6970
commit 695f119c2b
25 changed files with 2756 additions and 1936 deletions

View File

@@ -1,3 +1,14 @@
# Build artifacts and host-installed deps — the multi-stage Dockerfile
# rebuilds these inside the container for the target platform, so the
# host copies must NOT leak in (would clobber linux/amd64 binaries
# with darwin/arm64 ones).
dist
node_modules
# Secrets and local state
.env
.env.local
.git
src
# Local data dirs (Redis cache file, setup-state, etc.)
data

View File

@@ -1,7 +1,58 @@
# syntax=docker/dockerfile:1.7
#
# Multi-stage build for the helix-engage sidecar.
#
# Why multi-stage instead of "build on host, COPY dist + node_modules"?
# The host (developer Mac, CI runner) is rarely the same architecture
# as the target (linux/amd64 EC2 / VPS). Copying a host-built
# node_modules brings darwin-arm64 native bindings (sharp, livekit,
# fsevents, etc.) into the runtime image, which crash on first import.
# This Dockerfile rebuilds inside the target-platform container so
# native bindings are downloaded/compiled for the right arch.
#
# The build stage runs `npm ci` + `nest build`, then `npm prune` to
# strip dev deps. The runtime stage carries forward only `dist/`,
# the pruned `node_modules/`, and `package.json`.
# --- Builder stage ----------------------------------------------------------
FROM node:22-slim AS builder
WORKDIR /app
# Build deps for any native modules whose prebuilt binaries miss the
# target arch. Kept minimal — node:22-slim already ships most of what's
# needed for the deps in this project, but python/make/g++ are the
# canonical "I might need to gyp-rebuild" trio.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Lockfile-only install first so this layer caches when only source
# changes — much faster repeat builds.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund --loglevel=verbose
# Source + build config
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build
# Strip dev dependencies so the runtime image stays small.
RUN npm prune --omit=dev
# --- Runtime stage ----------------------------------------------------------
FROM node:22-slim
WORKDIR /app
COPY dist ./dist
COPY node_modules ./node_modules
COPY package.json ./
# Bring across only what the runtime needs. Source, dev deps, build
# tooling all stay in the builder stage and get discarded.
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 4100
CMD ["node", "dist/main.js"]

3217
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import { z } from 'zod';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { createAiModel, isAiConfigured } from './ai-provider';
import { AiConfigService } from '../config/ai-config.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
type ChatRequest = {
message: string;
@@ -126,7 +127,13 @@ export class AiChatController {
undefined, auth,
),
platformService.queryWithAuth<any>(
`{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`,
// Field names are label-derived camelCase on the
// current platform schema. The legacy lowercase
// names (ozonetelagentid etc.) only still exist on
// staging workspaces that were synced from an
// older SDK. See agent-config.service.ts for the
// canonical explanation.
`{ agents(first: 20) { edges { node { id name ozonetelAgentId npsScore maxIdleMinutes minNpsThreshold minConversion } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
@@ -143,7 +150,7 @@ export class AiChatController {
const agentMetrics = agents
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
.map((agent: any) => {
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelAgentId);
const totalCalls = agentCalls.length;
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
@@ -162,12 +169,12 @@ export class AiChatController {
conversionRate: `${conversionRate}%`,
assignedLeads: agentLeads.length,
pendingFollowUps,
npsScore: agent.npsscore,
maxIdleMinutes: agent.maxidleminutes,
minNpsThreshold: agent.minnpsthreshold,
minConversionPercent: agent.minconversionpercent,
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
npsScore: agent.npsScore,
maxIdleMinutes: agent.maxIdleMinutes,
minNpsThreshold: agent.minNpsThreshold,
minConversionPercent: agent.minConversion,
belowNpsThreshold: agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold,
belowConversionThreshold: agent.minConversion && conversionRate < agent.minConversion,
};
});
@@ -350,13 +357,13 @@ export class AiChatController {
const data = await platformService.queryWithAuth<any>(
`{ doctors(first: 10) { edges { node {
id fullName { firstName lastName }
department specialty visitingHours
department specialty
consultationFeeNew { amountMicros currencyCode }
clinic { clinicName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const doctors = data.doctors.edges.map((e: any) => e.node);
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
// Strip "Dr." prefix and search flexibly
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
const searchWords = search.split(/\s+/);
@@ -562,25 +569,28 @@ export class AiChatController {
try {
const docData = await this.platform.queryWithAuth<any>(
`{ doctors(first: 20) { edges { node {
fullName { firstName lastName } department specialty visitingHours
id fullName { firstName lastName } department specialty
consultationFeeNew { amountMicros currencyCode }
clinic { clinicName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const doctors = docData.doctors.edges.map((e: any) => e.node);
const doctors = normalizeDoctors(docData.doctors.edges.map((e: any) => e.node));
if (doctors.length) {
sections.push('\n## DOCTORS');
for (const d of doctors) {
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
const fee = d.consultationFeeNew ? `${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
const clinic = d.clinic?.clinicName ?? '';
// List ALL clinics this doctor visits in the KB so
// the AI can answer questions like "where can I see
// Dr. X" without needing a follow-up tool call.
const clinics = d.clinics.map((c) => c.clinicName).join(', ');
sections.push(`### ${name}`);
sections.push(` Department: ${d.department ?? 'N/A'}`);
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
if (fee) sections.push(` Consultation fee: ${fee}`);
if (clinic) sections.push(` Clinic: ${clinic}`);
if (clinics) sections.push(` Clinics: ${clinics}`);
}
}
} catch (err) {
@@ -651,24 +661,15 @@ export class AiChatController {
}
private buildSupervisorSystemPrompt(): string {
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
return this.aiConfig.renderPrompt('supervisorChat', {
hospitalName: this.getHospitalName(),
});
}
## YOUR CAPABILITIES
You have access to tools that query real-time data:
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
## RULES
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
4. Be concise — supervisors want quick answers. Use bullet points.
5. When recommending actions, ground them in the data returned by tools.
6. If asked about trends, use the call summary tool with different periods.
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
// Best-effort hospital name lookup for the AI prompts. Falls back
// to a generic label so prompt rendering never throws.
private getHospitalName(): string {
return process.env.HOSPITAL_NAME ?? 'the hospital';
}
private buildRulesSystemPrompt(currentConfig: any): string {
@@ -718,25 +719,10 @@ ${configJson}
}
private buildSystemPrompt(kb: string): string {
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below.
RULES:
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
6. NEVER give medical advice, diagnosis, or treatment recommendations.
7. Format with bullet points for easy scanning.
KNOWLEDGE BASE (this is real data from our system):
${kb}`;
return this.aiConfig.renderPrompt('ccAgentHelper', {
hospitalName: this.getHospitalName(),
knowledgeBase: kb,
});
}
private async chatWithTools(userMessage: string, auth: string) {
@@ -850,16 +836,15 @@ ${kb}`;
`{ doctors(first: 10) { edges { node {
id name fullName { firstName lastName }
department specialty qualifications yearsOfExperience
visitingHours
consultationFeeNew { amountMicros currencyCode }
consultationFeeFollowUp { amountMicros currencyCode }
active registrationNumber
clinic { id name clinicName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const doctors = data.doctors.edges.map((e: any) => e.node);
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
const search = doctorName.toLowerCase();
const matched = doctors.filter((d: any) => {
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
@@ -872,7 +857,13 @@ ${kb}`;
found: true,
doctors: matched.map((d: any) => ({
...d,
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
// Multi-clinic doctors show as
// "Koramangala / Indiranagar" so the
// model has the full picture without
// a follow-up tool call.
clinicName: d.clinics.length > 0
? d.clinics.map((c: { clinicName: string }) => c.clinicName).join(' / ')
: 'N/A',
feeNewFormatted: d.consultationFeeNew ? `${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
feeFollowUpFormatted: d.consultationFeeFollowUp ? `${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
})),
@@ -896,13 +887,13 @@ ${kb}`;
try {
const doctors = await this.platform.queryWithAuth<any>(
`{ doctors(first: 10) { edges { node {
name fullName { firstName lastName } department specialty visitingHours
id name fullName { firstName lastName } department specialty
consultationFeeNew { amountMicros currencyCode }
clinic { name clinicName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const docs = doctors.doctors.edges.map((e: any) => e.node);
const docs = normalizeDoctors(doctors.doctors.edges.map((e: any) => e.node));
const l = msg.toLowerCase();
const matchedDoc = docs.find((d: any) => {
@@ -912,7 +903,7 @@ ${kb}`;
if (matchedDoc) {
const fee = matchedDoc.consultationFeeNew ? `${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
const clinic = matchedDoc.clinic?.clinicName ?? '';
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours || 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
}
if (l.includes('doctor') || l.includes('available')) {

View File

@@ -66,19 +66,15 @@ export class AiEnrichmentService {
const { object } = await generateObject({
model: this.aiModel!,
schema: enrichmentSchema,
prompt: `You are an AI assistant for a hospital call center.
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
Lead details:
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
- Source: ${lead.leadSource ?? 'Unknown'}
- Interested in: ${lead.interestedService ?? 'Unknown'}
- Current status: ${lead.leadStatus ?? 'Unknown'}
- Lead age: ${daysSince} days
- Contact attempts: ${lead.contactAttempts ?? 0}
Recent activity:
${activitiesText}`,
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
leadSource: lead.leadSource ?? 'Unknown',
interestedService: lead.interestedService ?? 'Unknown',
leadStatus: lead.leadStatus ?? 'Unknown',
daysSince,
contactAttempts: lead.contactAttempts ?? 0,
activities: activitiesText,
}),
});
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);

View File

@@ -20,6 +20,7 @@ import { CallerResolutionModule } from './caller/caller-resolution.module';
import { RulesEngineModule } from './rules-engine/rules-engine.module';
import { ConfigThemeModule } from './config/config-theme.module';
import { WidgetModule } from './widget/widget.module';
import { TeamModule } from './team/team.module';
@Module({
imports: [
@@ -46,6 +47,7 @@ import { WidgetModule } from './widget/widget.module';
RulesEngineModule,
ConfigThemeModule,
WidgetModule,
TeamModule,
],
})
export class AppModule {}

View File

@@ -37,22 +37,29 @@ export class AgentConfigService {
if (cached) return cached;
try {
// Note: platform GraphQL field names are derived from the SDK
// `label`, not `name` — so the filter/column is
// `workspaceMemberId` and the SIP fields are camelCase. The
// legacy staging workspace was synced from an older SDK that
// exposed `wsmemberId`/`ozonetelagentid`/etc., but any fresh
// sync (and all new hospitals going forward) uses these
// label-derived names. Re-sync staging if it drifts.
const data = await this.platform.query<any>(
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelagentid sipextension sippassword campaignname
`{ agents(first: 1, filter: { workspaceMemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelAgentId sipExtension sipPassword campaignName
} } } }`,
);
const node = data?.agents?.edges?.[0]?.node;
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
if (!node || !node.ozonetelAgentId || !node.sipExtension) return null;
const agentConfig: AgentConfig = {
id: node.id,
ozonetelAgentId: node.ozonetelagentid,
sipExtension: node.sipextension,
sipPassword: node.sippassword ?? node.sipextension,
campaignName: node.campaignname ?? this.defaultCampaignName,
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
ozonetelAgentId: node.ozonetelAgentId,
sipExtension: node.sipExtension,
sipPassword: node.sipPassword ?? node.sipExtension,
campaignName: node.campaignName ?? this.defaultCampaignName,
sipUri: `sip:${node.sipExtension}@${this.sipDomain}`,
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
};

View File

@@ -1,19 +1,23 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
const SESSION_TTL = 3600; // 1 hour
@Injectable()
export class SessionService implements OnModuleInit {
export class SessionService {
private readonly logger = new Logger(SessionService.name);
private redis: Redis;
private readonly redis: Redis;
constructor(private config: ConfigService) {}
onModuleInit() {
// Redis client is constructed eagerly (not in onModuleInit) so
// other services can call cache methods from THEIR onModuleInit
// hooks. NestJS instantiates all providers before running any
// onModuleInit callback, so the client is guaranteed ready even
// when an earlier-firing module's init path touches the cache
// (e.g. WidgetConfigService → WidgetKeysService → setCachePersistent).
constructor(private config: ConfigService) {
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
this.redis = new Redis(url);
this.redis = new Redis(url, { lazyConnect: false });
this.redis.on('connect', () => this.logger.log('Redis connected'));
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
}

View File

@@ -5,6 +5,7 @@ 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';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
@Injectable()
export class CallAssistService {
@@ -81,16 +82,24 @@ export class CallAssistService {
const docResult = await this.platform.queryWithAuth<any>(
`{ doctors(first: 20) { edges { node {
fullName { firstName lastName } department specialty clinic { clinicName }
id fullName { firstName lastName } department specialty
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, authHeader,
);
const docs = docResult.doctors.edges.map((e: any) => e.node);
const docs = normalizeDoctors(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 ?? '?'}`);
// Show all clinics the doctor visits, joined with
// " / " — call assist context is read by the AI
// whisperer so multi-clinic doctors don't get
// truncated to their first location.
const clinicLabel = d.clinics.length > 0
? d.clinics.map((c) => c.clinicName).join(' / ')
: '?';
parts.push(`- ${name}${d.department ?? '?'}${clinicLabel}`);
}
}
@@ -107,18 +116,10 @@ export class CallAssistService {
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`,
system: this.aiConfig.renderPrompt('callAssist', {
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
context,
}),
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
maxOutputTokens: 150,
});

View File

@@ -1,12 +1,14 @@
import { Body, Controller, Get, Logger, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, Logger, Param, Post, Put } from '@nestjs/common';
import { AiConfigService } from './ai-config.service';
import type { AiConfig } from './ai.defaults';
import type { AiActorKey, AiConfig } from './ai.defaults';
// Mounted under /api/config alongside theme/widget/telephony/setup-state.
//
// GET /api/config/ai — full config (no secrets here, all safe to return)
// PUT /api/config/ai — admin update
// POST /api/config/ai/reset — reset to defaults
// PUT /api/config/ai — admin update (provider/model/temperature)
// POST /api/config/ai/reset — reset entire config to defaults
// PUT /api/config/ai/prompts/:actor — update one persona's system prompt template
// POST /api/config/ai/prompts/:actor/reset — restore one persona to its default
@Controller('api/config')
export class AiConfigController {
private readonly logger = new Logger(AiConfigController.name);
@@ -29,4 +31,19 @@ export class AiConfigController {
this.logger.log('AI config reset request');
return this.ai.resetConfig();
}
@Put('ai/prompts/:actor')
updatePrompt(
@Param('actor') actor: AiActorKey,
@Body() body: { template: string; editedBy?: string },
) {
this.logger.log(`AI prompt update for actor '${actor}'`);
return this.ai.updatePrompt(actor, body.template, body.editedBy ?? null);
}
@Post('ai/prompts/:actor/reset')
resetPrompt(@Param('actor') actor: AiActorKey) {
this.logger.log(`AI prompt reset for actor '${actor}'`);
return this.ai.resetPrompt(actor);
}
}

View File

@@ -2,17 +2,22 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import {
AI_ACTOR_KEYS,
AI_ENV_SEEDS,
DEFAULT_AI_CONFIG,
DEFAULT_AI_PROMPTS,
type AiActorKey,
type AiConfig,
type AiPromptConfig,
type AiProvider,
} from './ai.defaults';
const CONFIG_PATH = join(process.cwd(), 'data', 'ai.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'ai-backups');
// File-backed AI config — provider, model, temperature, prompt addendum.
// API keys stay in env. Mirrors TelephonyConfigService.
// File-backed AI config — provider, model, temperature, and per-actor
// system prompt templates. API keys stay in env. Mirrors
// TelephonyConfigService.
@Injectable()
export class AiConfigService implements OnModuleInit {
private readonly logger = new Logger(AiConfigService.name);
@@ -57,6 +62,76 @@ export class AiConfigService implements OnModuleInit {
return fresh;
}
// Update a single actor's prompt template, preserving the audit
// trail. Used by the wizard's edit slideout. Validates the actor
// key so a typo from a hand-crafted PUT can't write garbage.
updatePrompt(actor: AiActorKey, template: string, editedBy: string | null): AiConfig {
if (!AI_ACTOR_KEYS.includes(actor)) {
throw new Error(`Unknown AI actor: ${actor}`);
}
const current = this.getConfig();
const existing = current.prompts[actor] ?? DEFAULT_AI_PROMPTS[actor];
const updatedPrompt: AiPromptConfig = {
...existing,
template,
lastEditedAt: new Date().toISOString(),
lastEditedBy: editedBy,
};
const merged: AiConfig = {
...current,
prompts: { ...current.prompts, [actor]: updatedPrompt },
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`AI prompt for actor '${actor}' updated to v${merged.version}`);
return merged;
}
// Restore a single actor's prompt back to the SDK-shipped default.
// Clears the audit fields so it looks "fresh" in the UI.
resetPrompt(actor: AiActorKey): AiConfig {
if (!AI_ACTOR_KEYS.includes(actor)) {
throw new Error(`Unknown AI actor: ${actor}`);
}
const current = this.getConfig();
const fresh: AiPromptConfig = {
...DEFAULT_AI_PROMPTS[actor],
lastEditedAt: null,
lastEditedBy: null,
};
const merged: AiConfig = {
...current,
prompts: { ...current.prompts, [actor]: fresh },
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`AI prompt for actor '${actor}' reset to default`);
return merged;
}
// Render a prompt with `{{variable}}` substitution. Variables not
// present in `vars` are left as-is so a missing fill is loud
// (the AI sees `{{leadName}}` literally) rather than silently
// dropping the placeholder. Falls back to DEFAULT_AI_PROMPTS if
// the actor key is missing from the loaded config (handles old
// ai.json files that predate this refactor).
renderPrompt(actor: AiActorKey, vars: Record<string, string | number | null | undefined>): string {
const cfg = this.getConfig();
const prompt = cfg.prompts?.[actor] ?? DEFAULT_AI_PROMPTS[actor];
const template = prompt.template;
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) return match;
return String(value);
});
}
private ensureReady(): AiConfig {
if (existsSync(CONFIG_PATH)) {
return this.load();
@@ -83,10 +158,35 @@ export class AiConfigService implements OnModuleInit {
try {
const raw = readFileSync(CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw);
// Merge incoming prompts against defaults so old ai.json
// files (written before the prompts refactor) get topped
// up with the new actor entries instead of crashing on
// first read. Per-actor merging keeps any admin edits
// intact while filling in missing actors.
const mergedPrompts: Record<AiActorKey, AiPromptConfig> = { ...DEFAULT_AI_PROMPTS };
if (parsed.prompts && typeof parsed.prompts === 'object') {
for (const key of AI_ACTOR_KEYS) {
const incoming = parsed.prompts[key];
if (incoming && typeof incoming === 'object') {
mergedPrompts[key] = {
...DEFAULT_AI_PROMPTS[key],
...incoming,
// Always pull `defaultTemplate` from the
// shipped defaults — never trust the
// file's copy, since the SDK baseline can
// change between releases and we want
// "reset to default" to always reset to
// the latest baseline.
defaultTemplate: DEFAULT_AI_PROMPTS[key].defaultTemplate,
};
}
}
}
const merged: AiConfig = {
...DEFAULT_AI_CONFIG,
...parsed,
provider: (parsed.provider ?? DEFAULT_AI_CONFIG.provider) as AiProvider,
prompts: mergedPrompts,
};
this.cached = merged;
this.logger.log('AI config loaded from file');

View File

@@ -1,34 +1,286 @@
// Admin-editable AI assistant config. Holds the user-facing knobs (provider,
// model, temperature, optional system prompt override) — API keys themselves
// stay in env vars because they are true secrets and rotation is an ops event.
// model, temperature) AND a per-actor system prompt template map. API keys
// themselves stay in env vars because they are true secrets and rotation is
// an ops event.
//
// Each "actor" is a distinct AI persona used by the sidecar — widget chat,
// CC agent helper, supervisor, lead enrichment, etc. Pulling these out of
// hardcoded service files lets the hospital admin tune tone, boundaries,
// and instructions per persona without a sidecar redeploy. The 7 actors
// listed below cover every customer-facing AI surface in Helix Engage as
// of 2026-04-08; internal/dev-only prompts (rules engine config helper,
// recording speaker-channel identification) stay hardcoded since they are
// not customer-tunable.
//
// Templating: each actor's prompt is a string with `{{variable}}` placeholders
// that the calling service fills in via AiConfigService.renderPrompt(actor,
// vars). The variable shape per actor is documented in the `variables` field
// so the wizard UI can show admins what they can reference.
export type AiProvider = 'openai' | 'anthropic';
// Stable keys for each configurable persona. Adding a new actor:
// 1. add a key here
// 2. add a default entry in DEFAULT_AI_PROMPTS below
// 3. add the corresponding renderPrompt call in the consuming service
export const AI_ACTOR_KEYS = [
'widgetChat',
'ccAgentHelper',
'supervisorChat',
'leadEnrichment',
'callInsight',
'callAssist',
'recordingAnalysis',
] as const;
export type AiActorKey = (typeof AI_ACTOR_KEYS)[number];
export type AiPromptConfig = {
// Human-readable name shown in the wizard UI.
label: string;
// One-line description of when this persona is invoked.
description: string;
// Variables the template can reference, with a one-line hint each.
// Surfaced in the edit slideout so admins know what `{{var}}` they
// can use without reading code.
variables: Array<{ key: string; description: string }>;
// The current template (may be admin-edited).
template: string;
// The original baseline so we can offer a "reset to default" button.
defaultTemplate: string;
// Audit fields — when this prompt was last edited and by whom.
// null on the default-supplied entries.
lastEditedAt: string | null;
lastEditedBy: string | null;
};
export type AiConfig = {
provider: AiProvider;
model: string;
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
// values used in WidgetChatService and AI tools.
temperature: number;
// Optional admin-supplied system prompt addendum. Appended to the
// hospital-specific prompts WidgetChatService generates from the doctor
// roster, so the admin can add hospital-specific tone / boundaries
// without rewriting the entire prompt.
systemPromptAddendum: string;
// Per-actor system prompt templates. Keyed by AiActorKey so callers can
// do `config.prompts.widgetChat.template` and missing keys are caught
// at compile time.
prompts: Record<AiActorKey, AiPromptConfig>;
version?: number;
updatedAt?: string;
};
// ---------------------------------------------------------------------------
// Default templates — extracted verbatim from the hardcoded versions in:
// - widget-chat.service.ts → widgetChat
// - ai-chat.controller.ts → ccAgentHelper, supervisorChat
// - ai-enrichment.service.ts → leadEnrichment
// - ai-insight.consumer.ts → callInsight
// - call-assist.service.ts → callAssist
// - recordings.service.ts → recordingAnalysis
// ---------------------------------------------------------------------------
const WIDGET_CHAT_DEFAULT = `You are a helpful, concise assistant for {{hospitalName}}.
You are chatting with a website visitor named {{userName}}.
{{branchContext}}
TOOL USAGE RULES (STRICT):
- When the user asks about departments, call list_departments and DO NOT also list departments in prose.
- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.
- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.
- When they ask about a specific doctor's availability or want to book with them, call show_doctor_slots.
- When the conversation is trending toward booking, call suggest_booking.
- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence
(under 15 words) framing the widget, or no text at all. The widget already shows the data.
- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,
STOP and call the appropriate tool instead.
- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in
non-tool replies.
- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".
If the visitor asks about a future date, tell them to use the Book tab's date picker.
OTHER RULES:
- Answer other questions (directions, general info) concisely in prose.
- If you do not know something, say so and suggest they call the hospital.
- Never quote prices. No medical advice. For clinical questions, defer to a doctor.
{{knowledgeBase}}`;
const CC_AGENT_HELPER_DEFAULT = `You are an AI assistant for call center agents at {{hospitalName}}.
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
RULES:
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
6. NEVER give medical advice, diagnosis, or treatment recommendations.
7. Format with bullet points for easy scanning.
KNOWLEDGE BASE (this is real data from our system):
{{knowledgeBase}}`;
const SUPERVISOR_CHAT_DEFAULT = `You are an AI assistant for supervisors at {{hospitalName}}'s call center (Helix Engage).
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
## YOUR CAPABILITIES
You have access to tools that query real-time data:
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
## RULES
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
4. Be concise — supervisors want quick answers. Use bullet points.
5. When recommending actions, ground them in the data returned by tools.
6. If asked about trends, use the call summary tool with different periods.
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
const LEAD_ENRICHMENT_DEFAULT = `You are an AI assistant for a hospital call center.
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
Lead details:
- Name: {{leadName}}
- Source: {{leadSource}}
- Interested in: {{interestedService}}
- Current status: {{leadStatus}}
- Lead age: {{daysSince}} days
- Contact attempts: {{contactAttempts}}
Recent activity:
{{activities}}`;
const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}.
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.`;
const CALL_ASSIST_DEFAULT = `You are a real-time call assistant for {{hospitalName}}.
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`;
const RECORDING_ANALYSIS_DEFAULT = `You are a call quality analyst for {{hospitalName}}.
Analyze the following call recording transcript and provide structured insights.
Be specific, brief, and actionable. Focus on healthcare context.
{{summaryBlock}}
{{topicsBlock}}`;
// Helper that builds an AiPromptConfig with the same template for both
// `template` and `defaultTemplate` — what every actor starts with on a
// fresh boot.
const promptDefault = (
label: string,
description: string,
variables: Array<{ key: string; description: string }>,
template: string,
): AiPromptConfig => ({
label,
description,
variables,
template,
defaultTemplate: template,
lastEditedAt: null,
lastEditedBy: null,
});
export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
widgetChat: promptDefault(
'Website widget chat',
'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.',
[
{ key: 'hospitalName', description: 'Branded hospital display name from theme.json' },
{ key: 'userName', description: 'Visitor first name (or "there" if unknown)' },
{ key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' },
{ key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' },
],
WIDGET_CHAT_DEFAULT,
),
ccAgentHelper: promptDefault(
'CC agent helper',
'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' },
],
CC_AGENT_HELPER_DEFAULT,
),
supervisorChat: promptDefault(
'Supervisor assistant',
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
],
SUPERVISOR_CHAT_DEFAULT,
),
leadEnrichment: promptDefault(
'Lead enrichment',
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
[
{ key: 'leadName', description: 'Lead first + last name' },
{ key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' },
{ key: 'interestedService', description: 'What the lead enquired about' },
{ key: 'leadStatus', description: 'Current lead status' },
{ key: 'daysSince', description: 'Days since the lead was created' },
{ key: 'contactAttempts', description: 'Prior contact attempts count' },
{ key: 'activities', description: 'Pre-rendered recent activity summary' },
],
LEAD_ENRICHMENT_DEFAULT,
),
callInsight: promptDefault(
'Post-call insight',
'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
],
CALL_INSIGHT_DEFAULT,
),
callAssist: promptDefault(
'Live call whisper',
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' },
],
CALL_ASSIST_DEFAULT,
),
recordingAnalysis: promptDefault(
'Call recording analysis',
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'summaryBlock', description: 'Optional pre-rendered "Call summary: ..." line (empty when none)' },
{ key: 'topicsBlock', description: 'Optional pre-rendered "Detected topics: ..." line (empty when none)' },
],
RECORDING_ANALYSIS_DEFAULT,
),
};
export const DEFAULT_AI_CONFIG: AiConfig = {
provider: 'openai',
model: 'gpt-4o-mini',
temperature: 0.7,
systemPromptAddendum: '',
prompts: DEFAULT_AI_PROMPTS,
};
// Field-by-field mapping from the legacy env vars used by ai-provider.ts
// (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env.
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof AiConfig }> = [
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [
{ env: 'AI_PROVIDER', field: 'provider' },
{ env: 'AI_MODEL', field: 'model' },
];

View File

@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PlatformModule } from '../platform/platform.module';
import { ThemeController } from './theme.controller';
import { ThemeService } from './theme.service';
import { WidgetKeysService } from './widget-keys.service';
@@ -25,7 +26,7 @@ import { AiConfigController } from './ai-config.controller';
// (Redis-backed cache for widget site key storage).
@Global()
@Module({
imports: [AuthModule],
imports: [AuthModule, PlatformModule],
controllers: [
ThemeController,
WidgetConfigController,

View File

@@ -17,8 +17,11 @@ export class SetupStateController {
constructor(private readonly setupState: SetupStateService) {}
@Get('setup-state')
getState() {
const state = this.setupState.getState();
async getState() {
// Use the checked variant so the platform workspace probe runs
// before we serialize. Catches workspace changes (DB resets,
// re-onboards) on the very first frontend GET.
const state = await this.setupState.getStateChecked();
return {
...state,
wizardRequired: this.setupState.isWizardRequired(),
@@ -30,19 +33,24 @@ export class SetupStateController {
@Param('step') step: SetupStepName,
@Body() body: { completed: boolean; completedBy?: string },
) {
if (body.completed) {
return this.setupState.markStepCompleted(step, body.completedBy ?? null);
}
return this.setupState.markStepIncomplete(step);
const updated = body.completed
? this.setupState.markStepCompleted(step, body.completedBy ?? null)
: this.setupState.markStepIncomplete(step);
// Mirror GET shape — include `wizardRequired` so the frontend
// doesn't see a state object missing the field and re-render
// into an inconsistent shape.
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
}
@Post('setup-state/dismiss')
dismiss() {
return this.setupState.dismissWizard();
const updated = this.setupState.dismissWizard();
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
}
@Post('setup-state/reset')
reset() {
return this.setupState.resetState();
const updated = this.setupState.resetState();
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
}
}

View File

@@ -23,6 +23,12 @@ export type SetupState = {
// Settings hub still shows the per-section badges.
wizardDismissed: boolean;
steps: Record<SetupStepName, SetupStepStatus>;
// The platform workspace this state belongs to. The sidecar's API key
// is scoped to exactly one workspace, so on every load we compare the
// file's workspaceId against the live currentWorkspace.id and reset
// the file if they differ. Stops setup-state from leaking across DB
// resets and re-onboards.
workspaceId?: string | null;
};
const emptyStep = (): SetupStepStatus => ({
@@ -42,6 +48,7 @@ export const SETUP_STEP_NAMES: readonly SetupStepName[] = [
export const DEFAULT_SETUP_STATE: SetupState = {
wizardDismissed: false,
workspaceId: null,
steps: {
identity: emptyStep(),
clinics: emptyStep(),

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import {
DEFAULT_SETUP_STATE,
SETUP_STEP_NAMES,
@@ -14,13 +15,34 @@ const SETUP_STATE_PATH = join(process.cwd(), 'data', 'setup-state.json');
// pattern of ThemeService and WidgetConfigService — load on init, cache in
// memory, write on every change. No backups (the data is small and easily
// recreated by the wizard if it ever gets corrupted).
//
// Workspace scoping: the sidecar's API key is scoped to exactly one
// workspace, so on first access we compare the file's stored workspaceId
// against the live currentWorkspace.id from the platform. If they differ
// (DB reset, re-onboard, sidecar pointed at a new workspace), the file is
// reset before any reads return. This guarantees a fresh wizard for a
// fresh workspace without manual file deletion.
@Injectable()
export class SetupStateService implements OnModuleInit {
private readonly logger = new Logger(SetupStateService.name);
private cached: SetupState | null = null;
// Memoize the platform's currentWorkspace.id lookup so we don't hit
// the platform on every getState() call. Set once per process boot
// (or after a successful reset).
private liveWorkspaceId: string | null = null;
private workspaceCheckPromise: Promise<void> | null = null;
constructor(private platform: PlatformGraphqlService) {}
onModuleInit() {
this.load();
// Fire-and-forget the workspace probe so the file gets aligned
// before the frontend's first GET. Errors are logged but
// non-fatal — if the platform is down at boot, the legacy
// unscoped behaviour kicks in until the first reachable probe.
this.ensureWorkspaceMatch().catch((err) =>
this.logger.warn(`Initial workspace probe failed: ${err}`),
);
}
getState(): SetupState {
@@ -28,6 +50,59 @@ export class SetupStateService implements OnModuleInit {
return this.load();
}
// Awaits a workspace check before returning state. The controller
// calls this so the GET response always reflects the current
// workspace, not yesterday's.
async getStateChecked(): Promise<SetupState> {
await this.ensureWorkspaceMatch();
return this.getState();
}
private async ensureWorkspaceMatch(): Promise<void> {
// Single-flight: if a check is already running, await it.
if (this.workspaceCheckPromise) return this.workspaceCheckPromise;
if (this.liveWorkspaceId) {
// Already validated this process. Trust the cache.
return;
}
this.workspaceCheckPromise = (async () => {
try {
const data = await this.platform.query<{
currentWorkspace: { id: string };
}>(`{ currentWorkspace { id } }`);
const liveId = data?.currentWorkspace?.id ?? null;
if (!liveId) {
this.logger.warn(
'currentWorkspace.id was empty — cannot scope setup-state',
);
return;
}
this.liveWorkspaceId = liveId;
const current = this.getState();
if (current.workspaceId && current.workspaceId !== liveId) {
this.logger.log(
`Workspace changed (${current.workspaceId}${liveId}) — resetting setup-state`,
);
this.resetState();
}
if (!current.workspaceId) {
// First boot after the workspaceId field was added
// (or first boot ever). Stamp the file so future
// boots can detect drift.
const stamped: SetupState = {
...this.getState(),
workspaceId: liveId,
};
this.writeFile(stamped);
this.cached = stamped;
}
} finally {
this.workspaceCheckPromise = null;
}
})();
return this.workspaceCheckPromise;
}
// Returns true if any required step is incomplete and the wizard hasn't
// been explicitly dismissed. Used by the frontend post-login redirect.
isWizardRequired(): boolean {
@@ -95,8 +170,16 @@ export class SetupStateService implements OnModuleInit {
}
resetState(): SetupState {
this.writeFile(DEFAULT_SETUP_STATE);
this.cached = { ...DEFAULT_SETUP_STATE };
// Preserve the live workspaceId on reset so the file remains
// scoped — otherwise the next workspace check would think the
// file is unscoped and re-stamp it, which is fine but creates
// an extra write.
const fresh: SetupState = {
...DEFAULT_SETUP_STATE,
workspaceId: this.liveWorkspaceId ?? null,
};
this.writeFile(fresh);
this.cached = fresh;
this.logger.log('Setup state reset to defaults');
return this.cached;
}

View File

@@ -82,11 +82,9 @@ export class AiInsightConsumer implements OnModuleInit {
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.`,
system: this.aiConfig.renderPrompt('callInsight', {
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
}),
prompt: `Lead: ${leadName}
Status: ${lead.status ?? 'Unknown'}
Source: ${lead.source ?? 'Unknown'}

View File

@@ -235,11 +235,11 @@ The CUSTOMER typically:
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
callOutcome: z.string().describe('One-line summary of what was accomplished'),
}),
system: `You are a call quality analyst for Global Hospital Bangalore.
Analyze the following call recording transcript and provide structured insights.
Be specific, brief, and actionable. Focus on healthcare context.
${summary ? `\nCall summary: ${summary}` : ''}
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
system: this.aiConfig.renderPrompt('recordingAnalysis', {
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
summaryBlock: summary ? `\nCall summary: ${summary}` : '',
topicsBlock: topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : '',
}),
prompt: transcript,
maxOutputTokens: 500,
});

151
src/shared/doctor-utils.ts Normal file
View File

@@ -0,0 +1,151 @@
// Shared utilities for working with the helix-engage Doctor entity
// after the multi-clinic visit-slot rework. The doctor data model
// changed from { clinic: RELATION, visitingHours: TEXT } to many
// DoctorVisitSlot records (one per day-of-week × clinic), so every
// service that fetches doctors needs to:
//
// 1. Query `visitSlots { dayOfWeek startTime endTime clinic { id clinicName } }`
// instead of the legacy flat fields.
// 2. Fold the slots back into a "where do they visit" summary string
// and a list of unique clinics for branch-matching.
//
// This file provides:
//
// - DOCTOR_VISIT_SLOTS_FRAGMENT: a string fragment that callers can
// splice into their `doctors { edges { node { ... } } }` query so
// the field selection stays consistent across services.
//
// - normalizeDoctor(d): takes the raw GraphQL node and returns the
// same object plus three derived fields:
// * `clinics: { id, clinicName }[]` — unique list of clinics
// the doctor visits, deduped by id.
// * `clinic: { clinicName } | null` — first clinic for legacy
// consumers that only show one (the AI prompt KB, etc.).
// * `visitingHours: string` — pre-formatted summary like
// "Mon 09:00-13:00 (Koramangala) · Wed 14:00-18:00 (Indiranagar)"
// suitable for inlining into AI prompts.
//
// Keeping the legacy field names (`clinic`, `visitingHours`) on the
// normalized object means call sites that previously read those
// fields keep working — only the GraphQL query and the call to
// normalizeDoctor need to be added.
export type RawDoctorVisitSlot = {
dayOfWeek?: string | null;
startTime?: string | null;
endTime?: string | null;
clinic?: { id?: string | null; clinicName?: string | null } | null;
};
export type RawDoctor = {
id?: string;
name?: string | null;
fullName?: { firstName?: string | null; lastName?: string | null } | null;
department?: string | null;
specialty?: string | null;
visitSlots?: { edges?: Array<{ node: RawDoctorVisitSlot }> } | null;
[key: string]: any;
};
// Tightened shape — `id` and `name` are always strings (with sensible
// fallbacks) so consumers can assign them to typed maps without
// "string | undefined" errors. The remaining fields keep their
// nullable nature from RawDoctor.
export type NormalizedDoctor = Omit<RawDoctor, 'id' | 'name'> & {
id: string;
name: string;
clinics: Array<{ id: string; clinicName: string }>;
clinic: { clinicName: string } | null;
visitingHours: string;
};
// GraphQL fragment for the visit-slots reverse relation. Spliced into
// each doctors query so all services fetch the same shape. Capped at
// 20 slots per doctor — generous for any realistic schedule (7 days
// × 2-3 clinics).
export const DOCTOR_VISIT_SLOTS_FRAGMENT = `visitSlots(first: 20) {
edges { node {
dayOfWeek startTime endTime
clinic { id clinicName }
} }
}`;
const DAY_ABBREV: Record<string, string> = {
MONDAY: 'Mon',
TUESDAY: 'Tue',
WEDNESDAY: 'Wed',
THURSDAY: 'Thu',
FRIDAY: 'Fri',
SATURDAY: 'Sat',
SUNDAY: 'Sun',
};
const formatTime = (t: string | null | undefined): string => {
if (!t) return '';
// Times come in as "HH:MM" or "HH:MM:SS" — strip seconds for
// display compactness.
return t.length > 5 ? t.slice(0, 5) : t;
};
// Best-effort doctor name derivation — prefer the platform's `name`
// field, then fall back to the composite fullName, then to a generic
// label so consumers never see undefined.
const deriveName = (raw: RawDoctor): string => {
if (raw.name && raw.name.trim()) return raw.name.trim();
const first = raw.fullName?.firstName?.trim() ?? '';
const last = raw.fullName?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
if (full) return full;
return 'Unknown doctor';
};
export const normalizeDoctor = (raw: RawDoctor): NormalizedDoctor => {
const slots = raw.visitSlots?.edges?.map((e) => e.node) ?? [];
// Unique clinics, preserving the order they were encountered.
const seen = new Set<string>();
const clinics: Array<{ id: string; clinicName: string }> = [];
for (const slot of slots) {
const id = slot.clinic?.id;
const name = slot.clinic?.clinicName;
if (!id || !name || seen.has(id)) continue;
seen.add(id);
clinics.push({ id, clinicName: name });
}
// Visiting hours summary — `Day HH:MM-HH:MM (Clinic)` joined by
// " · ". Slots without a clinic or without a day get dropped.
const segments: string[] = [];
for (const slot of slots) {
const day = slot.dayOfWeek ? (DAY_ABBREV[slot.dayOfWeek] ?? slot.dayOfWeek) : null;
const start = formatTime(slot.startTime);
const end = formatTime(slot.endTime);
const clinic = slot.clinic?.clinicName;
if (!day || !start || !clinic) continue;
segments.push(`${day} ${start}${end ? `-${end}` : ''} (${clinic})`);
}
return {
...raw,
id: raw.id ?? '',
name: deriveName(raw),
clinics,
// Bridge field — first clinic, so legacy consumers that read
// `d.clinic.clinicName` keep working.
clinic: clinics.length > 0 ? { clinicName: clinics[0].clinicName } : null,
visitingHours: segments.join(' · '),
};
};
// Convenience: normalize an array of raw GraphQL nodes in one call.
export const normalizeDoctors = (raws: RawDoctor[]): NormalizedDoctor[] => raws.map(normalizeDoctor);
// Branch-matching helper: a doctor "matches" a branch if any of their
// visit slots is at a clinic whose name contains the branch substring
// (case-insensitive). Used by widget chat tools to filter doctors by
// the visitor's selected branch.
export const doctorMatchesBranch = (d: NormalizedDoctor, branch: string | undefined | null): boolean => {
if (!branch) return true;
const needle = branch.toLowerCase();
return d.clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
};

View File

@@ -108,11 +108,14 @@ export class SupervisorService implements OnModuleInit {
}
async getTeamPerformance(date: string): Promise<any> {
// Get all agents from platform
// Get all agents from platform. Field names are label-derived
// camelCase on the current platform schema — see
// agent-config.service.ts for the canonical explanation of the
// legacy lowercase names that used to exist on staging.
const agentData = await this.platform.query<any>(
`{ agents(first: 20) { edges { node {
id name ozonetelagentid npsscore
maxidleminutes minnpsthreshold minconversionpercent
id name ozonetelAgentId npsScore
maxIdleMinutes minNpsThreshold minConversion
} } } }`,
);
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
@@ -120,12 +123,12 @@ export class SupervisorService implements OnModuleInit {
// Fetch Ozonetel time summary per agent
const summaries = await Promise.all(
agents.map(async (agent: any) => {
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
try {
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
return { ...agent, timeBreakdown: summary };
} catch (err) {
this.logger.warn(`Failed to get summary for ${agent.ozonetelagentid}: ${err}`);
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
return { ...agent, timeBreakdown: null };
}
}),

View File

@@ -0,0 +1,39 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import {
TeamService,
type CreateMemberInput,
type CreatedMember,
type UpdateMemberInput,
} from './team.service';
// REST wrapper around TeamService. Mounted at /api/team/*.
// The Team wizard step on the frontend posts here instead of firing
// the platform's sendInvitations mutation directly.
@Controller('api/team')
export class TeamController {
constructor(private team: TeamService) {}
@Post('members')
async createMember(@Body() body: CreateMemberInput): Promise<CreatedMember> {
return this.team.createMember(body);
}
@Put('members/:id')
async updateMember(
@Param('id') id: string,
@Body() body: UpdateMemberInput,
): Promise<{ id: string }> {
return this.team.updateMember(id, body);
}
// Returns the cached plaintext temp password for a recently-created
// member if it's still within its 24h TTL, or { password: null }
// on cache miss. Used by the wizard's right-pane copy icon when
// its in-browser memory was wiped by a refresh.
@Get('members/:id/temp-password')
async getTempPassword(@Param('id') id: string): Promise<{ password: string | null }> {
const password = await this.team.getTempPassword(id);
return { password };
}
}

16
src/team/team.module.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module';
import { TeamController } from './team.controller';
import { TeamService } from './team.service';
// AuthModule is imported because TeamService uses SessionService for
// its generic Redis cache (storing recently-created temp passwords
// with a 24h TTL so the right pane's copy icon survives a reload).
@Module({
imports: [PlatformModule, AuthModule],
controllers: [TeamController],
providers: [TeamService],
exports: [TeamService],
})
export class TeamModule {}

334
src/team/team.service.ts Normal file
View File

@@ -0,0 +1,334 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service';
// Recently-created temp passwords are cached in Redis under this prefix
// for 24 hours so the right pane's copy icon keeps working after a
// browser refresh. The plaintext expires automatically — the assumption
// is the employee logs in within a day, at which point the password
// loses value anyway.
const TEMP_PASSWORD_KEY_PREFIX = 'team:tempPassword:';
const TEMP_PASSWORD_TTL_SECONDS = 24 * 60 * 60;
const tempPasswordKey = (memberId: string) => `${TEMP_PASSWORD_KEY_PREFIX}${memberId}`;
// In-place employee creation. The platform's sendInvitations flow is
// deliberately NOT used — hospital admins create employees from the
// portal and hand out credentials directly (see feedback-no-invites in
// memory).
//
// Chain:
// 1. Fetch workspace invite hash (workspace-level setting) so
// signUpInWorkspace accepts our call — this is the same hash the
// public invite link uses but we consume it server-side.
// 2. signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)
// — creates the core.user row + the workspaceMember row. Returns
// a loginToken we throw away (admin has their own session).
// 3. Look up the workspaceMember we just created, filtering by
// userEmail (the only field we have to go on).
// 4. updateWorkspaceMember to set firstName / lastName.
// 5. updateWorkspaceMemberRole to assign the role the admin picked.
// 6. (optional) updateAgent to link the new member to a SIP seat if
// they're a CC agent.
//
// Errors from any step bubble up as a BadRequestException — the admin
// sees the real GraphQL error message, which usually tells them
// exactly what went wrong (email already exists, role not assignable,
// etc).
export type CreateMemberInput = {
firstName: string;
lastName: string;
email: string;
password: string;
roleId: string;
// Optional SIP seat link — set when the role is HelixEngage User
// (CC agent). Ignored otherwise.
agentId?: string | null;
};
export type CreatedMember = {
id: string;
userId: string;
userEmail: string;
firstName: string;
lastName: string;
roleId: string;
agentId: string | null;
};
// Update payload — name + role only. Email and password are not
// touched (they need separate flows). SIP seat reassignment goes
// through the Telephony step's updateAgent path, not here.
export type UpdateMemberInput = {
firstName: string;
lastName: string;
roleId: string;
};
@Injectable()
export class TeamService {
private readonly logger = new Logger(TeamService.name);
// Workspace invite hash is stable for the lifetime of the workspace
// — cache it after first fetch so subsequent creates skip the
// extra round-trip.
private cachedInviteHash: { workspaceId: string; inviteHash: string } | null = null;
constructor(
private platform: PlatformGraphqlService,
private session: SessionService,
) {}
async createMember(input: CreateMemberInput): Promise<CreatedMember> {
const email = input.email.trim().toLowerCase();
const firstName = input.firstName.trim();
const lastName = input.lastName.trim();
if (!email || !firstName || !input.password || !input.roleId) {
throw new BadRequestException('email, firstName, password and roleId are required');
}
// Step 1 — fetch workspace id + invite hash
const ws = await this.getWorkspaceContext();
// Step 2 — create the user + workspace member via signUpInWorkspace
try {
await this.platform.query(
`mutation SignUpInWorkspace($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
signUpInWorkspace(
email: $email,
password: $password,
workspaceId: $workspaceId,
workspaceInviteHash: $workspaceInviteHash,
) {
workspace { id }
}
}`,
{
email,
password: input.password,
workspaceId: ws.workspaceId,
workspaceInviteHash: ws.inviteHash,
},
);
} catch (err) {
this.logger.warn(`signUpInWorkspace failed for ${email}: ${err}`);
throw new BadRequestException(this.extractGraphqlMessage(err));
}
// Step 3 — find the workspaceMember that just got created. We
// filter by userEmail since that's the only handle we have.
// Plural query + client-side pick so we don't rely on a
// specific filter shape.
const membersData = await this.platform.query<{
workspaceMembers: { edges: { node: { id: string; userId: string; userEmail: string } }[] };
}>(
`{ workspaceMembers { edges { node { id userId userEmail } } } }`,
);
const member = membersData.workspaceMembers.edges
.map((e) => e.node)
.find((m) => (m.userEmail ?? '').toLowerCase() === email);
if (!member) {
throw new BadRequestException(
'Workspace member was created but could not be located — retry in a few seconds',
);
}
// Step 4 — set their name. Note: the platform's
// updateWorkspaceMember mutation declares its `id` arg as
// `UUID!` (not `ID!`), and GraphQL refuses to coerce between
// those scalars even though both hold the same string value.
// Same applies to updateAgent below — verified via __schema
// introspection. Pre-existing code in platform-graphql.service
// still uses `ID!` for updateLead; that's a separate latent
// bug that's untouched here so the diff stays focused on the
// team-create failure.
try {
await this.platform.query(
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
updateWorkspaceMember(id: $id, data: $data) { id }
}`,
{
id: member.id,
data: {
name: { firstName, lastName },
},
},
);
} catch (err) {
this.logger.warn(`updateWorkspaceMember name failed for ${member.id}: ${err}`);
// Non-fatal — the account exists, just unnamed. Surface it
// anyway so the admin can fix in settings.
throw new BadRequestException(this.extractGraphqlMessage(err));
}
// Step 5 — assign role
try {
await this.platform.query(
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
id
}
}`,
{ workspaceMemberId: member.id, roleId: input.roleId },
);
} catch (err) {
this.logger.warn(`updateWorkspaceMemberRole failed for ${member.id}: ${err}`);
throw new BadRequestException(this.extractGraphqlMessage(err));
}
// Step 6 — (optional) link SIP seat
if (input.agentId) {
try {
await this.platform.query(
`mutation LinkAgent($id: UUID!, $data: AgentUpdateInput!) {
updateAgent(id: $id, data: $data) { id workspaceMemberId }
}`,
{
id: input.agentId,
data: { workspaceMemberId: member.id },
},
);
} catch (err) {
this.logger.warn(`updateAgent link failed for agent ${input.agentId}: ${err}`);
throw new BadRequestException(this.extractGraphqlMessage(err));
}
}
// Cache the plaintext temp password in Redis (24h TTL) so the
// wizard's right-pane copy icon keeps working after a browser
// refresh. The password is also stored hashed on the platform
// (used for actual login auth) — this Redis copy exists ONLY
// so the admin can recover the plaintext to share with the
// employee. Expires automatically; no plaintext persists past
// 24h. Trade-off accepted because the plan is to force a
// password reset on first login (defense in depth).
try {
await this.session.setCache(
tempPasswordKey(member.id),
input.password,
TEMP_PASSWORD_TTL_SECONDS,
);
} catch (err) {
this.logger.warn(`Failed to cache temp password for ${member.id}: ${err}`);
// Non-fatal — admin can still copy from session memory
// before page reload. We just lose the post-reload
// recovery path for this one member.
}
this.logger.log(
`Created member ${email} (id=${member.id}) role=${input.roleId} agent=${input.agentId ?? 'none'}`,
);
return {
id: member.id,
userId: member.userId,
userEmail: email,
firstName,
lastName,
roleId: input.roleId,
agentId: input.agentId ?? null,
};
}
// Read the cached temp password for a member, if it's still
// within its 24h TTL. Returns null on miss (cache expired, member
// never created via this endpoint, or Redis unreachable). The
// wizard's copy icon falls back to this when the in-browser
// memory was wiped by a page reload.
async getTempPassword(memberId: string): Promise<string | null> {
if (!memberId) return null;
try {
return await this.session.getCache(tempPasswordKey(memberId));
} catch (err) {
this.logger.warn(`Failed to read temp password cache for ${memberId}: ${err}`);
return null;
}
}
// Update an existing workspace member — name + role only.
// Mirrors the create path's mutation chain but skips signUp,
// member lookup, and the SIP seat link. Errors bubble up as
// BadRequestException so the admin sees the real GraphQL message.
async updateMember(memberId: string, input: UpdateMemberInput): Promise<{ id: string }> {
const firstName = input.firstName.trim();
const lastName = input.lastName.trim();
if (!memberId || !firstName || !input.roleId) {
throw new BadRequestException('memberId, firstName and roleId are required');
}
// Step 1 — set their name
try {
await this.platform.query(
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
updateWorkspaceMember(id: $id, data: $data) { id }
}`,
{
id: memberId,
data: {
name: { firstName, lastName },
},
},
);
} catch (err) {
this.logger.warn(`updateWorkspaceMember name failed for ${memberId}: ${err}`);
throw new BadRequestException(this.extractGraphqlMessage(err));
}
// Step 2 — assign role (idempotent — same call as the create
// path so changing role from X to X is a no-op).
try {
await this.platform.query(
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
id
}
}`,
{ workspaceMemberId: memberId, roleId: input.roleId },
);
} catch (err) {
this.logger.warn(`updateWorkspaceMemberRole failed for ${memberId}: ${err}`);
throw new BadRequestException(this.extractGraphqlMessage(err));
}
this.logger.log(`Updated member ${memberId} (name="${firstName} ${lastName}", role=${input.roleId})`);
return { id: memberId };
}
private async getWorkspaceContext(): Promise<{ workspaceId: string; inviteHash: string }> {
if (this.cachedInviteHash) return this.cachedInviteHash;
const data = await this.platform.query<{
currentWorkspace: {
id: string;
inviteHash: string;
isPublicInviteLinkEnabled: boolean;
};
}>(`{ currentWorkspace { id inviteHash isPublicInviteLinkEnabled } }`);
const ws = data.currentWorkspace;
if (!ws?.id || !ws?.inviteHash) {
throw new BadRequestException(
'Workspace is missing id/inviteHash — cannot create employees in-place',
);
}
if (!ws.isPublicInviteLinkEnabled) {
// signUpInWorkspace will reject us without this flag set.
// Surface a clear error instead of the platform's opaque
// "FORBIDDEN" response.
throw new BadRequestException(
'Workspace public invite link is disabled — enable it in workspace settings so the server can mint user accounts directly',
);
}
this.cachedInviteHash = { workspaceId: ws.id, inviteHash: ws.inviteHash };
return this.cachedInviteHash;
}
private extractGraphqlMessage(err: unknown): string {
const msg = (err as Error)?.message ?? 'Unknown error';
// PlatformGraphqlService wraps errors as `GraphQL error: [{...}]`.
// Pull out the first message so the admin sees something
// meaningful in the toast.
const match = msg.match(/"message":"([^"]+)"/);
return match ? match[1] : msg;
}
}

View File

@@ -158,48 +158,28 @@ export class WidgetChatService {
const kb = await this.getKnowledgeBase();
// Branch context flips the tool-usage rules: no branch = must call
// pick_branch first; branch set = always pass it to branch-aware tools.
// pick_branch first; branch set = always pass it to branch-aware
// tools. We pre-render this block since the structure is dynamic
// and the template just slots it in via {{branchContext}}.
const branchContext = selectedBranch
? [
`CURRENT BRANCH: ${selectedBranch}`,
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
]
].join('\n')
: [
'BRANCH STATUS: NOT SET',
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
];
return [
`You are a helpful, concise assistant for ${init.brand.name}.`,
`You are chatting with a website visitor named ${userName}.`,
'',
...branchContext,
'',
'TOOL USAGE RULES (STRICT):',
'- When the user asks about departments, call list_departments and DO NOT also list departments in prose.',
'- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.',
'- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.',
'- When they ask about a specific doctor\'s availability or want to book with them, call show_doctor_slots.',
'- When the conversation is trending toward booking, call suggest_booking.',
'- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence',
' (under 15 words) framing the widget, or no text at all. The widget already shows the data.',
'- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,',
' STOP and call the appropriate tool instead.',
'- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in',
' non-tool replies.',
'- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".',
' If the visitor asks about a future date, tell them to use the Book tab\'s date picker.',
'',
'OTHER RULES:',
'- Answer other questions (directions, general info) concisely in prose.',
'- If you do not know something, say so and suggest they call the hospital.',
'- Never quote prices. No medical advice. For clinical questions, defer to a doctor.',
'',
kb,
].join('\n');
return this.aiConfig.renderPrompt('widgetChat', {
hospitalName: init.brand.name,
userName,
branchContext,
knowledgeBase: kb,
});
}
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
@@ -213,13 +193,15 @@ export class WidgetChatService {
const platform = this.platform;
const widgetSvc = this.widget;
// Small helper: does a doctor's clinic match the branch filter?
// Case-insensitive substring match so "Indiranagar" matches
// "Indiranagar Clinic" etc.
// Branch-matching now uses the doctor's full `clinics` array
// (NormalizedDoctor) since one doctor can visit multiple
// clinics under the post-rework data model. doctorMatchesBranch
// returns true if ANY of their visit-slot clinics matches.
const matchesBranch = (d: any, branch: string | undefined): boolean => {
if (!branch) return true;
const clinicName = String(d.clinic?.clinicName ?? '').toLowerCase();
return clinicName.includes(branch.toLowerCase());
const needle = branch.toLowerCase();
const clinics: Array<{ clinicName: string }> = d.clinics ?? [];
return clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
};
const tools = {
@@ -229,23 +211,37 @@ export class WidgetChatService {
inputSchema: z.object({}),
execute: async () => {
const doctors = await widgetSvc.getDoctors();
const byBranch = new Map<string, { doctorCount: number; departments: Set<string> }>();
// Branches come from the union of all doctors'
// visit-slot clinics. Each (clinic × doctor) pair
// counts once toward that branch's doctor count;
// we use a Set on doctor ids to avoid double-
// counting the same doctor against the same branch
// when they have multiple slots there.
const byBranch = new Map<
string,
{ doctorIds: Set<string>; departments: Set<string> }
>();
for (const d of doctors) {
const name = d.clinic?.clinicName?.trim();
for (const c of d.clinics ?? []) {
const name = c.clinicName?.trim();
if (!name) continue;
if (!byBranch.has(name)) {
byBranch.set(name, { doctorCount: 0, departments: new Set() });
byBranch.set(name, {
doctorIds: new Set(),
departments: new Set(),
});
}
const entry = byBranch.get(name)!;
entry.doctorCount += 1;
if (d.id) entry.doctorIds.add(d.id);
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
}
}
return {
branches: Array.from(byBranch.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, { doctorCount, departments }]) => ({
.map(([name, { doctorIds, departments }]) => ({
name,
doctorCount,
doctorCount: doctorIds.size,
departmentCount: departments.size,
})),
};

View File

@@ -3,6 +3,7 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { ConfigService } from '@nestjs/config';
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
import { ThemeService } from '../config/theme.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
// Dedup window: any lead created for this phone within the last 24h is
// considered the same visitor's lead — chat + book + contact by the same
@@ -131,16 +132,22 @@ export class WidgetService {
};
}
async getDoctors(): Promise<any[]> {
// Returns NormalizedDoctor[] — the raw GraphQL fields plus three
// derived bridge fields (`clinics`, `clinic`, `visitingHours`)
// built from the visit-slots reverse relation. See
// shared/doctor-utils.ts for the rationale and the format of the
// visiting-hours summary string.
async getDoctors(): Promise<NormalizedDoctor[]> {
const data = await this.platform.queryWithAuth<any>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department specialty visitingHours
id name fullName { firstName lastName } department specialty
consultationFeeNew { amountMicros currencyCode }
clinic { clinicName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, this.auth,
);
return data.doctors.edges.map((e: any) => e.node);
const raws = data.doctors.edges.map((e: any) => e.node);
return normalizeDoctors(raws);
}
async getSlots(doctorId: string, date: string): Promise<any> {