mmday-firebase/src/services/chatService.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

500 lines
18 KiB
TypeScript

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<ChatSendResult> {
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<ChatSendResult> {
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<ChatProviderMessage[]> {
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<string>();
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<string>();
/** `POST /chat/messages`(§3.1). */
export async function sendMessage(uid: string, body: SendBody): Promise<ChatSendResult> {
// 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<ChatMessagesPage> {
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<ChatQuotaView> {
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<T>(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<ChatSuggestionsView> {
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,
};
}