mmday-firebase/src/services/chatContextService.ts
윤정민 4e98532f6c Add commentator style pack and make team personas tone-neutral.
- 정중한 해설위원 톤의 "commentator" 스타일 팩을 추가하고 STYLE_PACKS에 등록했습니다(bestfriend/commentator 2종).
- 팀 페르소나에서 말투 요소를 제거했습니다: 반말 샘플 문장을 걷어내고, 1인칭("나"/"저")을 팀 블록이 아니라 스타일 팩이 정하도록 옮겼습니다. 팀 페르소나는 이제 팀색(연고·정서·라이벌·금기·어휘)만 담습니다.
- 공통 프롬프트의 역할 분리를 명확히 했습니다: 말투·캐릭터·1인칭은 스타일이, 팀색은 팀 페르소나가 정하며 팀 페르소나는 말투를 지정하지 않습니다.
2026-06-23 13:27:17 +09:00

359 lines
14 KiB
TypeScript

import { getSchedule } from "./scheduleService";
import { getRank } from "./rankService";
import { getStats } from "./statsService";
import { getUserDateVotes } from "../repositories/voteRepository";
import { getDay } from "../repositories/voteHistoryRepository";
import {
COMMON_SYSTEM_PROMPT,
DEFAULT_PERSONA_BLOCK,
DEFAULT_TEAM_PERSONAS,
KBO_RANK_TEAM_NAMES,
KNOWLEDGE_GUIDANCE,
SERVER_DIRECTIVE_BLOCK,
TEAM_DISPLAY_NAMES,
USER_CONTEXT_TEMPLATE,
} from "../constants/chatPrompts";
import { resolveStylePack } from "../constants/chatStyles";
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<string>(Object.values(TeamCode));
const KNOWLEDGE_LEVELS = new Set<string>(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<T>(label: string, fallback: T, task: () => Promise<T>): Promise<T> {
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<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 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 {
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, string>): string {
let out = template;
for (const [key, value] of Object.entries(vars)) {
out = out.split(`{{${key}}}`).join(value);
}
return out;
}
/**
* [블록 3] 사용자 컨텍스트 블록(경량판) — 정체성 + 오늘 경기 유무 플래그만.
* 매치업·선발·순위·예측·전적·통계는 도구로 조회하므로 여기서 선주입하지 않는다.
*/
export function buildUserContextBlock(ctx: UserContext): string {
return fill(USER_CONTEXT_TEMPLATE, {
todayDate: ctx.date,
displayName: ctx.displayName,
favoriteTeamLine: ctx.teamCode ? `${ctx.teamName} (${ctx.teamCode})` : "응원팀 미설정",
});
}
/**
* 설정집 미수령 팀의 임시 페르소나 — "팀 무소속 기본 짹"을 쓰면 컨텍스트 블록의
* 응원팀 표기와 자기모순이 되므로(§5.2), 팀 인지형 최소 블록으로 대체한다.
* 클라이언트 설정집(1-1) 수령 시 `config/chat.teamPersonas`가 이를 대체한다.
*/
function genericTeamPersonaBlock(teamCode: TeamCode, teamName: string): string {
return `[팀 페르소나 — ${teamName} 짹 (${teamCode})]
- 팀: ${teamName}. 우리 팀은 "우리"로 부른다.
- 이 팀의 세부 설정(치어 문구·라이벌 관계·금기)은 아직 정의되지 않았다.
구단 고유의 슬로건·응원 문구·일화를 지어내지 말고, 공통 규칙(특히 4절
금지선)을 그대로 따른다.
- 라이벌 도발은 사용자가 먼저 꺼낸 화제에 컨텍스트의 전적·기록 근거로
짧게 호응하는 수준까지만 한다.`;
}
/** [블록 2] 팀 페르소나 블록 — config 우선, 없으면 내장 기본(HH placeholder)·팀 범용 블록. */
export function resolvePersonaBlock(
config: ChatConfig,
teamCode: TeamCode | null,
teamName?: string | null,
personaArchetype?: string,
): string {
const teamBlock = !teamCode ?
DEFAULT_PERSONA_BLOCK :
config.teamPersonas[teamCode] ??
DEFAULT_TEAM_PERSONAS[teamCode] ??
genericTeamPersonaBlock(teamCode, teamName ?? TEAM_DISPLAY_NAMES[teamCode]);
// 4층 = [짹 페르소나 아키타입(스타일 팩)] + [팀 페르소나]. 팀색은 팀 블록이 덧입힌다.
const archetype = personaArchetype ?? resolveStylePack(config.stylePack).personaArchetype;
return `${archetype}\n\n${teamBlock}`;
}
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 style = resolveStylePack(config.stylePack);
const common = config.systemPromptCommon.trim().length > 0 ?
config.systemPromptCommon :
COMMON_SYSTEM_PROMPT;
const filled = fill(common, {
styleBaseStyle: style.baseStyle,
knowledgeGuidance: KNOWLEDGE_GUIDANCE[ctx.knowledgeLevel],
});
const persona = resolvePersonaBlock(config, ctx.teamCode, ctx.teamName, style.personaArchetype);
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;
}