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