mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
- Team module: POST /api/team/members (in-place employee creation with temp password + Redis cache), PUT /api/team/members/:id, GET temp password endpoint. Uses signUpInWorkspace — no email invites. - Dockerfile: rewritten as multi-stage build (builder + runtime) so native modules compile for target arch. Fixes darwin→linux crash. - .dockerignore: exclude dist, node_modules, .env, .git, data/ - package-lock.json: regenerated against public npmjs.org (was pointing at localhost:4873 Verdaccio — broke docker builds) - Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors helper for visit-slot-aware queries across 6 consumers - AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped setup-state with workspace ID isolation, AI prompt defaults overhaul - Agent config: camelCase field fix for SDK-synced workspaces - Session service: workspace-scoped Redis key prefixing for setup state - Recordings/supervisor/widget services: updated to use doctor-utils shared fragments instead of inline visitingHours queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
219 lines
8.5 KiB
TypeScript
219 lines
8.5 KiB
TypeScript
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, 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);
|
|
private cached: AiConfig | null = null;
|
|
|
|
onModuleInit() {
|
|
this.ensureReady();
|
|
}
|
|
|
|
getConfig(): AiConfig {
|
|
if (this.cached) return this.cached;
|
|
return this.load();
|
|
}
|
|
|
|
updateConfig(updates: Partial<AiConfig>): AiConfig {
|
|
const current = this.getConfig();
|
|
const merged: AiConfig = {
|
|
...current,
|
|
...updates,
|
|
// Clamp temperature to a sane range so an admin typo can't break
|
|
// the model — most providers reject < 0 or > 2.
|
|
temperature:
|
|
updates.temperature !== undefined
|
|
? Math.max(0, Math.min(2, updates.temperature))
|
|
: current.temperature,
|
|
version: (current.version ?? 0) + 1,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
this.backup();
|
|
this.writeFile(merged);
|
|
this.cached = merged;
|
|
this.logger.log(`AI config updated to v${merged.version}`);
|
|
return merged;
|
|
}
|
|
|
|
resetConfig(): AiConfig {
|
|
this.backup();
|
|
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
|
|
this.writeFile(fresh);
|
|
this.cached = fresh;
|
|
this.logger.log('AI config reset to defaults');
|
|
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();
|
|
}
|
|
const seeded: AiConfig = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
|
|
let appliedCount = 0;
|
|
for (const seed of AI_ENV_SEEDS) {
|
|
const value = process.env[seed.env];
|
|
if (value === undefined || value === '') continue;
|
|
(seeded as any)[seed.field] = value;
|
|
appliedCount += 1;
|
|
}
|
|
seeded.version = 1;
|
|
seeded.updatedAt = new Date().toISOString();
|
|
this.writeFile(seeded);
|
|
this.cached = seeded;
|
|
this.logger.log(
|
|
`AI config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`,
|
|
);
|
|
return seeded;
|
|
}
|
|
|
|
private load(): AiConfig {
|
|
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');
|
|
return merged;
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to load AI config, using defaults: ${err}`);
|
|
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
|
|
this.cached = fresh;
|
|
return fresh;
|
|
}
|
|
}
|
|
|
|
private writeFile(cfg: AiConfig) {
|
|
const dir = dirname(CONFIG_PATH);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
|
|
}
|
|
|
|
private backup() {
|
|
try {
|
|
if (!existsSync(CONFIG_PATH)) return;
|
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `ai-${ts}.json`));
|
|
} catch (err) {
|
|
this.logger.warn(`AI config backup failed: ${err}`);
|
|
}
|
|
}
|
|
}
|