import { firestore } from "../firebase"; import { MemCache } from "../lib/memCache"; import { DEFAULT_FILTER_CONFIG } from "../constants/chatFilters"; import { DEFAULT_SUGGESTIONS } from "../constants/chatPrompts"; import type { ChatConfig, ChatFilterConfig, ChatProviderConfig, ChatSuggestion } from "../types/chat"; /** * `config/chat` 로더(§4.4). * * - 문서가 없거나 일부 필드만 있어도 기본값으로 채워 항상 완전한 설정을 반환한다. * - 메모리 캐시 TTL 5분 — 기존 캐싱 전략(`docs/server_prompt.md`)과 동일. * - 프롬프트·필터 사전을 평문으로 담으므로 클라이언트 읽기는 보안 규칙으로 차단된다(§4.6). */ /** 클라 receiveTimeout 30초(§9.2)를 지키기 위한 재시도 포함 총 AI 호출 예산 상한(§8.2). */ export const TOTAL_AI_BUDGET_MS = 25_000; /** 재시도 백오프(지수 — 500ms × 2^(attempt-1), §8.2)의 합. */ export function backoffSumMs(maxRetries: number): number { return 500 * (2 ** Math.max(0, maxRetries) - 1); } /** 25초 예산과 1초 타임아웃 하한에서 수용 가능한 재시도 상한. */ const MAX_RETRIES_CAP = 3; export const DEFAULT_PROVIDER_CONFIG: ChatProviderConfig = { // 기본 벤더: Vertex AI(Gemini) — Cloud Functions의 ADC 인증으로 키 관리가 불필요. // 교체는 config/chat.provider.name으로: "mock"(스텁) | "vertex" | "anthropic"(§8). // anthropic 사용 시 ANTHROPIC_API_KEY 시크릿 설정 필요. name: "vertex", model: "gemini-3.1-flash-lite", temperature: 0.7, maxOutputTokens: 512, timeoutMs: 12_000, maxRetries: 1, }; export const DEFAULT_CHAT_CONFIG: ChatConfig = { enabled: true, dailyLimit: 10, maxMessageLength: 500, historyTurns: 10, historyMaxAgeHours: 48, ratePerMinute: 6, blockThresholdPerDay: 10, crisisThresholdPerDay: 10, retentionDays: 90, provider: DEFAULT_PROVIDER_CONFIG, promptVersion: "2026-06-12.1", systemPromptCommon: "", teamPersonas: {}, teamDisplayNames: {}, suggestions: DEFAULT_SUGGESTIONS, suggestionsVersion: "2026-06-12.1", globalDailyCallLimit: 50_000, filters: DEFAULT_FILTER_CONFIG, }; const cache = new MemCache(5 * 60 * 1000); const CACHE_KEY = "config/chat"; function num(v: unknown, fallback: number): number { return typeof v === "number" && Number.isFinite(v) ? v : fallback; } function str(v: unknown, fallback: string): string { return typeof v === "string" ? v : fallback; } function bool(v: unknown, fallback: boolean): boolean { return typeof v === "boolean" ? v : fallback; } function strArray(v: unknown, fallback: string[]): string[] { return Array.isArray(v) && v.every((s) => typeof s === "string") ? (v as string[]) : fallback; } function mergeProvider(raw: unknown): ChatProviderConfig { const r = (raw ?? {}) as Record; const d = DEFAULT_PROVIDER_CONFIG; return { name: str(r.name, d.name), model: str(r.model, d.model), temperature: num(r.temperature, d.temperature), maxOutputTokens: num(r.maxOutputTokens, d.maxOutputTokens), timeoutMs: num(r.timeoutMs, d.timeoutMs), maxRetries: num(r.maxRetries, d.maxRetries), }; } function mergeFilters(raw: unknown): ChatFilterConfig { const r = (raw ?? {}) as Record; const d = DEFAULT_FILTER_CONFIG; return { hatePatterns: strArray(r.hatePatterns, d.hatePatterns), sexualPatterns: strArray(r.sexualPatterns, d.sexualPatterns), minorPatterns: strArray(r.minorPatterns, d.minorPatterns), insultPatterns: strArray(r.insultPatterns, d.insultPatterns), crisisPatterns: strArray(r.crisisPatterns, d.crisisPatterns), crisisUrgentPatterns: strArray(r.crisisUrgentPatterns, d.crisisUrgentPatterns), crisisAbusePatterns: strArray(r.crisisAbusePatterns, d.crisisAbusePatterns), crisisThreatPatterns: strArray(r.crisisThreatPatterns, d.crisisThreatPatterns), }; } function mergeSuggestions(raw: unknown): ChatSuggestion[] { if (!Array.isArray(raw) || raw.length === 0) return DEFAULT_SUGGESTIONS; const out: ChatSuggestion[] = []; for (const item of raw) { const r = item as Record; if (typeof r?.id !== "string" || typeof r?.text !== "string") continue; out.push({ id: r.id, text: r.text, requiresTeam: bool(r.requiresTeam, false), requiresTodayTeamGame: bool(r.requiresTodayTeamGame, false), requiresYesterdayRecap: bool(r.requiresYesterdayRecap, false), excludeWhenPredictedToday: bool(r.excludeWhenPredictedToday, false), priorityWhenRecap: bool(r.priorityWhenRecap, false), }); } return out.length > 0 ? out : DEFAULT_SUGGESTIONS; } /** * 타임아웃 예산 제약(§8.2): timeoutMs × (1 + maxRetries) + 백오프 합 ≤ 25,000ms. * 위반 설정은 거부 대신 maxRetries·timeoutMs를 차례로 보정(클램프)하고 경고를 남긴다 — * 보정 결과는 항상 부등식을 만족한다. */ export function enforceProviderBudget(p: ChatProviderConfig): ChatProviderConfig { const retries = Math.min(MAX_RETRIES_CAP, Math.max(0, Math.floor(p.maxRetries))); const budget = p.timeoutMs * (1 + retries) + backoffSumMs(retries); if (retries === p.maxRetries && budget <= TOTAL_AI_BUDGET_MS) return { ...p, maxRetries: retries }; if (budget <= TOTAL_AI_BUDGET_MS) { console.warn(`[chat-config] maxRetries ${p.maxRetries} → ${retries}로 보정(예산 상한)`); return { ...p, maxRetries: retries }; } const clampedTimeout = Math.max( 1_000, Math.floor((TOTAL_AI_BUDGET_MS - backoffSumMs(retries)) / (1 + retries)), ); console.warn( `[chat-config] provider 타임아웃 예산 초과(${budget}ms > ${TOTAL_AI_BUDGET_MS}ms) — ` + `maxRetries ${p.maxRetries} → ${retries}, timeoutMs ${p.timeoutMs} → ${clampedTimeout}로 보정`, ); return { ...p, maxRetries: retries, timeoutMs: clampedTimeout }; } function strMap(raw: unknown): Record { const out: Record = {}; for (const [key, value] of Object.entries((raw ?? {}) as Record)) { if (typeof value === "string" && value.trim().length > 0) out[key] = value; } return out; } function mergeWithDefaults(raw: Record): ChatConfig { const d = DEFAULT_CHAT_CONFIG; const teamPersonas = strMap(raw.teamPersonas); return { enabled: bool(raw.enabled, d.enabled), dailyLimit: num(raw.dailyLimit, d.dailyLimit), maxMessageLength: num(raw.maxMessageLength, d.maxMessageLength), historyTurns: num(raw.historyTurns, d.historyTurns), historyMaxAgeHours: num(raw.historyMaxAgeHours, d.historyMaxAgeHours), ratePerMinute: num(raw.ratePerMinute, d.ratePerMinute), blockThresholdPerDay: num(raw.blockThresholdPerDay, d.blockThresholdPerDay), crisisThresholdPerDay: num(raw.crisisThresholdPerDay, d.crisisThresholdPerDay), retentionDays: num(raw.retentionDays, d.retentionDays), provider: enforceProviderBudget(mergeProvider(raw.provider)), promptVersion: str(raw.promptVersion, d.promptVersion), systemPromptCommon: str(raw.systemPromptCommon, d.systemPromptCommon), teamPersonas, teamDisplayNames: strMap(raw.teamDisplayNames), suggestions: mergeSuggestions(raw.suggestions), suggestionsVersion: str(raw.suggestionsVersion, d.suggestionsVersion), globalDailyCallLimit: num(raw.globalDailyCallLimit, d.globalDailyCallLimit), filters: mergeFilters(raw.filters), }; } export async function getChatConfig(): Promise { return cache.getOrFetch(CACHE_KEY, async () => { const snap = await firestore.collection("config").doc("chat").get(); return mergeWithDefaults((snap.data() ?? {}) as Record); }); } /** 테스트·운영 도구용 캐시 무효화. */ export function invalidateChatConfigCache(): void { cache.delete(CACHE_KEY); }