- 정중한 해설위원 톤의 "commentator" 스타일 팩을 추가하고 STYLE_PACKS에 등록했습니다(bestfriend/commentator 2종).
- 팀 페르소나에서 말투 요소를 제거했습니다: 반말 샘플 문장을 걷어내고, 1인칭("나"/"저")을 팀 블록이 아니라 스타일 팩이 정하도록 옮겼습니다. 팀 페르소나는 이제 팀색(연고·정서·라이벌·금기·어휘)만 담습니다.
- 공통 프롬프트의 역할 분리를 명확히 했습니다: 말투·캐릭터·1인칭은 스타일이, 팀색은 팀 페르소나가 정하며 팀 페르소나는 말투를 지정하지 않습니다.
359 lines
14 KiB
TypeScript
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;
|
|
}
|