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, SERVER_DIRECTIVE_BLOCK, TEAM_DISPLAY_NAMES, USER_CONTEXT_TEMPLATE, } from "../constants/chatPrompts"; import { KnowledgeLevel, TeamCode, type User } from "../types/panit"; import type { ChatConfig } from "../types/chat"; import { parseDateString, todayKst, type DateString } from "../types/dateString"; import type { ScheduleGame } from "../types/kbo"; /** * 컨텍스트 조립(§5) — 시스템 프롬프트 + 사용자 컨텍스트 블록 생성. * * 원칙(2-1): 종료·확정된 정보만 사실로 주입한다. 진행 중 경기는 확정 필드만 * 주입하고 스코어는 제거한다. 각 항목은 실패 허용 — 외부(KBO) 조회가 깨져도 * 채팅 자체는 동작해야 하므로 부재 표기로 대체한다. */ // ── 검증(§5.4) — users/{uid} 값은 사용자 제어 가능 입력으로 간주 ── const TEAM_CODES = new Set(Object.values(TeamCode)); const KNOWLEDGE_LEVELS = new Set(Object.values(KnowledgeLevel)); /** 팀 코드 화이트리스트 정확 일치 검증 — 불일치 시 null(중립 짹). */ export function resolveTeamCode(raw: unknown): TeamCode | null { return typeof raw === "string" && TEAM_CODES.has(raw) ? (raw as TeamCode) : null; } /** knowledgeLevel enum 정확 일치 검증 — 불일치 시 casual. */ export function resolveKnowledgeLevel(raw: unknown): KnowledgeLevel { return typeof raw === "string" && KNOWLEDGE_LEVELS.has(raw) ? (raw as KnowledgeLevel) : KnowledgeLevel.Casual; } /** * 닉네임 새니타이즈(2.2) — 길이 20자 제한, 개행·구분자(대괄호 등)·제어문자 제거. * 프롬프트 인젝션 방지의 일부이며, 컨텍스트 블록 고정 구분자와 결합된다. */ export function sanitizeDisplayName(raw: unknown): string { if (typeof raw !== "string") return "팬"; const cleaned = raw .replace(/[\r\n\t]/g, " ") // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001f\u007f]/g, "") .replace(/[[\]{}<>`|\\]/g, "") .trim() .slice(0, 20) .trim(); return cleaned.length > 0 ? cleaned : "팬"; } // ── 컨텍스트 데이터 수집 ── export interface UserContext { 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) 공용 플래그. */ hasYesterdayRecap: boolean; hasTodayTeamGame: boolean; hasPredictedToday: boolean; } async function safely(label: string, fallback: T, task: () => Promise): Promise { try { return await task(); } catch (err) { console.warn(`[chat-context] ${label} 조회 실패 — 부재 표기로 대체`, err); return fallback; } } function matchupLabel(g: ScheduleGame): string { return `${g.awayTeamCode} vs ${g.homeTeamCode}`; } /** * 오늘 경기 일정 포맷(2.2 {{todaySchedule}}). * `live`는 확정 필드만 주입하고 스코어는 제거, `completed`는 스코어 포함, `cancelled`는 취소 라벨. */ 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); 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, 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, }; } // ── 프롬프트 조립(2.1 — 블록 1 + 블록 2 + 블록 3) ── function fill(template: string, vars: Record): string { let out = template; for (const [key, value] of Object.entries(vars)) { out = out.split(`{{${key}}}`).join(value); } return out; } /** [블록 3] 사용자 컨텍스트 블록 — 값 부재 시 표기는 2.2 표 기준. */ export function buildUserContextBlock(ctx: UserContext): string { const optionalLines: string[] = []; if (ctx.recentTeamResults != null) { optionalLines.push(`- 응원팀 최근 5경기: ${ctx.recentTeamResults}`); } if (ctx.h2hRecords != null) { optionalLines.push(`- 오늘 상대팀과 시즌 상대 전적: ${ctx.h2hRecords}`); } return fill(USER_CONTEXT_TEMPLATE, { todayDate: ctx.date, displayName: ctx.displayName, knowledgeLevel: ctx.knowledgeLevel, favoriteTeamLine: ctx.teamCode ? `${ctx.teamName} (${ctx.teamCode})` : "응원팀 미설정", todaySchedule: ctx.todaySchedule, todayMyPredictions: ctx.todayMyPredictions, yesterdayRecap: ctx.yesterdayRecap, optionalLines: optionalLines.length > 0 ? `${optionalLines.join("\n")}\n` : "", myStats: ctx.myStats, }); } /** * 설정집 미수령 팀의 임시 페르소나 — "팀 무소속 기본 짹"을 쓰면 컨텍스트 블록의 * 응원팀 표기와 자기모순이 되므로(§5.2), 팀 인지형 최소 블록으로 대체한다. * 클라이언트 설정집(1-1) 수령 시 `config/chat.teamPersonas`가 이를 대체한다. */ function genericTeamPersonaBlock(teamCode: TeamCode, teamName: string): string { return `[팀 페르소나 — ${teamName} 짹 (${teamCode})] - 너는 ${teamName}를 응원하는 참새다. 1인칭은 "나", 사용자는 닉네임으로 부른다(호격 조사는 공통 규칙). - 이 팀의 세부 설정(치어 문구·라이벌 관계·금기)은 아직 정의되지 않았다. 구단 고유의 슬로건·응원 문구·일화를 지어내지 말고, 공통 규칙(특히 4절 금지선)을 그대로 따른다. - 라이벌 도발은 사용자가 먼저 꺼낸 화제에 컨텍스트의 전적·기록 근거로 짧게 호응하는 수준까지만 한다.`; } /** [블록 2] 팀 페르소나 블록 — config 우선, 없으면 내장 기본(HH placeholder)·팀 범용 블록. */ export function resolvePersonaBlock( config: ChatConfig, teamCode: TeamCode | null, teamName?: string | null, ): string { if (!teamCode) return DEFAULT_PERSONA_BLOCK; return ( config.teamPersonas[teamCode] ?? DEFAULT_TEAM_PERSONAS[teamCode] ?? genericTeamPersonaBlock(teamCode, teamName ?? TEAM_DISPLAY_NAMES[teamCode]) ); } export interface AssembledPrompt { system: string; /** 출력 유출 검사(§7.2) 대상 — 공통+페르소나 본문(사용자 데이터 블록 제외). */ leakBody: string; } /** * 시스템 프롬프트 전체 조립(§5) — 2.1의 순서대로 * [블록 1] 공통(변수 치환) + [블록 2] 팀 페르소나 + [블록 3] 사용자 컨텍스트, * 맨 끝에 서버 지시(위기 마커·카나리 — 기술 설계 §7.3 ②)를 덧붙인다. * 사용자 입력은 여기에 절대 이어붙이지 않는다 — user 롤로만 전달(§7.5). */ export function assemblePrompt(config: ChatConfig, ctx: UserContext): AssembledPrompt { const common = config.systemPromptCommon.trim().length > 0 ? config.systemPromptCommon : COMMON_SYSTEM_PROMPT; const filled = fill(common, { displayName: ctx.displayName, knowledgeLevel: ctx.knowledgeLevel, }); const persona = resolvePersonaBlock(config, ctx.teamCode, ctx.teamName); const system = [ filled, persona, buildUserContextBlock(ctx), SERVER_DIRECTIVE_BLOCK, ].join("\n\n"); return { system, leakBody: `${filled}\n${persona}` }; } /** 조립된 시스템 프롬프트 문자열만 필요할 때의 단축형. */ export function assembleSystemPrompt(config: ChatConfig, ctx: UserContext): string { return assemblePrompt(config, ctx).system; }