Trim chat context and history reads

- 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 제거
This commit is contained in:
윤정민 2026-07-27 13:27:10 +09:00
parent ca5c4c69b9
commit d2df63e63c
7 changed files with 278 additions and 290 deletions

View File

@ -2,7 +2,7 @@ import { randomBytes, createHash } from "node:crypto";
import { FieldPath, Timestamp } from "firebase-admin/firestore"; import { FieldPath, Timestamp } from "firebase-admin/firestore";
import { ServerValue } from "firebase-admin/database"; import { ServerValue } from "firebase-admin/database";
import { firestore, rtdb } from "../firebase"; import { firestore, rtdb } from "../firebase";
import { HttpError } from "../middleware/errors"; import { HttpError, isAlreadyExistsError } from "../middleware/errors";
import { MemCache } from "../lib/memCache"; import { MemCache } from "../lib/memCache";
import type { import type {
ChatMessageDoc, ChatMessageDoc,
@ -98,8 +98,11 @@ export interface ReserveParams {
export type ReserveOutcome = export type ReserveOutcome =
| { kind: "done"; assistantMessageId: string; threadId: string } | { 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 . * ·· Firestore .
@ -136,7 +139,9 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
const stillDebited = req.debited && !req.refunded; const stillDebited = req.debited && !req.refunded;
if (stillDebited || params.crisisPath) { if (stillDebited || params.crisisPath) {
tx.update(reqRef, { createdAt: now }); tx.update(reqRef, { createdAt: now });
return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true }; // 이 분기는 쿼터를 갱신하지 않으므로 읽은 값이 그대로 현재 값이다.
const keptUsed = ((quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>).used ?? 0;
return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true, used: keptUsed };
} }
const resumeQuota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>; const resumeQuota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>;
const resumeUsed = resumeQuota.used ?? 0; const resumeUsed = resumeQuota.used ?? 0;
@ -152,7 +157,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
limit: params.limit, limit: params.limit,
updatedAt: now, updatedAt: now,
}, { merge: true }); }, { merge: true });
return { kind: "reserved", threadId: req.threadId, debited: true, resumed: true }; return { kind: "reserved", threadId: req.threadId, debited: true, resumed: true, used: resumeUsed + 1 };
} }
const quota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>; const quota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>;
@ -208,7 +213,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
updatedAt: now, updatedAt: now,
}, { merge: true }); }, { merge: true });
return { kind: "reserved", threadId: params.threadId, debited, resumed: false }; return { kind: "reserved", threadId: params.threadId, debited, resumed: false, used: debited ? used + 1 : used };
}); });
} }
@ -304,6 +309,11 @@ export interface ExchangeParams {
/** assistant 화면 이동 액션(마커에서 추출). 비었으면 미저장. */ /** assistant 화면 이동 액션(마커에서 추출). 비었으면 미저장. */
actions?: NavAction[]; actions?: NavAction[];
retentionDays: number; retentionDays: number;
/**
* . `createdAt` .
* read를 . .
*/
threadExists?: boolean;
} }
export interface ExchangeResult { export interface ExchangeResult {
@ -324,7 +334,9 @@ export async function finalizeExchange(params: ExchangeParams): Promise<Exchange
const batch = firestore.batch(); const batch = firestore.batch();
const tRef = threadRef(params.uid, params.threadId); const tRef = threadRef(params.uid, params.threadId);
const threadExists = (await tRef.get()).exists; // 호출자가 이미 스레드 문서를 읽었으면(loadHistory 경유 정상 경로) 그 값을 쓴다.
// 위기 경로는 loadHistory를 거치지 않으므로 여기서 직접 읽는다.
const threadExists = params.threadExists ?? (await tRef.get()).exists;
const userDoc: ChatMessageDoc = { const userDoc: ChatMessageDoc = {
role: "user", role: "user",
@ -459,17 +471,31 @@ export async function upsertReport(
): Promise<void> { ): Promise<void> {
const ref = firestore.collection(REPORTS).doc(`${uid}_${messageId}`); const ref = firestore.collection(REPORTS).doc(`${uid}_${messageId}`);
const now = Timestamp.now(); const now = Timestamp.now();
const existing = await ref.get();
const doc: ChatReportDoc = { const doc: ChatReportDoc = {
uid, uid,
messageId, messageId,
reason, reason,
...(comment ? { comment } : {}), ...(comment ? { comment } : {}),
status: "open", status: "open",
createdAt: existing.exists ? (existing.data() as ChatReportDoc).createdAt : now, createdAt: now,
updatedAt: 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) ── // ── 전역 호출·토큰 카운터(RTDB, §8.3) ──

View File

@ -376,14 +376,18 @@ async function fetchScheduleSingleDay(
let cached = (await readDayDocs([key])).get(key) ?? null; let cached = (await readDayDocs([key])).get(key) ?? null;
if (cached === null) { if (cached === null) {
// 미스 → 월 단위 외부 fetch 흐름이 캐시를 채우도록 호출. 결과는 사용하지 않음. // 미스 → 월 단위 리필. 리필 결과를 그대로 재사용해 day doc 재읽기를 없앤다.
await fetchScheduleMonth({ // loadMonthDayCache는 live 병합 전 원본을 돌려주므로 아래 merge가 이중 적용되지 않는다.
const load = await loadMonthDayCache({
year: filters.year, year: filters.year,
month: filters.month, month: filters.month,
team: filters.team, team: filters.team,
series: filters.series, 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); 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<string, ScheduleGame[] | null> }
| { kind: "bypass"; games: ScheduleGame[] };
/**
* day ( fetch로 ).
*
* live ****
* / .
*/
async function loadMonthDayCache(
filters: ScheduleFilters filters: ScheduleFilters
): Promise<ScheduleResult> { ): Promise<MonthDayCacheLoad> {
const allDays = enumerateMonthDays(filters.year, filters.month); const allDays = enumerateMonthDays(filters.year, filters.month);
const keys = allDays.map((d) => dayKey(d, filters.team, filters.series)); 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 { } finally {
await releaseLock(lockKey); await releaseLock(lockKey);
} }
@ -451,15 +469,26 @@ async function fetchScheduleMonth(
} }
if (missing.length > 0) { if (missing.length > 0) {
// 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함). // 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함).
return fetchSchedule(filters); return { kind: "bypass", games: (await fetchSchedule(filters)).games };
} }
} }
} }
return { kind: "cache", allDays, cached };
}
async function fetchScheduleMonth(
filters: ScheduleFilters
): Promise<ScheduleResult> {
const load = await loadMonthDayCache(filters);
if (load.kind === "bypass") {
return { year: filters.year, month: filters.month, games: load.games };
}
const games: ScheduleGame[] = []; const games: ScheduleGame[] = [];
const today = todayKst().replace(/-/g, ""); const today = todayKst().replace(/-/g, "");
for (const ymd of allDays) { for (const ymd of load.allDays) {
const list = cached.get(dayKey(ymd, filters.team, filters.series)) ?? []; const list = load.cached.get(dayKey(ymd, filters.team, filters.series)) ?? [];
if (ymd === today) { if (ymd === today) {
const merged = await mergeLiveIntoSchedule(ymd, list, filters.series); const merged = await mergeLiveIntoSchedule(ymd, list, filters.series);
games.push(...merged); games.push(...merged);

View File

@ -1,13 +1,10 @@
import { getSchedule } from "./scheduleService"; import { getSchedule } from "./scheduleService";
import { getRank } from "./rankService";
import { getStats } from "./statsService";
import { getUserDateVotes } from "../repositories/voteRepository"; import { getUserDateVotes } from "../repositories/voteRepository";
import { getDay } from "../repositories/voteHistoryRepository"; import { getDay } from "../repositories/voteHistoryRepository";
import { import {
COMMON_SYSTEM_PROMPT, COMMON_SYSTEM_PROMPT,
DEFAULT_PERSONA_BLOCK, DEFAULT_PERSONA_BLOCK,
DEFAULT_TEAM_PERSONAS, DEFAULT_TEAM_PERSONAS,
KBO_RANK_TEAM_NAMES,
KNOWLEDGE_GUIDANCE, KNOWLEDGE_GUIDANCE,
SERVER_DIRECTIVE_BLOCK, SERVER_DIRECTIVE_BLOCK,
TEAM_DISPLAY_NAMES, 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; date: DateString;
displayName: string; displayName: string;
knowledgeLevel: KnowledgeLevel; knowledgeLevel: KnowledgeLevel;
teamCode: TeamCode | null; teamCode: TeamCode | null;
teamName: string | null; teamName: string | null;
todaySchedule: string; }
todayMyPredictions: string;
yesterdayRecap: string; /** 추천 질문 노출 조건(§6.2) 플래그 — GET /chat/suggestions 전용. */
/** 응원팀 미설정 시 null → 줄 생략. */ export interface SuggestionFlags {
recentTeamResults: string | null; teamCode: TeamCode | null;
/** 오늘 응원팀 경기 없음/미설정 시 null → 줄 생략. */
h2hRecords: string | null;
myStats: string;
/** 추천 질문 노출 조건(§6.2) 공용 플래그. */
hasYesterdayRecap: boolean; hasYesterdayRecap: boolean;
hasTodayTeamGame: boolean; hasTodayTeamGame: boolean;
hasPredictedToday: boolean; hasPredictedToday: boolean;
@ -92,178 +92,65 @@ async function safely<T>(label: string, fallback: T, task: () => Promise<T>): Pr
} }
} }
function matchupLabel(g: ScheduleGame): string {
return `${g.awayTeamCode} vs ${g.homeTeamCode}`;
}
/** /**
* (2.2 {{todaySchedule}}). * Firestore .
* `live` , `completed` , `cancelled` . *
* `users/{uid}` . (§3.1)
* .
*/ */
export function formatTodaySchedule(games: ScheduleGame[]): string { export function identityContext(user: User | null, config?: ChatConfig): IdentityContext {
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<ScheduleGame[]> {
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<UserContext> {
const date = todayKst();
const [y, m, d] = date.split("-").map(Number);
const teamCode = resolveTeamCode(user?.favoriteTeamCode); 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<ScheduleGame[]>("todaySchedule", [], async () => (await getSchedule(y, m, undefined, undefined, d)).games),
safely<Record<string, { team: string }>>("todayMyPredictions", {}, () => getUserDateVotes(uid, date)),
safely("yesterdayRecap", null, async () =>
user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null,
),
safely<ScheduleGame[]>("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 { return {
date, date: todayKst(),
displayName, displayName: sanitizeDisplayName(user?.displayName),
knowledgeLevel, knowledgeLevel: resolveKnowledgeLevel(user?.knowledgeLevel),
teamCode, teamCode,
// 표기명은 config로 강등(닉네임 전환) 가능 — KBO 라이선스 미확보 대비(1-2) // 표기명은 config로 강등(닉네임 전환) 가능 — KBO 라이선스 미확보 대비(1-2)
teamName: teamCode ? teamName: teamCode ?
config?.teamDisplayNames?.[teamCode] ?? TEAM_DISPLAY_NAMES[teamCode] : config?.teamDisplayNames?.[teamCode] ?? TEAM_DISPLAY_NAMES[teamCode] :
null, null,
todaySchedule: formatTodaySchedule(todayGames), };
todayMyPredictions, }
yesterdayRecap,
recentTeamResults, /**
h2hRecords, * (§6.2) GET /chat/suggestions .
myStats, *
hasYesterdayRecap, *
hasTodayTeamGame, * . false로 degrade된다( ).
hasPredictedToday: voteEntries.length > 0, */
export async function gatherSuggestionFlags(
uid: string,
user: User | null,
): Promise<SuggestionFlags> {
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<ScheduleGame[]>("todaySchedule", [], async () =>
(await getSchedule(y, m, undefined, undefined, d)).games,
) :
Promise.resolve<ScheduleGame[]>([]),
safely<Record<string, { team: string }>>("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, string>): string {
} }
/** /**
* [ 3] () + . * [ 3] () .
* ····· . * ····· .
*/ */
export function buildUserContextBlock(ctx: UserContext): string { export function buildUserContextBlock(ctx: IdentityContext): string {
return fill(USER_CONTEXT_TEMPLATE, { return fill(USER_CONTEXT_TEMPLATE, {
todayDate: ctx.date, todayDate: ctx.date,
displayName: ctx.displayName, displayName: ctx.displayName,
@ -333,7 +220,7 @@ export interface AssembledPrompt {
* ( · §7.3 ) . * ( · §7.3 ) .
* user (§7.5). * 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 style = resolveStylePack(config.stylePack);
const common = config.systemPromptCommon.trim().length > 0 ? const common = config.systemPromptCommon.trim().length > 0 ?
config.systemPromptCommon : 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; return assemblePrompt(config, ctx).system;
} }

View File

@ -2,7 +2,7 @@ import { getChatConfig, invalidateChatConfigCache } from "./chatConfigService";
import { getUser } from "../repositories/userRepository"; import { getUser } from "../repositories/userRepository";
import { import {
assemblePrompt, assemblePrompt,
gatherUserContext, identityContext,
resolveTeamCode, resolveTeamCode,
} from "./chatContextService"; } from "./chatContextService";
import { buildChatTools, type ChatToolContext } from "./chatToolService"; import { buildChatTools, type ChatToolContext } from "./chatToolService";
@ -112,7 +112,7 @@ async function buildProbeEnv(
} }
const teamCode = resolveTeamCode(user.favoriteTeamCode); const teamCode = resolveTeamCode(user.favoriteTeamCode);
const date = todayKst(); const date = todayKst();
const ctx = await gatherUserContext(uid, user, config); const ctx = identityContext(user, config);
const { system } = assemblePrompt(config, ctx); const { system } = assemblePrompt(config, ctx);
const provider = getChatProvider(config.provider); const provider = getChatProvider(config.provider);
const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)"; const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)";

View File

@ -30,7 +30,13 @@ import {
import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService"; import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService";
import { logChatEvent } from "./chatAnalyticsService"; import { logChatEvent } from "./chatAnalyticsService";
import { extractNavActions } from "./chatNavService"; 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 { buildChatTools, withToolLabels } from "./chatToolService";
import { import {
EMPTY_REPLY_NOTICE, FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE, 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 }; return { used: quota.used ?? 0 };
} }
/**
* @param usedOverride `used`.
* . (replay, GET /chat/quota)
* .
*/
async function buildSendResult( async function buildSendResult(
uid: string, uid: string,
date: DateString, date: DateString,
@ -88,8 +99,9 @@ async function buildSendResult(
createdAt: Timestamp, createdAt: Timestamp,
toolCalls?: ChatToolCallInfo[], toolCalls?: ChatToolCallInfo[],
actions?: NavAction[], actions?: NavAction[],
usedOverride?: number,
): Promise<ChatSendResult> { ): Promise<ChatSendResult> {
const { used } = await quotaView(uid, date); const used = usedOverride ?? (await quotaView(uid, date)).used;
return { return {
messageId, messageId,
reply, reply,
@ -121,14 +133,23 @@ async function replayDone(
); );
} }
/** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */ /**
* (§5.5) N턴, , / .
*
* `threadExists` (`finalizeExchange`) `createdAt`
* .
*/
async function loadHistory( async function loadHistory(
uid: string, uid: string,
threadId: string, threadId: string,
config: ChatConfig, config: ChatConfig,
): Promise<ChatProviderMessage[]> { ): Promise<{ messages: ChatProviderMessage[]; threadExists: boolean }> {
const fetchLimit = config.historyTurns * 2 + 30; const target = config.historyTurns * 2;
const [thread, recentDesc] = await Promise.all([ // 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에는 상한이 없다.
// 위기 쌍은 문서 2개를 차지하고 윈도잉에서 둘 다 빠지므로, 고정 padding은
// 유효한 상한이 될 수 없다. 평시에는 작게 읽고, 실제로 모자랄 때만 한 번 넓힌다.
const fetchLimit = target + 10;
const [thread, firstPage] = await Promise.all([
getThreadDoc(uid, threadId), getThreadDoc(uid, threadId),
getRecentMessages(uid, threadId, fetchLimit), getRecentMessages(uid, threadId, fetchLimit),
]); ]);
@ -138,26 +159,38 @@ async function loadHistory(
// 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐. // 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐.
const cutAt = thread?.historyCutAt?.toMillis() ?? 0; 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) // 위기 교환 쌍 제외 — crisis assistant와 그 replyTo user 메시지(§5.5)
const excludedUserIds = new Set<string>(); const excludedUserIds = new Set<string>();
for (const m of asc) { for (const m of asc) {
if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo); if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo);
} }
return asc.filter((m: MessageWithId) => {
const windowed = asc.filter((m: MessageWithId) => {
const ms = m.createdAt.toMillis(); const ms = m.createdAt.toMillis();
if (ms < minCreatedAt || ms < cutAt) return false; if (ms < minCreatedAt || ms < cutAt) return false;
if (m.role === "assistant" && (m.crisis || m.filtered)) return false; if (m.role === "assistant" && (m.crisis || m.filtered)) return false;
if (m.role === "user" && excludedUserIds.has(m.messageId)) return false; if (m.role === "user" && excludedUserIds.has(m.messageId)) return false;
return true; return true;
}); });
};
const lastN = windowed.slice(-config.historyTurns * 2); let windowed = window(firstPage);
// 가져온 만큼을 다 채웠는데도 목표에 못 미친다 = 제외된 분량 때문에 잘렸을 수
// 있다는 뜻. 더 오래된 유효 메시지가 남아 있을 수 있으므로 한 번만 넓혀 재조회한다.
// (스레드가 원래 짧아서 못 채운 경우에는 firstPage가 fetchLimit보다 작아 재조회하지 않는다.)
if (windowed.length < target && firstPage.length === fetchLimit) {
windowed = window(await getRecentMessages(uid, threadId, target + 40));
}
const lastN = windowed.slice(-target);
// provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거 // provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거
while (lastN.length > 0 && lastN[0].role === "assistant") lastN.shift(); 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회만 경고. */ /** 비용 가드 80% 운영 알림(§8.3) — 인스턴스·날짜당 1회만 경고. */
@ -277,20 +310,30 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
crisis: true, crisisType: crisis.type, crisis: true, crisisType: crisis.type,
msgLen: message.length, latencyMs: Date.now() - startedAt, msgLen: message.length, latencyMs: Date.now() - startedAt,
}); });
return buildSendResult(uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt); return buildSendResult(
uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt,
undefined, undefined, outcome.used,
);
} }
let saved: ExchangeResult | null = null; let saved: ExchangeResult | null = null;
// loadHistory가 읽은 스레드 존재 여부 — 저장 단계로 넘겨 중복 read를 없앤다.
let threadExists: boolean | undefined;
let finalReply = ""; let finalReply = "";
let finalCrisis = false; let finalCrisis = false;
let finalToolCalls: ChatToolCallInfo[] = []; let finalToolCalls: ChatToolCallInfo[] = [];
let finalActions: NavAction[] = []; let finalActions: NavAction[] = [];
try { try {
// 6) 컨텍스트 조립(§5) // 6) 컨텍스트 조립(§5)
const ctx: UserContext = await gatherUserContext(uid, user, config); // 정체성만 조립한다 — 시사 데이터는 도구로 조회되므로 여기서 선조회하지 않는다
const ctx: IdentityContext = identityContext(user, config);
const { system, leakBody } = assemblePrompt(config, ctx); const { system, leakBody } = assemblePrompt(config, ctx);
const history = await loadHistory(uid, activeThreadId, config); const history = await loadHistory(uid, activeThreadId, config);
const messages: ChatProviderMessage[] = [...history, { role: "user", content: message }]; threadExists = history.threadExists;
const messages: ChatProviderMessage[] = [
...history.messages,
{ role: "user", content: message },
];
// 7) 입력 필터 2단계(provider 모더레이션, 선택) — 차단 시 422 + 차감 복원 // 7) 입력 필터 2단계(provider 모더레이션, 선택) — 차단 시 422 + 차감 복원
const provider = getChatProvider(config.provider); const provider = getChatProvider(config.provider);
@ -383,6 +426,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
crisis: crisisOut, crisis: crisisOut,
toolCalls, toolCalls,
actions, actions,
...(threadExists !== undefined ? { threadExists } : {}),
retentionDays: config.retentionDays, retentionDays: config.retentionDays,
}); });
finalReply = reply; finalReply = reply;
@ -413,9 +457,11 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
throw err; throw err;
} }
// 11) 응답 반환 — 저장 성공 이후의 쿼터 재조회 실패는 복원 대상이 아니다(§6.2) // 11) 응답 반환 — 예약 트랜잭션이 계산한 used를 그대로 쓴다. 여기 도달하는
// 경로에서 used를 바꾸는 것은 refund뿐인데, refund는 모두 throw로 빠진다.
return buildSendResult( return buildSendResult(
uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt, finalToolCalls, finalActions, uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt,
finalToolCalls, finalActions, outcome.used,
); );
} }
@ -548,17 +594,17 @@ export async function getSuggestions(uid: string): Promise<ChatSuggestionsView>
let priority: ChatSuggestion[] = []; let priority: ChatSuggestion[] = [];
try { try {
const user = await getUser(uid); const user = await getUser(uid);
const ctx = await gatherUserContext(uid, user, config); const flags = await gatherSuggestionFlags(uid, user);
candidates = config.suggestions.filter((s) => { 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 제외"는 질문 문구("오늘 우리 경기")에 // 6.2 표의 "오늘 경기 없음 → Q7/Q11/Q12 제외"는 질문 문구("오늘 우리 경기")에
// 맞춰 "응원팀의 오늘 경기 유무"로 해석해 적용한다(리그 전체 기준보다 엄격) // 맞춰 "응원팀의 오늘 경기 유무"로 해석해 적용한다(리그 전체 기준보다 엄격)
if (s.requiresTodayTeamGame && !ctx.hasTodayTeamGame) return false; if (s.requiresTodayTeamGame && !flags.hasTodayTeamGame) return false;
if (s.requiresYesterdayRecap && !ctx.hasYesterdayRecap) return false; if (s.requiresYesterdayRecap && !flags.hasYesterdayRecap) return false;
if (s.excludeWhenPredictedToday && ctx.hasPredictedToday) return false; if (s.excludeWhenPredictedToday && flags.hasPredictedToday) return false;
return true; return true;
}); });
if (ctx.hasYesterdayRecap) { if (flags.hasYesterdayRecap) {
priority = candidates.filter((s) => s.priorityWhenRecap); priority = candidates.filter((s) => s.priorityWhenRecap);
} }
} catch (err) { } catch (err) {

View File

@ -1,56 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildUserContextBlock, buildUserContextBlock,
formatTodaySchedule,
resolveKnowledgeLevel, resolveKnowledgeLevel,
resolvePersonaBlock, resolvePersonaBlock,
resolveTeamCode, resolveTeamCode,
sanitizeDisplayName, sanitizeDisplayName,
type UserContext, type IdentityContext,
} from "../../src/services/chatContextService"; } from "../../src/services/chatContextService";
import { assembleSystemPrompt } from "../../src/services/chatContextService"; import { assembleSystemPrompt } from "../../src/services/chatContextService";
import { DEFAULT_CHAT_CONFIG } from "../../src/services/chatConfigService"; import { DEFAULT_CHAT_CONFIG } from "../../src/services/chatConfigService";
import { CHAT_CANARY_TOKEN } from "../../src/constants/chatPrompts"; import { CHAT_CANARY_TOKEN } from "../../src/constants/chatPrompts";
import { KnowledgeLevel, TeamCode } from "../../src/types/panit"; import { KnowledgeLevel, TeamCode } from "../../src/types/panit";
import type { DateString } from "../../src/types/dateString"; import type { DateString } from "../../src/types/dateString";
import type { ScheduleGame } from "../../src/types/kbo";
function game(overrides: Partial<ScheduleGame>): ScheduleGame { function ctx(overrides: Partial<IdentityContext>): IdentityContext {
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>): UserContext {
return { return {
date: "2026-06-12" as DateString, date: "2026-06-12" as DateString,
displayName: "솔방울", displayName: "솔방울",
knowledgeLevel: KnowledgeLevel.Casual, knowledgeLevel: KnowledgeLevel.Casual,
teamCode: TeamCode.HH, teamCode: TeamCode.HH,
teamName: "한화 이글스", teamName: "한화 이글스",
todaySchedule: "오늘 경기 없음",
todayMyPredictions: "오늘 예측 없음",
yesterdayRecap: "어제 예측 기록 없음",
recentTeamResults: null,
h2hRecords: null,
myStats: "통계 없음",
hasYesterdayRecap: false,
hasTodayTeamGame: false,
hasPredictedToday: false,
...overrides, ...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 템플릿)", () => { describe("buildUserContextBlock(2.2 템플릿)", () => {
it("고정 구분자로 감싸고 응원팀 미설정·선택 줄 생략을 적용한다", () => { it("고정 구분자로 감싸고 응원팀 미설정·선택 줄 생략을 적용한다", () => {
const block = buildUserContextBlock(ctx({ teamCode: null, teamName: null })); const block = buildUserContextBlock(ctx({ teamCode: null, teamName: null }));
@ -114,7 +60,7 @@ describe("chatContextService", () => {
}); });
it("정체성(닉네임·응원팀)만 주입하고 시사 수치·일정은 넣지 않는다(경량판)", () => { it("정체성(닉네임·응원팀)만 주입하고 시사 수치·일정은 넣지 않는다(경량판)", () => {
const block = buildUserContextBlock(ctx({ hasTodayTeamGame: true })); const block = buildUserContextBlock(ctx({}));
expect(block).toContain("- 사용자: 솔방울"); expect(block).toContain("- 사용자: 솔방울");
expect(block).not.toContain("오늘"); // 오늘 경기·일정은 컨텍스트에 없음(도구로) expect(block).not.toContain("오늘"); // 오늘 경기·일정은 컨텍스트에 없음(도구로)
expect(block).not.toContain("최근 5경기"); expect(block).not.toContain("최근 5경기");

View File

@ -617,6 +617,60 @@ describe("chatService", () => {
expect(contents).toEqual(["어제 경기 봤어?", "봤지! 짜릿했어", "오늘은 어때?"]); expect(contents).toEqual(["어제 경기 봤어?", "봤지! 짜릿했어", "오늘은 어때?"]);
expect(history[0].role).toBe("user"); 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<ChatMessageDoc> = {},
) =>
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)", () => { describe("스레드 결정 규칙(추가-1·추가-2)", () => {