mmday-firebase/src/services/chatConfigService.ts
윤정민 b67125f412 Add AI chat persona and infrastructure for the "Jjaek" chatbot.
- AI 챗봇 '짹'의 페르소나 정의 및 시스템 프롬프트 상수를 추가했습니다.
- 채팅 이력, 쿼터 관리, 요청 멱등성 보장을 위한 Firestore 데이터 모델을 구현했습니다.
- 위기 상황 대응(자해·위해 예고 등) 및 입력/출력 필터링 파이프라인을 구축했습니다.
- Gemini 및 Anthropic 모델을 지원하는 AI Provider 추상화 계층을 마련했습니다.
2026-06-15 13:07:58 +09:00

193 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ChatConfig>(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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries((raw ?? {}) as Record<string, unknown>)) {
if (typeof value === "string" && value.trim().length > 0) out[key] = value;
}
return out;
}
function mergeWithDefaults(raw: Record<string, unknown>): 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<ChatConfig> {
return cache.getOrFetch(CACHE_KEY, async () => {
const snap = await firestore.collection("config").doc("chat").get();
return mergeWithDefaults((snap.data() ?? {}) as Record<string, unknown>);
});
}
/** 테스트·운영 도구용 캐시 무효화. */
export function invalidateChatConfigCache(): void {
cache.delete(CACHE_KEY);
}