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 { 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 { 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 = { ...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}`); } } }