- 대화 스타일(3부 기본 스타일 + 짹 아키타입)을 chatStyles.ts의 스타일 팩으로 분리하고, 현재 "익명 야구방 상주러" 스타일을 "bestfriend" id로 등록했습니다.
- COMMON 시스템 프롬프트는 1부 안전·데이터, 2부 앱 능력만 고정으로 남기고 3부 자리에 {{styleBaseStyle}} 플레이스홀더를 두어 스타일 팩에서 주입하도록 했습니다.
- ChatConfig에 stylePack 필드(기본 bestfriend)를 추가해 config/chat.stylePack으로 코드 배포 없이 스타일을 교체할 수 있게 했습니다.
- 조립 결과는 기존과 본문 동일(3부 헤더 주석 1줄 외)로 동작 변화 없이 구조만 모듈화했습니다.
202 lines
8.4 KiB
TypeScript
202 lines
8.4 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 { DEFAULT_STYLE_ID } from "../constants/chatStyles";
|
||
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,
|
||
// Gemini 3는 thinking 토큰도 출력 예산에 포함된다 — 512는 thinking이 예산을
|
||
// 소진해 빈 응답(finishReason=length)을 유발했다. 짧은 캐릭터 응답이라도
|
||
// thinking + 함수호출 + 답변 여유분으로 넉넉히 잡는다.
|
||
maxOutputTokens: 2048,
|
||
// 모든 모델 공평 타임아웃 — 25초 총예산(클라 receiveTimeout 30초 - 여유)에서 쓸 수
|
||
// 있는 최대 단일 시도. 모델마다 속도가 달라도 동일 예산이라 프로필이 불필요하다
|
||
// (느린 모델도 24초까지 기다림). 재시도는 예산상 0(24s×1 ≤ 25s).
|
||
timeoutMs: 24_000,
|
||
maxRetries: 0,
|
||
};
|
||
|
||
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: "",
|
||
stylePack: DEFAULT_STYLE_ID,
|
||
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),
|
||
stylePack: str(raw.stylePack, d.stylePack),
|
||
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);
|
||
}
|