- AI 챗봇 '짹'의 페르소나 정의 및 시스템 프롬프트 상수를 추가했습니다. - 채팅 이력, 쿼터 관리, 요청 멱등성 보장을 위한 Firestore 데이터 모델을 구현했습니다. - 위기 상황 대응(자해·위해 예고 등) 및 입력/출력 필터링 파이프라인을 구축했습니다. - Gemini 및 Anthropic 모델을 지원하는 AI Provider 추상화 계층을 마련했습니다.
193 lines
7.7 KiB
TypeScript
193 lines
7.7 KiB
TypeScript
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);
|
||
}
|