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:
parent
ca5c4c69b9
commit
d2df63e63c
@ -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) ──
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 : "(미설정)";
|
||||||
|
|||||||
@ -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)
|
||||||
|
const excludedUserIds = new Set<string>();
|
||||||
|
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)
|
let windowed = window(firstPage);
|
||||||
const excludedUserIds = new Set<string>();
|
// 가져온 만큼을 다 채웠는데도 목표에 못 미친다 = 제외된 분량 때문에 잘렸을 수
|
||||||
for (const m of asc) {
|
// 있다는 뜻. 더 오래된 유효 메시지가 남아 있을 수 있으므로 한 번만 넓혀 재조회한다.
|
||||||
if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo);
|
// (스레드가 원래 짧아서 못 채운 경우에는 firstPage가 fetchLimit보다 작아 재조회하지 않는다.)
|
||||||
|
if (windowed.length < target && firstPage.length === fetchLimit) {
|
||||||
|
windowed = window(await getRecentMessages(uid, threadId, target + 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowed = asc.filter((m: MessageWithId) => {
|
const lastN = windowed.slice(-target);
|
||||||
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 잔여분 제거
|
// 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) {
|
||||||
|
|||||||
@ -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경기");
|
||||||
|
|||||||
@ -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)", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user