- Firestore Timestamp가 res.json으로 그대로 나가 {_seconds,_nanoseconds}로 직렬화되던 문제 수정
- 앱의 포인트 내역(/reward/ledger)과 예측 기록(/stats/history) 크래시 원인 제거
- 클라 소비 7개 핸들러(reward/attendance/prediction/user/stats/kbo/chat)의 모든 응답에 명시적 DTO 타입 도입
- 날짜 와이어 형식을 순수 UTC ISO 8601(...Z)로 통일 — 채팅의 기존 KST(+09:00) 출력도 UTC로 전환
- DTO는 필드를 명시적으로 나열해 조립 (스프레드 덤프 제거) — 문서에 새 Timestamp 필드가 생겨도 다시 새지 않는다
- 재사용되는 주문 형태만 toOrderDto로 분리, 나머지는 응답 경계에서 직접 조립
- Firestore 저장 형식은 변경하지 않음. 출석 idempotent 재요청 경로는 저장된 Timestamp/문자열을 모두 처리
- DTO 직렬화 회귀 테스트 추가 (_seconds 부재, UTC ISO 형식, optional 키 생략, 미지 필드 차단)
587 lines
23 KiB
TypeScript
587 lines
23 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 { logChatEvent } from "./chatAnalyticsService";
|
|
import { extractNavActions } from "./chatNavService";
|
|
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
|
|
import { buildChatTools, withToolLabels } from "./chatToolService";
|
|
import {
|
|
EMPTY_REPLY_NOTICE, 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, type ChatToolCallInfo, type NavAction } from "../types/chat";
|
|
import type { User } from "../types/panit";
|
|
import { todayKst, type DateString } from "../types/dateString";
|
|
import { toIso } from "../types/dto/iso";
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/** 활성 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,
|
|
toolCalls?: ChatToolCallInfo[],
|
|
actions?: NavAction[],
|
|
): Promise<ChatSendResult> {
|
|
const { used } = await quotaView(uid, date);
|
|
return {
|
|
messageId,
|
|
reply,
|
|
crisis,
|
|
remainingCount: Math.max(0, config.dailyLimit - used),
|
|
limit: config.dailyLimit,
|
|
createdAt: toIso(createdAt),
|
|
...(toolCalls && toolCalls.length > 0 ? { toolCalls: withToolLabels(toolCalls) } : {}),
|
|
...(actions && actions.length > 0 ? { actions } : {}),
|
|
};
|
|
}
|
|
|
|
/** 멱등 재반환(§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,
|
|
stored.toolCalls, stored.actions,
|
|
);
|
|
}
|
|
|
|
/** 단기 문맥 윈도잉(§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;
|
|
// TODO(historyCutAt): 이 값을 set하는 코드가 아직 없어 항상 no-op(?? 0). stylePack/페르소나
|
|
// 교체 시점에 스레드 문서에 historyCutAt를 찍어야 활성화된다(§5.5 표시/입력 분리). 그전까지
|
|
// 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐.
|
|
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> {
|
|
const startedAt = Date.now(); // 관측성(#3) 지연 측정 기준
|
|
// 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) {
|
|
logChatEvent({
|
|
outcome: "rejected", uid, errorCode: "GLOBAL_LIMIT",
|
|
msgLen: message.length, latencyMs: Date.now() - startedAt,
|
|
});
|
|
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);
|
|
logChatEvent({
|
|
outcome: "input_blocked", uid, blockedCategory: inputCheck.category,
|
|
msgLen: message.length, latencyMs: Date.now() - startedAt,
|
|
});
|
|
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") {
|
|
logChatEvent({
|
|
outcome: "replay", uid, threadId: outcome.threadId,
|
|
msgLen: message.length, latencyMs: Date.now() - startedAt,
|
|
});
|
|
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;
|
|
}
|
|
logChatEvent({
|
|
outcome: "crisis_input", uid, teamCode: activeTeamCode, threadId: activeThreadId,
|
|
stylePack: config.stylePack, promptVersion: config.promptVersion,
|
|
crisis: true, crisisType: crisis.type,
|
|
msgLen: message.length, latencyMs: Date.now() - startedAt,
|
|
});
|
|
return buildSendResult(uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt);
|
|
}
|
|
|
|
let saved: ExchangeResult | null = null;
|
|
let finalReply = "";
|
|
let finalCrisis = false;
|
|
let finalToolCalls: ChatToolCallInfo[] = [];
|
|
let finalActions: NavAction[] = [];
|
|
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);
|
|
logChatEvent({
|
|
outcome: "input_blocked", uid, teamCode: activeTeamCode, threadId: activeThreadId,
|
|
blockedCategory: "moderation", msgLen: message.length, latencyMs: Date.now() - startedAt,
|
|
});
|
|
throw new HttpError(422, "input blocked (moderation)", "INPUT_BLOCKED", {
|
|
notice: INPUT_BLOCKED_NOTICE,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 8) AI Provider 호출(§8) — 예산 기반 재시도. 시점 의존 정보(라인업·로스터)는
|
|
// 상시 주입 대신 도구로 노출해 모델이 필요할 때만 조회하게 한다(미지원 provider는 무시)
|
|
await incrementGlobalUsage(date);
|
|
const tools = buildChatTools({ uid, teamCode: activeTeamCode, date });
|
|
let result;
|
|
try {
|
|
result = await callProviderWithBudget(provider, {
|
|
system, messages, config: config.provider, tools,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof HttpError) throw err;
|
|
console.error("[chat] provider 호출 실패", err);
|
|
logChatEvent({
|
|
outcome: "error", uid, teamCode: activeTeamCode, threadId: activeThreadId,
|
|
stylePack: config.stylePack, provider: config.provider.name, model: config.provider.model,
|
|
errorCode: "AI_UNAVAILABLE", msgLen: message.length, latencyMs: Date.now() - startedAt,
|
|
});
|
|
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 emptyReply = false; // 필터 아님 — 모델 빈 응답(도구 호출 뒤 무텍스트 등)·비정상 출력
|
|
let crisisOut = false;
|
|
let actions: NavAction[] = [];
|
|
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) {
|
|
// 검열이 아니므로 FILTERED_REPLY("전하기 어렵다")가 아닌 재시도 유도 문구를 쓴다
|
|
reply = EMPTY_REPLY_NOTICE;
|
|
emptyReply = true;
|
|
} else {
|
|
// 정상 모델 응답 — 화면 이동 마커([[NAV:route]])를 actions로 분리하고 본문에서 제거
|
|
const nav = extractNavActions(reply);
|
|
if (nav.clean.length === 0) {
|
|
reply = EMPTY_REPLY_NOTICE; // 마커만 있고 본문이 비는 비정상 출력 방어(필터 아님)
|
|
emptyReply = true;
|
|
} else {
|
|
reply = nav.clean;
|
|
actions = nav.actions;
|
|
}
|
|
}
|
|
|
|
// 도구 호출·이동 액션 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 응답은 제외)
|
|
const replaced = crisisOut || filtered || emptyReply;
|
|
const toolCalls = replaced ? [] : result.toolCalls;
|
|
|
|
// 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지
|
|
saved = await finalizeExchange({
|
|
uid,
|
|
threadId: activeThreadId,
|
|
teamCode: activeTeamCode,
|
|
clientMessageId,
|
|
userContent: message,
|
|
assistantContent: reply,
|
|
model: config.provider.model,
|
|
promptVersion: config.promptVersion,
|
|
// 저장 filtered는 "응답이 대체 문구로 교체됨" 표지 — 히스토리 윈도잉(§5.5)이
|
|
// 이 플래그로 교체 응답을 문맥에서 제외하므로 빈 응답 폴백도 포함시킨다.
|
|
filtered: filtered || emptyReply,
|
|
crisis: crisisOut,
|
|
toolCalls,
|
|
actions,
|
|
retentionDays: config.retentionDays,
|
|
});
|
|
finalReply = reply;
|
|
finalCrisis = crisisOut;
|
|
finalToolCalls = toolCalls;
|
|
finalActions = actions;
|
|
logChatEvent({
|
|
outcome: crisisOut ? "crisis_output" : filtered ? "filtered" : emptyReply ? "empty" : "ok",
|
|
uid, teamCode: activeTeamCode, threadId: activeThreadId,
|
|
stylePack: config.stylePack, knowledgeLevel: ctx.knowledgeLevel, promptVersion: config.promptVersion,
|
|
provider: config.provider.name, model: config.provider.model,
|
|
finishReason: result.finishReason,
|
|
crisis: crisisOut, crisisType: crisisOut ? (outputCheck.crisisType ?? "selfHarm") : undefined,
|
|
filtered: filtered || emptyReply,
|
|
filterReason: outputCheck.action === "filter" ? outputCheck.reason : undefined,
|
|
toolNames: result.toolCalls.map((t) => t.name),
|
|
navRoutes: actions.map((a) => a.route),
|
|
tokensIn: result.usage.inputTokens,
|
|
tokensOut: result.usage.outputTokens,
|
|
tokensCached: result.usage.cachedTokens,
|
|
latencyMs: Date.now() - startedAt, msgLen: message.length,
|
|
});
|
|
} 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, finalToolCalls, finalActions,
|
|
);
|
|
}
|
|
|
|
// ── 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: toIso(m.createdAt),
|
|
...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
|
|
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: withToolLabels(m.toolCalls) } : {}),
|
|
...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}),
|
|
}));
|
|
|
|
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,
|
|
};
|
|
}
|