mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-12 02:18:18 +00:00
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:
@@ -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
|
||||
// GET /api/config/ai — full config (no secrets here, all safe to return)
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user