From d2df63e63c44be5bb37d1a387a64e8715e7c593f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:27:10 +0900 Subject: [PATCH] Trim chat context and history reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gatherUserContext 제거 — 메시지마다 6개 소스를 병렬 조회했지만 USER_CONTEXT_TEMPLATE은 날짜·닉네임·응원팀 3개 필드만 쓴다. 시사 데이터를 도구 조회로 옮기면서 선주입 블록만 지우고 fetch는 남아 있던 것으로, 메시지당 33~64 read가 전량 폐기되고 있었다 - identityContext(Firestore read 0, 이미 로드한 user 문서만 사용)와 gatherSuggestionFlags(추천 질문 전용, 응원팀 미설정 시 일정 조회도 생략)로 분리. 소비되지 않던 필드와 fetchRecentTeamGames·formatRecentResults·formatTodaySchedule 삭제 - fetchScheduleMonth가 리필 직후 전 키(28~31 doc)를 재읽기하던 것을 byDate 메모리 병합으로 교체. loadMonthDayCache를 추출해 단일일 조회도 월 리필 결과를 재사용하게 하고, live 병합을 호출자에서 1회만 수행해 이중 적용을 막았다 - loadHistory를 2단계 페치로 변경. 위기 요청은 일일 한도를 우회하므로 위기 교환쌍 수에 상한이 없고, 그 쌍은 문서 2개를 먹고 윈도잉에서 둘 다 빠진다. 1차 페이지가 다 찼는데도 목표 턴에 못 미칠 때만 한 번 넓혀 재조회한다(평시 추가 쿼리 0회) - 예약 트랜잭션이 계산한 used를 ReserveOutcome으로 돌려줘 응답 조립의 쿼터 재조회 제거(트랜잭션이 없는 replay·GET /chat/quota는 기존 read 유지) - loadHistory가 threadExists를 반환해 저장 단계로 전달 — 같은 요청에서 thread 문서를 두 번 읽던 것 제거(위기 경로는 loadHistory를 안 거치므로 optional) - upsertReport를 create 후 ALREADY_EXISTS 폴백 merge로 전환해 최초 신고의 사전 read 제거 --- src/repositories/chatRepository.ts | 46 +++- src/repositories/kboRepository.ts | 47 +++- src/services/chatContextService.ts | 247 ++++++---------------- src/services/chatProbeService.ts | 4 +- src/services/chatService.ts | 110 +++++++--- tests/services/chatContextService.test.ts | 60 +----- tests/services/chatService.test.ts | 54 +++++ 7 files changed, 278 insertions(+), 290 deletions(-) diff --git a/src/repositories/chatRepository.ts b/src/repositories/chatRepository.ts index 12e5ae9..6ffdbfe 100644 --- a/src/repositories/chatRepository.ts +++ b/src/repositories/chatRepository.ts @@ -2,7 +2,7 @@ import { randomBytes, createHash } from "node:crypto"; import { FieldPath, Timestamp } from "firebase-admin/firestore"; import { ServerValue } from "firebase-admin/database"; import { firestore, rtdb } from "../firebase"; -import { HttpError } from "../middleware/errors"; +import { HttpError, isAlreadyExistsError } from "../middleware/errors"; import { MemCache } from "../lib/memCache"; import type { ChatMessageDoc, @@ -98,8 +98,11 @@ export interface ReserveParams { export type ReserveOutcome = | { kind: "done"; assistantMessageId: string; threadId: string } - /** threadId는 pin된 값 — 크래시 재개 시 원래 예약의 스레드를 그대로 쓴다(§3.1 처리 5). */ - | { kind: "reserved"; threadId: string; debited: boolean; resumed: boolean }; + /** + * threadId는 pin된 값 — 크래시 재개 시 원래 예약의 스레드를 그대로 쓴다(§3.1 처리 5). + * `used`는 차감 반영 후 값 — 응답 조립이 쿼터 문서를 다시 읽지 않도록 함께 돌려준다. + */ + | { kind: "reserved"; threadId: string; debited: boolean; resumed: boolean; used: number }; /** * 멱등 예약·레이트리밋·한도 차감을 단일 Firestore 트랜잭션으로 원자 수행한다. @@ -136,7 +139,9 @@ export async function reserveRequestTx(params: ReserveParams): Promise).used ?? 0; + return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true, used: keptUsed }; } const resumeQuota = (quotaSnap.data() ?? {}) as Partial; const resumeUsed = resumeQuota.used ?? 0; @@ -152,7 +157,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise; @@ -208,7 +213,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise { const ref = firestore.collection(REPORTS).doc(`${uid}_${messageId}`); const now = Timestamp.now(); - const existing = await ref.get(); const doc: ChatReportDoc = { uid, messageId, reason, ...(comment ? { comment } : {}), status: "open", - createdAt: existing.exists ? (existing.data() as ChatReportDoc).createdAt : now, + createdAt: now, updatedAt: now, }; - await ref.set(doc); + // 신규면 create가 성공하고, 재신고면 ALREADY_EXISTS로 떨어져 merge 갱신한다. + // createdAt 보존을 위해 사전 read를 하던 것을 대체한다 — 최초 신고는 read 0회. + try { + await ref.create(doc); + } catch (err) { + if (!isAlreadyExistsError(err)) throw err; + // 재신고는 createdAt을 건드리지 않는다 — 최초 신고 시각을 보존. + await ref.set({ + uid, + messageId, + reason, + ...(comment ? { comment } : {}), + status: "open", + updatedAt: now, + }, { merge: true }); + } } // ── 전역 호출·토큰 카운터(RTDB, §8.3) ── diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 3c90344..5a5d9b0 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -376,14 +376,18 @@ async function fetchScheduleSingleDay( let cached = (await readDayDocs([key])).get(key) ?? null; if (cached === null) { - // 미스 → 월 단위 외부 fetch 흐름이 캐시를 채우도록 호출. 결과는 사용하지 않음. - await fetchScheduleMonth({ + // 미스 → 월 단위 리필. 리필 결과를 그대로 재사용해 day doc 재읽기를 없앤다. + // loadMonthDayCache는 live 병합 전 원본을 돌려주므로 아래 merge가 이중 적용되지 않는다. + const load = await loadMonthDayCache({ year: filters.year, month: filters.month, team: filters.team, series: filters.series, }); - cached = (await readDayDocs([key])).get(key) ?? []; + cached = + load.kind === "bypass" ? + load.games.filter((g) => gameDateToYmd(filters.year, g.date) === ymd) : + load.cached.get(key) ?? []; } const merged = await mergeLiveIntoSchedule(ymd, cached, filters.series); @@ -396,9 +400,19 @@ async function fetchScheduleSingleDay( }; } -async function fetchScheduleMonth( +type MonthDayCacheLoad = + | { kind: "cache"; allDays: string[]; cached: Map } + | { kind: "bypass"; games: ScheduleGame[] }; + +/** + * 월 단위 day 캐시를 확보한다(미스가 있으면 외부 fetch로 리필). + * + * live 병합 **전** 원본을 돌려준다 — 호출자가 필요한 범위에만 한 번 병합하도록 + * 해서 월/일 경로가 서로 다른 병합 횟수를 갖는 문제를 막는다. + */ +async function loadMonthDayCache( filters: ScheduleFilters -): Promise { +): Promise { const allDays = enumerateMonthDays(filters.year, filters.month); const keys = allDays.map((d) => dayKey(d, filters.team, filters.series)); @@ -436,7 +450,11 @@ async function fetchScheduleMonth( }) ); - cached = await readDayDocs(keys); + // 방금 쓴 내용은 byDate에 그대로 있다 — 전 키(28~31 doc) 재읽기 대신 + // 메모리에서 병합한다. 재읽기가 새로 가져오는 정보는 없다. + for (const ymd of missing) { + cached.set(dayKey(ymd, filters.team, filters.series), byDate.get(ymd) ?? []); + } } finally { await releaseLock(lockKey); } @@ -451,15 +469,26 @@ async function fetchScheduleMonth( } if (missing.length > 0) { // 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함). - return fetchSchedule(filters); + return { kind: "bypass", games: (await fetchSchedule(filters)).games }; } } } + return { kind: "cache", allDays, cached }; +} + +async function fetchScheduleMonth( + filters: ScheduleFilters +): Promise { + const load = await loadMonthDayCache(filters); + if (load.kind === "bypass") { + return { year: filters.year, month: filters.month, games: load.games }; + } + const games: ScheduleGame[] = []; const today = todayKst().replace(/-/g, ""); - for (const ymd of allDays) { - const list = cached.get(dayKey(ymd, filters.team, filters.series)) ?? []; + for (const ymd of load.allDays) { + const list = load.cached.get(dayKey(ymd, filters.team, filters.series)) ?? []; if (ymd === today) { const merged = await mergeLiveIntoSchedule(ymd, list, filters.series); games.push(...merged); diff --git a/src/services/chatContextService.ts b/src/services/chatContextService.ts index 1e24c64..6567ad0 100644 --- a/src/services/chatContextService.ts +++ b/src/services/chatContextService.ts @@ -1,13 +1,10 @@ import { getSchedule } from "./scheduleService"; -import { getRank } from "./rankService"; -import { getStats } from "./statsService"; import { getUserDateVotes } from "../repositories/voteRepository"; import { getDay } from "../repositories/voteHistoryRepository"; import { COMMON_SYSTEM_PROMPT, DEFAULT_PERSONA_BLOCK, DEFAULT_TEAM_PERSONAS, - KBO_RANK_TEAM_NAMES, KNOWLEDGE_GUIDANCE, SERVER_DIRECTIVE_BLOCK, TEAM_DISPLAY_NAMES, @@ -63,21 +60,24 @@ export function sanitizeDisplayName(raw: unknown): string { // ── 컨텍스트 데이터 수집 ── -export interface UserContext { +/** + * 프롬프트 조립에 필요한 정체성 컨텍스트. + * + * 전량 `users/{uid}` 문서에서 파생되므로 조립 경로의 Firestore 조회는 0회다. + * 매치업·선발·순위·예측·전적·통계는 컨텍스트 블록에 선주입하지 않고 도구로 + * 조회하므로(2.2 경량판), 여기에 시사 데이터를 다시 넣지 말 것. + */ +export interface IdentityContext { date: DateString; displayName: string; knowledgeLevel: KnowledgeLevel; teamCode: TeamCode | null; teamName: string | null; - todaySchedule: string; - todayMyPredictions: string; - yesterdayRecap: string; - /** 응원팀 미설정 시 null → 줄 생략. */ - recentTeamResults: string | null; - /** 오늘 응원팀 경기 없음/미설정 시 null → 줄 생략. */ - h2hRecords: string | null; - myStats: string; - /** 추천 질문 노출 조건(§6.2) 공용 플래그. */ +} + +/** 추천 질문 노출 조건(§6.2) 플래그 — GET /chat/suggestions 전용. */ +export interface SuggestionFlags { + teamCode: TeamCode | null; hasYesterdayRecap: boolean; hasTodayTeamGame: boolean; hasPredictedToday: boolean; @@ -92,178 +92,65 @@ async function safely(label: string, fallback: T, task: () => Promise): Pr } } -function matchupLabel(g: ScheduleGame): string { - return `${g.awayTeamCode} vs ${g.homeTeamCode}`; -} - /** - * 오늘 경기 일정 포맷(2.2 {{todaySchedule}}). - * `live`는 확정 필드만 주입하고 스코어는 제거, `completed`는 스코어 포함, `cancelled`는 취소 라벨. + * 정체성 컨텍스트 구성 — Firestore 조회 없음. + * + * 호출자가 이미 로드한 `users/{uid}` 문서만으로 구성된다. 전송 경로(§3.1)와 + * 프로브가 공유한다. */ -export function formatTodaySchedule(games: ScheduleGame[]): string { - if (games.length === 0) return "오늘 경기 없음"; - const lines = games.map((g) => { - const pitchers = - g.awayStartingPitcher || g.homeStartingPitcher ? - ` 선발 ${g.awayStartingPitcher?.name ?? "미정"} vs ${g.homeStartingPitcher?.name ?? "미정"}` : - ""; - const base = `${matchupLabel(g)} ${g.time} ${g.stadium}${pitchers}`; - switch (g.status) { - case "completed": - return `${base} — 종료 ${g.awayScore ?? "?"}:${g.homeScore ?? "?"}`; - case "live": - return `${base} — 진행 중(스코어 미제공)`; - case "cancelled": - return `${base} — 취소${g.note ? `(${g.note})` : ""}`; - default: - return `${base} — 예정`; - } - }); - return lines.join(" / "); -} - -function formatRecentResults(team: TeamCode, games: ScheduleGame[]): string { - const completed = games.filter( - (g) => - g.status === "completed" && - g.awayScore != null && - g.homeScore != null && - (g.awayTeamCode === team || g.homeTeamCode === team), - ); - if (completed.length === 0) return "최근 경기 정보 없음"; - const recent = completed.slice(-5); - const lines = recent.map((g) => { - const isAway = g.awayTeamCode === team; - const my = isAway ? g.awayScore as number : g.homeScore as number; - const opp = isAway ? g.homeScore as number : g.awayScore as number; - const oppCode = isAway ? g.homeTeamCode : g.awayTeamCode; - const result = my > opp ? "승" : my < opp ? "패" : "무"; - return `${g.date} vs ${oppCode} ${my}:${opp} ${result}`; - }); - return lines.join(", "); -} - -/** - * 응원팀 최근 경기 수집 — 당월 완료 경기가 5건 미만이면 전월을 보충 조회한다 - * (월초에 "최근 5경기"가 비는 것을 방지, 페르소나 문서 2.2 {{recentTeamResults}}). - */ -async function fetchRecentTeamGames(y: number, m: number, team: TeamCode): Promise { - const current = (await getSchedule(y, m, team)).games; - const completed = current.filter((g) => g.status === "completed").length; - if (completed >= 5) return current; - const prevY = m === 1 ? y - 1 : y; - const prevM = m === 1 ? 12 : m - 1; - try { - const prev = (await getSchedule(prevY, prevM, team)).games; - return [...prev, ...current]; - } catch { - return current; // 전월 보충 실패는 당월만으로 degrade - } -} - -/** 전체 사용자 컨텍스트를 병렬 수집한다. 각 항목 실패는 부재 표기로 대체된다. */ -export async function gatherUserContext( - uid: string, - user: User | null, - config?: ChatConfig, -): Promise { - const date = todayKst(); - const [y, m, d] = date.split("-").map(Number); +export function identityContext(user: User | null, config?: ChatConfig): IdentityContext { const teamCode = resolveTeamCode(user?.favoriteTeamCode); - const knowledgeLevel = resolveKnowledgeLevel(user?.knowledgeLevel); - const displayName = sanitizeDisplayName(user?.displayName); - - const [todayGames, myVotes, recapDoc, teamMonthGames, rankResults, stats] = await Promise.all([ - safely("todaySchedule", [], async () => (await getSchedule(y, m, undefined, undefined, d)).games), - safely>("todayMyPredictions", {}, () => getUserDateVotes(uid, date)), - safely("yesterdayRecap", null, async () => - user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null, - ), - safely("recentTeamResults", [], async () => - teamCode ? fetchRecentTeamGames(y, m, teamCode) : [], - ), - safely("h2hRecords", null, async () => (teamCode ? getRank([y]) : null)), - safely("myStats", null, () => getStats(uid, "current")), - ]); - - // 오늘 내 예측 — 매치업 라벨로 표기(gameId 단독보다 모델이 읽기 좋다) - const voteEntries = Object.entries(myVotes); - const gameById = new Map(todayGames.filter((g) => g.gameId).map((g) => [g.gameId as string, g])); - const todayMyPredictions = - voteEntries.length === 0 ? - "오늘 예측 없음" : - voteEntries - .map(([gameId, v]) => { - const g = gameById.get(gameId); - return g ? `${matchupLabel(g)}: ${v.team} 선택` : `${gameId}: ${v.team} 선택`; - }) - .join(", "); - - // 어제(최근 채점일) 예측 결과 — 서버 채점 결과(result)를 그대로 사용, 재계산 금지 - let yesterdayRecap = "어제 예측 기록 없음"; - let hasYesterdayRecap = false; - if (recapDoc && Array.isArray(recapDoc.data) && recapDoc.data.length > 0) { - hasYesterdayRecap = true; - const correct = recapDoc.data.filter((e) => e.result === true).length; - const detail = recapDoc.data - .map((e) => `${e.team} 선택 → ${e.result ? "적중" : "오답"}`) - .join(", "); - yesterdayRecap = `${user?.lastJudgedDate ?? ""} 기준 ${correct}/${recapDoc.data.length} 적중 (${detail})`; - } - - // 응원팀 최근 5경기(completed만) - const recentTeamResults = teamCode ? formatRecentResults(teamCode, teamMonthGames) : null; - - // 오늘 상대팀과의 시즌 상대 전적(vsRecords) — 오늘 응원팀 경기 없으면 줄 생략 - let h2hRecords: string | null = null; - let hasTodayTeamGame = false; - if (teamCode) { - const todayTeamGame = todayGames.find( - (g) => g.awayTeamCode === teamCode || g.homeTeamCode === teamCode, - ); - hasTodayTeamGame = todayTeamGame != null && todayTeamGame.status !== "cancelled"; - // 취소된 경기는 "오늘 경기 없음"과 동일하게 취급 — h2h도 주입하지 않는다 - if (todayTeamGame && hasTodayTeamGame && rankResults && rankResults.length > 0) { - const opponentCode = todayTeamGame.awayTeamCode === teamCode ? - todayTeamGame.homeTeamCode : - todayTeamGame.awayTeamCode; - const myName = KBO_RANK_TEAM_NAMES[teamCode]; - const oppName = KBO_RANK_TEAM_NAMES[opponentCode as TeamCode]; - const record = rankResults[0].vsRecords.find((r) => r.team === myName); - const wld = oppName ? record?.headToHead[oppName] : undefined; - if (wld) { - h2hRecords = `vs ${oppName} 시즌 ${wld.wins}승 ${wld.losses}패 ${wld.draws}무`; - } - } - } - - // 내 통계 - let myStats = "통계 없음"; - if (stats) { - const pct = (v: number) => `${Math.round(v * 100)}%`; - myStats = - `연속 참여 ${stats.streakDays}일, 적중률 전체 ${pct(stats.winRates.overall)} / ` + - `주간 ${pct(stats.winRates.weekly)} / 월간 ${pct(stats.winRates.monthly)}`; - } - return { - date, - displayName, - knowledgeLevel, + date: todayKst(), + displayName: sanitizeDisplayName(user?.displayName), + knowledgeLevel: resolveKnowledgeLevel(user?.knowledgeLevel), teamCode, // 표기명은 config로 강등(닉네임 전환) 가능 — KBO 라이선스 미확보 대비(1-2) teamName: teamCode ? config?.teamDisplayNames?.[teamCode] ?? TEAM_DISPLAY_NAMES[teamCode] : null, - todaySchedule: formatTodaySchedule(todayGames), - todayMyPredictions, - yesterdayRecap, - recentTeamResults, - h2hRecords, - myStats, - hasYesterdayRecap, - hasTodayTeamGame, - hasPredictedToday: voteEntries.length > 0, + }; +} + +/** + * 추천 질문 노출 플래그 수집(§6.2) — GET /chat/suggestions 전용. + * + * 응원팀 미설정이면 오늘 일정은 어떤 플래그에도 영향을 줄 수 없으므로 조회를 + * 건너뛴다. 각 항목 실패는 플래그 false로 degrade된다(추천 질문은 비핵심). + */ +export async function gatherSuggestionFlags( + uid: string, + user: User | null, +): Promise { + const date = todayKst(); + const [y, m, d] = date.split("-").map(Number); + const teamCode = resolveTeamCode(user?.favoriteTeamCode); + + const [todayGames, myVotes, recapDoc] = await Promise.all([ + teamCode ? + safely("todaySchedule", [], async () => + (await getSchedule(y, m, undefined, undefined, d)).games, + ) : + Promise.resolve([]), + safely>("todayMyPredictions", {}, () => + getUserDateVotes(uid, date), + ), + safely("yesterdayRecap", null, async () => + user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null, + ), + ]); + + const todayTeamGame = teamCode ? + todayGames.find((g) => g.awayTeamCode === teamCode || g.homeTeamCode === teamCode) : + undefined; + + return { + teamCode, + // 취소된 경기는 "오늘 경기 없음"과 동일하게 취급한다 + hasTodayTeamGame: todayTeamGame != null && todayTeamGame.status !== "cancelled", + hasYesterdayRecap: + recapDoc != null && Array.isArray(recapDoc.data) && recapDoc.data.length > 0, + hasPredictedToday: Object.keys(myVotes).length > 0, }; } @@ -278,10 +165,10 @@ function fill(template: string, vars: Record): string { } /** - * [블록 3] 사용자 컨텍스트 블록(경량판) — 정체성 + 오늘 경기 유무 플래그만. + * [블록 3] 사용자 컨텍스트 블록(경량판) — 정체성만. * 매치업·선발·순위·예측·전적·통계는 도구로 조회하므로 여기서 선주입하지 않는다. */ -export function buildUserContextBlock(ctx: UserContext): string { +export function buildUserContextBlock(ctx: IdentityContext): string { return fill(USER_CONTEXT_TEMPLATE, { todayDate: ctx.date, displayName: ctx.displayName, @@ -333,7 +220,7 @@ export interface AssembledPrompt { * 맨 끝에 서버 지시(위기 마커·카나리 — 기술 설계 §7.3 ②)를 덧붙인다. * 사용자 입력은 여기에 절대 이어붙이지 않는다 — user 롤로만 전달(§7.5). */ -export function assemblePrompt(config: ChatConfig, ctx: UserContext): AssembledPrompt { +export function assemblePrompt(config: ChatConfig, ctx: IdentityContext): AssembledPrompt { const style = resolveStylePack(config.stylePack); const common = config.systemPromptCommon.trim().length > 0 ? config.systemPromptCommon : @@ -353,6 +240,6 @@ export function assemblePrompt(config: ChatConfig, ctx: UserContext): AssembledP } /** 조립된 시스템 프롬프트 문자열만 필요할 때의 단축형. */ -export function assembleSystemPrompt(config: ChatConfig, ctx: UserContext): string { +export function assembleSystemPrompt(config: ChatConfig, ctx: IdentityContext): string { return assemblePrompt(config, ctx).system; } diff --git a/src/services/chatProbeService.ts b/src/services/chatProbeService.ts index 8038f5a..d9b398b 100644 --- a/src/services/chatProbeService.ts +++ b/src/services/chatProbeService.ts @@ -2,7 +2,7 @@ import { getChatConfig, invalidateChatConfigCache } from "./chatConfigService"; import { getUser } from "../repositories/userRepository"; import { assemblePrompt, - gatherUserContext, + identityContext, resolveTeamCode, } from "./chatContextService"; import { buildChatTools, type ChatToolContext } from "./chatToolService"; @@ -112,7 +112,7 @@ async function buildProbeEnv( } const teamCode = resolveTeamCode(user.favoriteTeamCode); const date = todayKst(); - const ctx = await gatherUserContext(uid, user, config); + const ctx = identityContext(user, config); const { system } = assemblePrompt(config, ctx); const provider = getChatProvider(config.provider); const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)"; diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 209b0cf..3a03e0e 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -30,7 +30,13 @@ import { import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService"; import { logChatEvent } from "./chatAnalyticsService"; import { extractNavActions } from "./chatNavService"; -import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService"; +import { + assemblePrompt, + gatherSuggestionFlags, + identityContext, + resolveTeamCode, + type IdentityContext, +} from "./chatContextService"; import { buildChatTools, withToolLabels } from "./chatToolService"; import { EMPTY_REPLY_NOTICE, FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE, @@ -78,6 +84,11 @@ async function quotaView(uid: string, date: DateString): Promise<{ used: number return { used: quota.used ?? 0 }; } +/** + * @param usedOverride 예약 트랜잭션이 이미 계산한 차감 후 `used`. 넘기면 쿼터 + * 문서를 다시 읽지 않는다. 트랜잭션이 돌지 않는 경로(replay, GET /chat/quota)는 + * 생략해서 조회하게 둔다. + */ async function buildSendResult( uid: string, date: DateString, @@ -88,8 +99,9 @@ async function buildSendResult( createdAt: Timestamp, toolCalls?: ChatToolCallInfo[], actions?: NavAction[], + usedOverride?: number, ): Promise { - const { used } = await quotaView(uid, date); + const used = usedOverride ?? (await quotaView(uid, date)).used; return { messageId, reply, @@ -121,14 +133,23 @@ async function replayDone( ); } -/** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */ +/** + * 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. + * + * `threadExists`를 함께 돌려준다 — 저장 단계(`finalizeExchange`)가 `createdAt` + * 보존 판단을 위해 같은 문서를 다시 읽지 않도록. + */ async function loadHistory( uid: string, threadId: string, config: ChatConfig, -): Promise { - const fetchLimit = config.historyTurns * 2 + 30; - const [thread, recentDesc] = await Promise.all([ +): Promise<{ messages: ChatProviderMessage[]; threadExists: boolean }> { + const target = config.historyTurns * 2; + // 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에는 상한이 없다. + // 위기 쌍은 문서 2개를 차지하고 윈도잉에서 둘 다 빠지므로, 고정 padding은 + // 유효한 상한이 될 수 없다. 평시에는 작게 읽고, 실제로 모자랄 때만 한 번 넓힌다. + const fetchLimit = target + 10; + const [thread, firstPage] = await Promise.all([ getThreadDoc(uid, threadId), getRecentMessages(uid, threadId, fetchLimit), ]); @@ -138,26 +159,38 @@ async function loadHistory( // 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐. const cutAt = thread?.historyCutAt?.toMillis() ?? 0; - const asc = [...recentDesc].reverse(); + /** 나이·cut·위기/필터 제외를 적용해 사용 가능한 메시지만 시간순으로 남긴다. */ + const window = (desc: MessageWithId[]): MessageWithId[] => { + const asc = [...desc].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); + } + return 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; + }); + }; - // 위기 교환 쌍 제외 — 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); + let windowed = window(firstPage); + // 가져온 만큼을 다 채웠는데도 목표에 못 미친다 = 제외된 분량 때문에 잘렸을 수 + // 있다는 뜻. 더 오래된 유효 메시지가 남아 있을 수 있으므로 한 번만 넓혀 재조회한다. + // (스레드가 원래 짧아서 못 채운 경우에는 firstPage가 fetchLimit보다 작아 재조회하지 않는다.) + if (windowed.length < target && firstPage.length === fetchLimit) { + windowed = window(await getRecentMessages(uid, threadId, target + 40)); } - 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); + const lastN = windowed.slice(-target); // provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거 while (lastN.length > 0 && lastN[0].role === "assistant") lastN.shift(); - return lastN.map((m) => ({ role: m.role, content: m.content })); + return { + messages: lastN.map((m) => ({ role: m.role, content: m.content })), + threadExists: thread != null, + }; } /** 비용 가드 80% 운영 알림(§8.3) — 인스턴스·날짜당 1회만 경고. */ @@ -277,20 +310,30 @@ export async function sendMessage(uid: string, body: SendBody): Promise let priority: ChatSuggestion[] = []; try { const user = await getUser(uid); - const ctx = await gatherUserContext(uid, user, config); + const flags = await gatherSuggestionFlags(uid, user); candidates = config.suggestions.filter((s) => { - if (s.requiresTeam && ctx.teamCode == null) return false; + if (s.requiresTeam && flags.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; + if (s.requiresTodayTeamGame && !flags.hasTodayTeamGame) return false; + if (s.requiresYesterdayRecap && !flags.hasYesterdayRecap) return false; + if (s.excludeWhenPredictedToday && flags.hasPredictedToday) return false; return true; }); - if (ctx.hasYesterdayRecap) { + if (flags.hasYesterdayRecap) { priority = candidates.filter((s) => s.priorityWhenRecap); } } catch (err) { diff --git a/tests/services/chatContextService.test.ts b/tests/services/chatContextService.test.ts index 083c7f7..ee66916 100644 --- a/tests/services/chatContextService.test.ts +++ b/tests/services/chatContextService.test.ts @@ -1,56 +1,25 @@ import { describe, expect, it } from "vitest"; import { buildUserContextBlock, - formatTodaySchedule, resolveKnowledgeLevel, resolvePersonaBlock, resolveTeamCode, sanitizeDisplayName, - type UserContext, + type IdentityContext, } from "../../src/services/chatContextService"; import { assembleSystemPrompt } from "../../src/services/chatContextService"; import { DEFAULT_CHAT_CONFIG } from "../../src/services/chatConfigService"; import { CHAT_CANARY_TOKEN } from "../../src/constants/chatPrompts"; import { KnowledgeLevel, TeamCode } from "../../src/types/panit"; import type { DateString } from "../../src/types/dateString"; -import type { ScheduleGame } from "../../src/types/kbo"; -function game(overrides: Partial): ScheduleGame { - return { - date: "06.12", - dayOfWeek: "금", - time: "18:30", - awayTeamCode: "LG", - homeTeamCode: "HH", - awayScore: null, - homeScore: null, - status: "scheduled", - stadium: "대전", - broadcast: "", - note: "", - gameId: "20260612LGHH0", - awayStartingPitcher: { id: 1, name: "김선발" }, - homeStartingPitcher: { id: 2, name: "박선발" }, - ...overrides, - }; -} - -function ctx(overrides: Partial): UserContext { +function ctx(overrides: Partial): IdentityContext { return { date: "2026-06-12" as DateString, displayName: "솔방울", knowledgeLevel: KnowledgeLevel.Casual, teamCode: TeamCode.HH, teamName: "한화 이글스", - todaySchedule: "오늘 경기 없음", - todayMyPredictions: "오늘 예측 없음", - yesterdayRecap: "어제 예측 기록 없음", - recentTeamResults: null, - h2hRecords: null, - myStats: "통계 없음", - hasYesterdayRecap: false, - hasTodayTeamGame: false, - hasPredictedToday: false, ...overrides, }; } @@ -80,29 +49,6 @@ describe("chatContextService", () => { }); }); - describe("formatTodaySchedule — 결정된 사항만(2-1)", () => { - it("진행 중 경기는 확정 필드만 주입하고 스코어를 제거한다", () => { - const out = formatTodaySchedule([game({ status: "live", awayScore: 3, homeScore: 5 })]); - expect(out).toContain("진행 중(스코어 미제공)"); - expect(out).not.toContain("3:5"); // 스코어 미주입 - expect(out).toContain("김선발"); // 선발 예고는 확정 정보 — 주입 - }); - - it("종료 경기는 스코어를 포함한다", () => { - const out = formatTodaySchedule([game({ status: "completed", awayScore: 2, homeScore: 7 })]); - expect(out).toContain("2:7"); - expect(out).toContain("종료"); - }); - - it("취소 경기는 취소 라벨로 표기한다", () => { - expect(formatTodaySchedule([game({ status: "cancelled", note: "우천취소" })])).toContain("취소"); - }); - - it("경기 없으면 부재 표기를 쓴다", () => { - expect(formatTodaySchedule([])).toBe("오늘 경기 없음"); - }); - }); - describe("buildUserContextBlock(2.2 템플릿)", () => { it("고정 구분자로 감싸고 응원팀 미설정·선택 줄 생략을 적용한다", () => { const block = buildUserContextBlock(ctx({ teamCode: null, teamName: null })); @@ -114,7 +60,7 @@ describe("chatContextService", () => { }); it("정체성(닉네임·응원팀)만 주입하고 시사 수치·일정은 넣지 않는다(경량판)", () => { - const block = buildUserContextBlock(ctx({ hasTodayTeamGame: true })); + const block = buildUserContextBlock(ctx({})); expect(block).toContain("- 사용자: 솔방울"); expect(block).not.toContain("오늘"); // 오늘 경기·일정은 컨텍스트에 없음(도구로) expect(block).not.toContain("최근 5경기"); diff --git a/tests/services/chatService.test.ts b/tests/services/chatService.test.ts index 079f579..8d6506d 100644 --- a/tests/services/chatService.test.ts +++ b/tests/services/chatService.test.ts @@ -617,6 +617,60 @@ describe("chatService", () => { expect(contents).toEqual(["어제 경기 봤어?", "봤지! 짜릿했어", "오늘은 어때?"]); expect(history[0].role).toBe("user"); }); + + /** + * 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에 상한이 없다. + * 위기 쌍은 문서 2개를 먹고 윈도잉에서 둘 다 빠지므로, 1차 페이지가 위기 문서로 + * 채워지면 나이 창 안에 유효 메시지가 남아 있는데도 턴 수가 모자라게 된다. + */ + it("위기 쌍이 1차 페이지를 채워도 목표 턴 수를 유지한다", async () => { + const col = messagesCol(uid, "HH"); + const now = Date.now(); + const mk = ( + id: string, + role: "user" | "assistant", + content: string, + atMs: number, + flags: Partial = {}, + ) => + col.doc(id).set({ + role, + content, + createdAt: Timestamp.fromMillis(atMs), + filtered: false, + crisis: false, + expireAt: Timestamp.fromMillis(atMs + 1000_000), + ...flags, + }); + + // 유효 대화 10쌍(20 doc) — 60분 전부터 42분 전까지 + for (let i = 0; i < 10; i++) { + const at = now - (60 - i * 2) * 60_000; + await mk(`v${i}u`, "user", `유효질문${i}`, at); + await mk(`v${i}a`, "assistant", `유효답변${i}`, at + 1); + } + // 위기 6쌍(12 doc) — 더 최근(30분 전부터). 1차 페이지(30건)를 잠식한다. + for (let j = 0; j < 6; j++) { + const at = now - (30 - j * 2) * 60_000; + await mk(`c${j}u`, "user", `위기질문${j}`, at); + await mk(`c${j}a`, "assistant", `위기안내${j}`, at + 1, { + crisis: true, + replyTo: `c${j}u`, + }); + } + + const calls = useEchoProvider(); + await sendMessage(uid, { message: "오늘은 어때?", clientMessageId: newUuid() }); + + const history = calls[0].messages; + const contents = history.map((m) => m.content); + // 위기 쌍은 전부 제외되고, 유효 10턴(20 doc) + 이번 입력 1건이 남아야 한다 + expect(contents.some((c) => c.startsWith("위기"))).toBe(false); + expect(history).toHaveLength(21); + // 2차 확장 없이는 가장 오래된 유효 쌍이 잘려 나간다 + expect(contents[0]).toBe("유효질문0"); + expect(history[0].role).toBe("user"); + }); }); describe("스레드 결정 규칙(추가-1·추가-2)", () => {