import { Timestamp } from "firebase-admin/firestore"; import { HttpError } from "../middleware/errors"; import { getUser } from "../repositories/userRepository"; import { finalizeExchange, findMessageAcrossThreads, getGlobalUsage, getMessage, getQuotaDoc, getRecentMessages, getThreadDoc, hashMessage, incrementGlobalUsage, kstResetAt, listMessagesPage, recordBlockedAttemptTx, recordTokenUsage, refundWithRetry, reserveRequestTx, upsertReport, type ExchangeResult, type MessageWithId, } from "../repositories/chatRepository"; import { getChatConfig } from "./chatConfigService"; import { callProviderWithBudget, getChatProvider, type ChatProviderMessage, } from "./chatProviderService"; import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService"; import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService"; import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts"; import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatMessageView, type ChatQuotaView, type ChatReportReason, type ChatSendResult, type ChatSuggestion, type ChatSuggestionsView } from "../types/chat"; import type { User } from "../types/panit"; import { todayKst, type DateString } from "../types/dateString"; /** * AI 채팅(짹) 서비스 — `POST /chat/messages` 11단계 처리(§3.1)와 부속 조회. */ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** KST ISO 8601(+09:00) 포맷. */ export function toKstIso(ts: Timestamp): string { const kst = new Date(ts.toMillis() + 9 * 3600 * 1000); return kst.toISOString().replace(/\.\d{3}Z$/, "+09:00"); } /** 활성 threadId 결정(§3 스레드 결정 규칙) — 클라이언트는 스레드를 지정하지 않는다. */ function resolveThreadId(user: User | null): string { return resolveTeamCode(user?.favoriteTeamCode) ?? "default"; } interface SendBody { message?: unknown; clientMessageId?: unknown; } function validateSendBody(body: SendBody, config: ChatConfig): { message: string; clientMessageId: string } { const message = body.message; if (typeof message !== "string" || message.trim().length === 0) { throw new HttpError(400, "message is required", "INVALID_REQUEST"); } if (message.length > config.maxMessageLength) { throw new HttpError(400, `message exceeds ${config.maxMessageLength} chars`, "INVALID_REQUEST"); } const clientMessageId = body.clientMessageId; if (typeof clientMessageId !== "string" || !UUID_V4_RE.test(clientMessageId)) { throw new HttpError(400, "clientMessageId must be a UUID v4", "INVALID_REQUEST"); } return { message, clientMessageId: clientMessageId.toLowerCase() }; } async function quotaView(uid: string, date: DateString): Promise<{ used: number }> { const quota = await getQuotaDoc(uid, date); return { used: quota.used ?? 0 }; } async function buildSendResult( uid: string, date: DateString, config: ChatConfig, messageId: string, reply: string, crisis: boolean, createdAt: Timestamp, ): Promise { const { used } = await quotaView(uid, date); return { messageId, reply, crisis, remainingCount: Math.max(0, config.dailyLimit - used), limit: config.dailyLimit, createdAt: toKstIso(createdAt), }; } /** 멱등 재반환(§3.1) — reply·messageId는 저장값, 쿼터는 응답 시점 재계산. */ async function replayDone( uid: string, date: DateString, config: ChatConfig, threadId: string, assistantMessageId: string, ): Promise { const stored = await getMessage(uid, threadId, assistantMessageId); if (!stored) { // done 마킹과 메시지 저장은 단일 배치이므로 정상 경로에서는 도달 불가 throw new HttpError(503, "stored reply missing", "AI_UNAVAILABLE"); } return buildSendResult(uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt); } /** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */ async function loadHistory( uid: string, threadId: string, config: ChatConfig, ): Promise { const fetchLimit = config.historyTurns * 2 + 30; const [thread, recentDesc] = await Promise.all([ getThreadDoc(uid, threadId), getRecentMessages(uid, threadId, fetchLimit), ]); const minCreatedAt = Date.now() - config.historyMaxAgeHours * 3600 * 1000; const cutAt = thread?.historyCutAt?.toMillis() ?? 0; const asc = [...recentDesc].reverse(); // 위기 교환 쌍 제외 — crisis assistant와 그 replyTo user 메시지(§5.5) const excludedUserIds = new Set(); for (const m of asc) { if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo); } const windowed = asc.filter((m: MessageWithId) => { const ms = m.createdAt.toMillis(); if (ms < minCreatedAt || ms < cutAt) return false; if (m.role === "assistant" && (m.crisis || m.filtered)) return false; if (m.role === "user" && excludedUserIds.has(m.messageId)) return false; return true; }); const lastN = windowed.slice(-config.historyTurns * 2); // provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거 while (lastN.length > 0 && lastN[0].role === "assistant") lastN.shift(); return lastN.map((m) => ({ role: m.role, content: m.content })); } /** 비용 가드 80% 운영 알림(§8.3) — 인스턴스·날짜당 1회만 경고. */ const usageWarnedDates = new Set(); /** `POST /chat/messages`(§3.1). */ export async function sendMessage(uid: string, body: SendBody): Promise { // 2) 입력 검증 const config = await getChatConfig(); const { message, clientMessageId } = validateSendBody(body, config); const date = todayKst(); // 3) 기능·비용 가드 — 차감 전 검사이므로 미차감(§8.3) if (!config.enabled) { throw new HttpError(503, "chat is disabled", "AI_UNAVAILABLE"); } const globalUsage = await getGlobalUsage(date); if (globalUsage >= config.globalDailyCallLimit) { throw new HttpError(503, "daily global call limit reached", "AI_UNAVAILABLE"); } if (globalUsage >= config.globalDailyCallLimit * 0.8 && !usageWarnedDates.has(date)) { usageWarnedDates.add(date); console.warn( `[chat] 전역 호출량 80% 임계 도달 — date=${date} used≈${globalUsage} limit=${config.globalDailyCallLimit}`, ); } const user = await getUser(uid); const threadId = resolveThreadId(user); // 4-a) 위기 키워드 우선(§7.3 ①) — 자기파괴 발화가 모욕 사전에 걸려 // 422로 차단되지 않도록 위기 감지를 입력 필터보다 먼저 수행한다(안전 최우선) const crisis = detectCrisis(message, config.filters); // 4-b) 입력 필터 1단계(키워드) — 차감 전 수행, 본문은 저장하지 않는다(§4.1) if (!crisis.crisis) { const inputCheck = checkInput(message, config.filters); if (inputCheck.blocked) { await recordBlockedAttemptTx(uid, date, config.dailyLimit, config.blockThresholdPerDay); throw new HttpError(422, `input blocked (${inputCheck.category})`, "INPUT_BLOCKED", { notice: INPUT_BLOCKED_NOTICE, }); } } // 5) 멱등 예약·레이트리밋·한도 차감 — 단일 트랜잭션 let outcome; try { outcome = await reserveRequestTx({ uid, clientMessageId, messageHash: hashMessage(message), threadId, date, limit: config.dailyLimit, ratePerMinute: config.ratePerMinute, retentionDays: config.retentionDays, crisisPath: crisis.crisis, crisisThresholdPerDay: config.crisisThresholdPerDay, }); } catch (err) { // 동시 중복 create 충돌(ALREADY_EXISTS) → 처리 중으로 응답 if ((err as { code?: number }).code === 6) { throw new HttpError(409, "request in flight", "DUPLICATE_REQUEST"); } throw err; } // 멱등 재반환 — 항상 pin된 threadId 기준(처리 도중 응원팀 변경에도 원래 스레드) if (outcome.kind === "done") { return replayDone(uid, date, config, outcome.threadId, outcome.assistantMessageId); } // pin된 threadId 채택(§3.1 처리 5) — 크래시 재개 시 응답이 원래 스레드에 저장된다. // threadId는 곧 팀 코드("default" 제외, §4.1)이므로 teamCode도 pin 기준으로 도출한다. const activeThreadId = outcome.threadId; const activeTeamCode = resolveTeamCode(activeThreadId); // 위기 경로 — 고정 응답 저장 후 반환(차감은 reserve에서 임계 기준으로 처리됨) if (crisis.crisis && crisis.type) { const reply = crisisReply(crisis.type, crisis.urgent); let saved: ExchangeResult; try { saved = await finalizeExchange({ uid, threadId: activeThreadId, teamCode: activeTeamCode, clientMessageId, userContent: message, assistantContent: reply, promptVersion: config.promptVersion, filtered: false, crisis: true, retentionDays: config.retentionDays, }); } catch (err) { // 저장(처리 10) 이전 실패 — 임계 초과 차감분이 있다면 복원(§3.1 복원 규칙) await refundWithRetry(uid, clientMessageId); throw err; } return buildSendResult(uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt); } let saved: ExchangeResult | null = null; let finalReply = ""; let finalCrisis = false; try { // 6) 컨텍스트 조립(§5) const ctx: UserContext = await gatherUserContext(uid, user, config); const { system, leakBody } = assemblePrompt(config, ctx); const history = await loadHistory(uid, activeThreadId, config); const messages: ChatProviderMessage[] = [...history, { role: "user", content: message }]; // 7) 입력 필터 2단계(provider 모더레이션, 선택) — 차단 시 422 + 차감 복원 const provider = getChatProvider(config.provider); if (provider.moderate) { await incrementGlobalUsage(date); // 모더레이션 호출도 비용 가드에 집계(§8.3) const moderation = await provider.moderate(message); if (moderation.blocked) { await refundWithRetry(uid, clientMessageId); await recordBlockedAttemptTx(uid, date, config.dailyLimit, config.blockThresholdPerDay); throw new HttpError(422, "input blocked (moderation)", "INPUT_BLOCKED", { notice: INPUT_BLOCKED_NOTICE, }); } } // 8) AI Provider 호출(§8) — 예산 기반 재시도 await incrementGlobalUsage(date); let result; try { result = await callProviderWithBudget(provider, { system, messages, config: config.provider }); } catch (err) { if (err instanceof HttpError) throw err; console.error("[chat] provider 호출 실패", err); throw new HttpError(503, "AI provider unavailable", "AI_UNAVAILABLE"); } // 일일 토큰 사용량 합산(§8.3) — best-effort, 실패가 응답을 막지 않는다 void recordTokenUsage(date, result.usage).catch((e) => console.warn("[chat] 토큰 사용량 집계 실패", e), ); // 9) 출력 필터·위기 전환 검사(§7.2, §7.3 ②) let reply = result.reply; let filtered = false; let crisisOut = false; const outputCheck = checkOutput(reply, config.filters, leakBody); if (outputCheck.action === "crisis") { reply = crisisReply(outputCheck.crisisType ?? "selfHarm", outputCheck.crisisUrgent); crisisOut = true; } else if (outputCheck.action === "filter" || result.finishReason === "filtered") { reply = FILTERED_REPLY; filtered = true; } else if (reply.length === 0) { reply = FILTERED_REPLY; filtered = true; } // 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지 saved = await finalizeExchange({ uid, threadId: activeThreadId, teamCode: activeTeamCode, clientMessageId, userContent: message, assistantContent: reply, model: config.provider.model, promptVersion: config.promptVersion, filtered, crisis: crisisOut, retentionDays: config.retentionDays, }); finalReply = reply; finalCrisis = crisisOut; } catch (err) { // 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미 // 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지). if (err instanceof HttpError && err.status === 422) throw err; await refundWithRetry(uid, clientMessageId); throw err; } // 11) 응답 반환 — 저장 성공 이후의 쿼터 재조회 실패는 복원 대상이 아니다(§6.2) return buildSendResult(uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt); } // ── GET /chat/messages(§3.2) ── interface Cursor { t: string; c: number; id: string; } function encodeCursor(c: Cursor): string { return Buffer.from(JSON.stringify(c), "utf8").toString("base64url"); } function decodeCursor(raw: string): Cursor | null { try { const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Cursor; if (typeof parsed.t !== "string" || typeof parsed.c !== "number" || typeof parsed.id !== "string") { return null; } return parsed; } catch { return null; } } export async function getMessages( uid: string, cursorRaw: string | undefined, limitRaw: string | undefined, ): Promise { const limit = Math.min(Math.max(Number(limitRaw ?? 30) || 30, 1), 50); const user = await getUser(uid); const threadId = resolveThreadId(user); // 커서의 threadId가 활성 스레드와 다르면(응원팀 변경) 커서 무시, 최신부터 재시작 const cursor = cursorRaw ? decodeCursor(cursorRaw) : null; const after = cursor && cursor.t === threadId ? { createdAtMs: cursor.c, messageId: cursor.id } : undefined; const page = await listMessagesPage({ uid, threadId, limit: limit + 1, after }); const hasMore = page.length > limit; const items = hasMore ? page.slice(0, limit) : page; const messages: ChatMessageView[] = items.map((m) => ({ messageId: m.messageId, role: m.role, content: m.content, crisis: m.crisis, createdAt: toKstIso(m.createdAt), ...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}), })); const last = items[items.length - 1]; return { messages, nextCursor: hasMore && last ? encodeCursor({ t: threadId, c: last.createdAt.toMillis(), id: last.messageId }) : null, hasMore, }; } // ── GET /chat/quota(§3.3) ── export async function getQuota(uid: string): Promise { const config = await getChatConfig(); const date = todayKst(); const quota = await getQuotaDoc(uid, date); const used = quota.used ?? 0; return { date, used, limit: config.dailyLimit, remaining: Math.max(0, config.dailyLimit - used), resetAt: kstResetAt(date), }; } // ── POST /chat/messages/{messageId}/report(§3.4) ── interface ReportBody { reason?: unknown; comment?: unknown; } export async function reportMessage( uid: string, messageId: string, body: ReportBody, ): Promise<{ reported: boolean }> { const reason = body.reason; if (typeof reason !== "string" || !CHAT_REPORT_REASONS.includes(reason as ChatReportReason)) { throw new HttpError(400, "invalid reason", "INVALID_REQUEST"); } const comment = body.comment; if (comment != null && (typeof comment !== "string" || comment.length > 200)) { throw new HttpError(400, "invalid comment", "INVALID_REQUEST"); } // 본인 스레드(이전 팀 스레드 포함)의 assistant 메시지만 신고 가능 const found = await findMessageAcrossThreads(uid, messageId); if (!found || found.message.role !== "assistant") { throw new HttpError(404, "message not found", "INVALID_REQUEST"); } await upsertReport(uid, messageId, reason as ChatReportReason, comment as string | undefined); return { reported: true }; } // ── GET /chat/suggestions(§3.5, 노출 규칙은 페르소나 문서 6.2) ── function dayNumber(date: DateString): number { return Math.floor(Date.parse(`${date}T00:00:00+09:00`) / 86_400_000); } function rotate(items: T[], offset: number): T[] { if (items.length === 0) return items; const k = offset % items.length; return [...items.slice(k), ...items.slice(0, k)]; } export async function getSuggestions(uid: string): Promise { const config = await getChatConfig(); const date = todayKst(); let candidates: ChatSuggestion[]; let priority: ChatSuggestion[] = []; try { const user = await getUser(uid); const ctx = await gatherUserContext(uid, user, config); candidates = config.suggestions.filter((s) => { if (s.requiresTeam && ctx.teamCode == null) return false; // 6.2 표의 "오늘 경기 없음 → Q7/Q11/Q12 제외"는 질문 문구("오늘 우리 경기")에 // 맞춰 "응원팀의 오늘 경기 유무"로 해석해 적용한다(리그 전체 기준보다 엄격) if (s.requiresTodayTeamGame && !ctx.hasTodayTeamGame) return false; if (s.requiresYesterdayRecap && !ctx.hasYesterdayRecap) return false; if (s.excludeWhenPredictedToday && ctx.hasPredictedToday) return false; return true; }); if (ctx.hasYesterdayRecap) { priority = candidates.filter((s) => s.priorityWhenRecap); } } catch (err) { // 추천 질문은 비핵심 — 컨텍스트 조회 실패 시 기본 3종으로 응답 console.warn("[chat] suggestions 컨텍스트 조회 실패 — 기본 풀 사용", err); candidates = config.suggestions.filter((s) => FALLBACK_SUGGESTION_IDS.includes(s.id)); } const day = dayNumber(date); const slots: ChatSuggestion[] = []; if (priority.length > 0) { // 어제 기록 있음 → Q5/Q9를 첫 슬롯에 우선 노출(날짜 로테이션) slots.push(rotate(priority, day)[0]); } const rest = rotate(candidates.filter((s) => !slots.includes(s)), day); for (const s of rest) { if (slots.length >= 3) break; slots.push(s); } return { suggestions: slots.map((s) => ({ id: s.id, text: s.text })), version: config.suggestionsVersion, }; }