mmday-firebase/src/services/chatToolService.ts
윤정민 c964572a9c Replace point ledger with wallet engine and rework reward earning
- 지갑 문서(users/{uid}/wallet/current)와 문서 ID=멱등키 원장(pointLedger/{txId})으로 포인트 엔진 교체 — 기존 balanceAfter 최신 row 조회 방식 폐기
- 모든 포인트 변경은 pointService.applyPointChangesTx 단일 경로로 처리, available+reserved == totalEarned-totalSpent 불변식을 매 커밋 검증
- 출석 리워드 개편: 일일 20P, 연속 5일 +50P(사이클당 1회), 10일 단위 +100P — attendance/state 문서에 스트릭 상태 저장, 주간·월간 보너스 폐기
- 승부예측 일일 리워드 정산 신설: 전체 참여 50P + 성공 100P + 퍼펙트 50P, judgeDay 이후 voteHistory.rewardSettledAt 플래그와 원장 멱등키로 배치 재실행에도 중복 지급 차단
- 관리자 포인트 지급·회수(adminPointService)와 수동 재정산 디버그 라우트(/debug/settle-reward) 추가
- 소비처 없던 티켓 시스템(dailyAllKill·weeklyMaster)과 위클리마스터 판정 흐름 전체 제거 — StatsResponse.tickets 필드 삭제로 클라 응답 스키마 변경
2026-07-16 14:25:42 +09:00

649 lines
29 KiB
TypeScript

import { getSchedule } from "./scheduleService";
import { getGameDetail } from "./gameDetailService";
import { getPlayerStats } from "./playerService";
import { getRank } from "./rankService";
import { resolveTeamCode } from "./chatContextService";
import { getDay } from "../repositories/voteHistoryRepository";
import { getMonthDoc } from "../repositories/attendanceRepository";
import { getAvailableBalance } from "../repositories/walletRepository";
import { TEAM_DISPLAY_NAMES, KBO_RANK_TEAM_NAMES } from "../constants/chatPrompts";
import type { ChatTool } from "./chatProviderService";
import type { ChatToolCallInfo } from "../types/chat";
import type { Lineup, KeyPlayerRanking } from "../kbo/game-detail";
import type { PlayerRecord } from "../kbo/player/common";
import type { TeamRank, TeamVsRecord } from "../kbo/team-rank";
import type { ScheduleGame } from "../types/kbo";
import { TeamCode, type AttendanceMonthDoc, type VoteHistoryDoc } from "../types/panit";
import { addDays, type DateString } from "../types/dateString";
/**
* 도구 이름 → 사람이 읽을 라벨. 클라이언트가 "🔍 순위 조회" 같은 칩으로 표시하기 위함.
* 저장(Firestore)은 이름·인자만, 라벨은 API 응답 시점에만 부여한다(표현 계층, 변경 자유).
*/
const TOOL_LABELS: Record<string, string> = {
get_lineup: "라인업 조회",
get_roster: "로스터 조회",
get_game_standouts: "경기 활약 선수 조회",
get_team_rank_snapshot: "순위 조회",
get_my_attendance_this_month: "내 출석·포인트 조회",
get_prediction_breakdown_date: "내 예측 내역 조회",
};
/** toolCalls에 사람이 읽을 라벨을 부여한다(매핑 없는 도구는 라벨 생략). API 경계에서만 호출. */
export function withToolLabels(calls: ChatToolCallInfo[]): ChatToolCallInfo[] {
return calls.map((c) => {
const label = TOOL_LABELS[c.name];
return label ? { ...c, label } : c;
});
}
/**
* AI 채팅 도구(함수 호출) 레이어 — "질문 시점에만 알 수 있는" 정보를 모델이
* 필요할 때만 DB/KBO에서 조회하도록 한다(§5 컨텍스트 상시 주입의 보완).
*
* 원칙:
* - "결정된 사항만"(2-1) — 라인업은 확정(announced)일 때만 타순을 준다. 발표 전이면
* 예고 선발만 사실로 전하고 타순은 "미발표"로 답하게 한다.
* - 진행 중 경기의 실시간 스코어는 도구로도 제공하지 않는다(§5.4 실시간 정보 규칙 유지).
* - 각 도구는 실패·부재를 예외로 던지지 않고 자연어 문자열로 반환한다 — provider
* 루프를 단순하게 유지하고, 모델이 "없음"을 사실대로 전할 수 있게 한다.
*
* TODO(manager): 감독 정보는 현재 DB 소스가 없다(KBO 크롤 모듈 미수집). 확보 방식
* 확정 시 get_manager 도구를 추가한다 — config/chat.teamManagers 수기 입력안 우선
* 검토. 그 전까지 감독 질문은 9절(모르는 정보) 규칙으로 답한다.
*/
/** 도구 실행에 필요한 요청 컨텍스트(호출자 uid·응원팀·KST 오늘). */
export interface ChatToolContext {
/** 호출자 uid — per-uid 데이터(출석·예측 이력) 조회 경계. */
uid: string;
/** 활성 스레드 기준 응원팀(없으면 null — 도구 인자로 팀을 받아야 동작). */
teamCode: TeamCode | null;
/** KST 오늘(YYYY-MM-DD). */
date: DateString;
}
function teamLabel(code: TeamCode): string {
return TEAM_DISPLAY_NAMES[code] ?? code;
}
/** 인자 team(검증) 우선, 없으면 컨텍스트 응원팀. 둘 다 없으면 null. */
export function pickTeam(args: Record<string, unknown>, ctx: ChatToolContext): TeamCode | null {
return resolveTeamCode(args.team) ?? ctx.teamCode;
}
/**
* 날짜 문자열을 KST 절대 날짜로 정규화한다.
* - 빈 값 → 오늘. 상대어("오늘/어제/그저께/내일" 등) 허용.
* - "YYYY-MM-DD" 절대 날짜 허용. 그 외 형식은 null.
*/
export function resolveToolDate(raw: unknown, today: DateString): DateString | null {
if (typeof raw !== "string" || raw.trim() === "") return today;
const s = raw.trim().toLowerCase();
if (s === "today" || s === "오늘") return today;
if (s === "yesterday" || s === "어제") return addDays(today, -1);
if (s === "그저께" || s === "그제" || s === "엊그제") return addDays(today, -2);
if (s === "tomorrow" || s === "내일") return addDays(today, 1);
if (s === "모레") return addDays(today, 2);
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s as DateString;
return null;
}
/** 상대 일수가 비합리적으로 크면 거부할 경계(±1년). */
const MAX_DAY_OFFSET = 366;
/**
* 도구 인자(date 문자열 또는 dayOffset 정수)로부터 조회 날짜를 정한다.
*
* 모델이 월 경계를 넘는 날짜 산수를 직접 하지 않아도 되도록 상대 일수를 받는다:
* 0=오늘, -1=어제, -3=사흘 전, +1=내일. 명시적 date 문자열이 유효하면 그쪽을 우선한다.
* 둘 다 없으면 오늘. 형식이 잘못됐으면 null(호출부가 안내).
*/
export function resolveRequestedDate(
args: Record<string, unknown>,
today: DateString,
): DateString | null {
const hasDateStr = typeof args.date === "string" && args.date.trim() !== "";
if (hasDateStr) {
const fromStr = resolveToolDate(args.date, today);
if (fromStr) return fromStr;
// date 문자열이 깨졌어도 dayOffset이 있으면 그걸로 폴백
}
const off = args.dayOffset;
if (typeof off === "number" && Number.isFinite(off)) {
const n = Math.trunc(off);
if (Math.abs(n) > MAX_DAY_OFFSET) return null;
return addDays(today, n);
}
return hasDateStr ? null : today;
}
/** 오늘 일정에서 해당 팀의 경기를 찾는다(취소 포함 — 호출부에서 상태 처리). */
function findTeamGame(games: ScheduleGame[], team: TeamCode): ScheduleGame | undefined {
return games.find((g) => g.homeTeamCode === team || g.awayTeamCode === team);
}
/**
* 라인업 결과 포맷(순수 함수 — 테스트 용이). 확정 라인업만 타순을 노출한다.
*
* - 과거 경기: boxscore에서 실제 출전 라인업(announced=true) → 타순 노출, 종료 스코어 포함.
* - 미래·당일: 발표 전(announced=false)이면 타순 미노출(예고 선발만 확정).
* @param dateLabel 사용자에게 보여줄 날짜 라벨("오늘" 또는 YYYY-MM-DD).
*/
export function formatLineupResult(
team: TeamCode,
game: ScheduleGame,
lineup: Lineup | null,
dateLabel: string,
): string {
const isHome = game.homeTeamCode === team;
const oppCode = (isHome ? game.awayTeamCode : game.homeTeamCode) as TeamCode;
const myPitcher = isHome ? game.homeStartingPitcher : game.awayStartingPitcher;
const oppPitcher = isHome ? game.awayStartingPitcher : game.homeStartingPitcher;
// 종료 경기의 최종 스코어는 결정된 사실 — 주입 허용(진행 중 스코어와 구분).
// 점수는 팀명 순서(우리팀:상대)와 일치시키고 승패를 명기한다 — "원정:홈" 위치 표기는
// 앞의 "우리팀 vs 상대" 재배열과 어긋나 모델이 홈 승리를 패배로 오독했다.
const myScore = isHome ? game.homeScore : game.awayScore;
const oppScore = isHome ? game.awayScore : game.homeScore;
const finalScore =
game.status === "completed" && myScore != null && oppScore != null ?
` — 종료 ${myScore}:${oppScore} (${teamLabel(team)} ${
myScore > oppScore ? "승" : myScore < oppScore ? "패" : "무"})` :
"";
const head =
`${dateLabel} ${teamLabel(team)} vs ${teamLabel(oppCode)} (${game.time} ${game.stadium})${finalScore}. ` +
`선발: ${teamLabel(team)} ${myPitcher?.name ?? "미정"} / ${teamLabel(oppCode)} ${oppPitcher?.name ?? "미정"}.`;
// "결정된 사항만" — 확정(announced) 라인업만 타순을 노출한다.
if (!lineup || !lineup.announced) {
return `${head}\n선발 타순(라인업)은 아직 확인할 수 없어 — 발표 전이거나 기록이 없어. 확정된 건 예고 선발뿐.`;
}
const side = isHome ? lineup.home : lineup.away;
if (side.slots.length === 0) {
return `${head}\n타순 데이터를 못 가져왔어.`;
}
const order = [...side.slots]
.sort((a, b) => a.batOrder - b.batOrder)
.slice(0, 9)
.map((s) => `${s.batOrder}. ${s.name} (${s.position})`)
.join("\n");
return `${head}\n[확정 선발 라인업]\n${order}`;
}
const ROSTER_COLUMNS: Record<"hitter" | "pitcher", readonly string[]> = {
hitter: ["avg", "hr", "rbi", "hits"],
pitcher: ["era", "wins", "losses", "saves", "holds", "so"],
};
/**
* 로스터 결과 포맷(순수 함수). 시즌 기록 상위 N명을 핵심 스탯과 함께 전한다.
* @param team null이면 리그 전체(팀 필터 없음) — "올해 타율 1위" 등 리그 랭킹용.
*/
export function formatRosterResult(
team: TeamCode | null,
type: "hitter" | "pitcher",
columns: readonly string[],
records: PlayerRecord[],
year: number,
limit = 12,
): string {
const top = records.slice(0, limit);
const kind = type === "pitcher" ? "주요 투수" : "주요 타자";
const who = team ? teamLabel(team) : "리그";
if (top.length === 0) {
return `${who} ${kind} 시즌 기록을 못 찾았어.`;
}
const cols = ROSTER_COLUMNS[type].filter((c) => columns.includes(c)).slice(0, 4);
const lines = top.map((r) => {
const stats = cols.map((c) => `${c}=${r[c] ?? "-"}`).join(", ");
return stats ? `- ${r.player ?? "?"}: ${stats}` : `- ${r.player ?? "?"}`;
});
return `[${who} ${kind} (시즌 기록 상위, ${year})]\n${lines.join("\n")}`;
}
/** 팀 순위 결과 포맷(순수 함수). teamCode 지정 시 그 팀 상세, null이면 전체 순위표. */
export function formatRankResult(teams: TeamRank[], teamCode: TeamCode | null): string {
if (teams.length === 0) return "순위 정보를 못 가져왔어.";
if (!teamCode) {
const rows = teams.map(
(t) => `${t.rank}. ${t.team} ${t.wins}${t.losses}${t.draws}무 (${t.winRate.toFixed(3)})`,
);
return `[KBO 순위]\n${rows.join("\n")}`;
}
const name = KBO_RANK_TEAM_NAMES[teamCode];
const row = teams.find((t) => t.team === name);
if (!row) return `${teamLabel(teamCode)} 순위 정보를 못 찾았어.`;
const streak = row.streak > 0 ? `${row.streak}연승` : row.streak < 0 ? `${-row.streak}연패` : "연승·연패 없음";
const gb = row.gamesBehind > 0 ? `, ${row.gamesBehind}경기차` : "";
return (
`[${teamLabel(teamCode)} 순위] ${row.rank}위 — ${row.wins}${row.losses}${row.draws}` +
`(승률 ${row.winRate.toFixed(3)})${gb}\n` +
`최근10: ${row.last10.wins}${row.last10.losses}패, ${streak}\n` +
`${row.home.wins}-${row.home.losses}-${row.home.draws} / 원정 ${row.away.wins}-${row.away.losses}-${row.away.draws}`
);
}
/**
* 팀간 시즌 상대 전적(h2h) 포맷(순수 함수). KBO 순위표의 팀간 승패표(vsRecords)에서
* 두 팀의 맞대결 성적을 꺼낸다 — 오늘 상대가 아닌 임의 팀과의 전적 질문용.
*/
export function formatH2hResult(team: TeamCode, vs: TeamCode, vsRecords: TeamVsRecord[]): string {
const myName = KBO_RANK_TEAM_NAMES[team];
const oppName = KBO_RANK_TEAM_NAMES[vs];
const wld = vsRecords.find((r) => r.team === myName)?.headToHead[oppName];
if (!wld) return `${teamLabel(team)}${teamLabel(vs)}의 상대 전적 정보를 못 찾았어.`;
return (
`[${teamLabel(team)} vs ${teamLabel(vs)}] 올 시즌 상대 전적 ` +
`${wld.wins}${wld.losses}${wld.draws}무 (${teamLabel(team)} 기준)`
);
}
/** 출석 결과 포맷(순수 함수). 이번 달 출석 일자·누적·포인트 잔액. */
export function formatAttendanceResult(
monthLabel: string,
doc: AttendanceMonthDoc | null,
balance: number,
): string {
if (!doc || doc.days.length === 0) {
return `${monthLabel} 출석 기록이 아직 없어. (현재 포인트 ${balance}점)`;
}
const days = [...doc.days].sort((a, b) => a - b);
return `${monthLabel} 출석 ${days.length}일 (${days.join(", ")}일). 현재 포인트 ${balance}점.`;
}
/**
* 예측 복기 포맷(순수 함수). 그 날짜의 경기별 내 픽·적중 여부에 최종 스코어를 조인한다.
* @param gameById 같은 날짜 일정(gameId → 경기). 종료 스코어 표시에 사용.
*/
export function formatPredictionBreakdown(
dateLabel: string,
doc: VoteHistoryDoc | null,
gameById: Map<string, ScheduleGame>,
): string {
if (!doc || !doc.data || doc.data.length === 0) {
return `${dateLabel} 예측 기록이 없어.`;
}
const lines = doc.data.map((e) => {
const g = gameById.get(e.gameId);
const matchup = g ?
`${teamLabel(g.awayTeamCode as TeamCode)} vs ${teamLabel(g.homeTeamCode as TeamCode)}` :
e.gameId;
const score =
g && g.status === "completed" && g.awayScore != null && g.homeScore != null ?
` ${g.awayScore}:${g.homeScore}` :
"";
const pick = teamLabel(e.team as TeamCode);
// 취소 경기 무효표는 result가 없다 — 오답으로 말하지 않는다.
const outcome = e.cancelled ? "경기 취소(무효)" : e.result ? "적중" : "오답";
return `- ${matchup}${score}${pick} 픽: ${outcome}`;
});
const summary =
doc.correctCount != null && doc.completedCount != null ?
` (${doc.correctCount}/${doc.completedCount} 적중)` :
"";
return `${dateLabel} 예측 복기${summary}\n${lines.join("\n")}`;
}
/** 출석/잔액 월 인자 정규화: "YYYY-MM" 또는 monthOffset(0=이번달, -1=지난달). */
export function resolveToolMonth(args: Record<string, unknown>, today: DateString): string | null {
const cur = today.slice(0, 7);
if (typeof args.month === "string" && /^\d{4}-\d{2}$/.test(args.month.trim())) {
return args.month.trim();
}
const off = args.monthOffset;
if (typeof off === "number" && Number.isFinite(off)) {
const n = Math.trunc(off);
if (Math.abs(n) > 24) return null;
const [y, m] = cur.split("-").map(Number);
const total = (y * 12 + (m - 1)) + n;
const ny = Math.floor(total / 12);
const nm = (total % 12) + 1;
return `${ny}-${String(nm).padStart(2, "0")}`;
}
return cur;
}
/**
* 한 경기의 활약 선수 결과 포맷(순수 함수). KBO가 계산한 경기별 WPA(승리확률
* 기여도) 상위 선수를 그 경기 기록(recordText)과 함께 전한다 — "활약"의 객관 지표.
* @param playerName 특정 선수를 확인하려는 경우, 그 선수가 활약 상위에 있는지 표시.
*/
export function formatGameStandouts(
team: TeamCode,
game: ScheduleGame,
hitters: KeyPlayerRanking | null,
pitchers: KeyPlayerRanking | null,
dateLabel: string,
playerName?: string,
): string {
const isHome = game.homeTeamCode === team;
const oppCode = (isHome ? game.awayTeamCode : game.homeTeamCode) as TeamCode;
const my = isHome ? game.homeScore : game.awayScore;
const opp = isHome ? game.awayScore : game.homeScore;
// 점수는 팀명 순서(우리팀:상대)와 일치시킨다 — "원정:홈" 위치 표기는 앞의 팀명
// 재배열과 어긋나 모델이 홈 승리(8:3)를 "3:8 패배"로 오독하고 서사까지 지어냈다.
const score = my != null && opp != null ? `${my}:${opp}` : "?";
const result = my != null && opp != null ? (my > opp ? "승" : my < opp ? "패" : "무") : "";
const head = `${dateLabel} ${teamLabel(team)} vs ${teamLabel(oppCode)} — 종료 ${score} (${teamLabel(team)} ${result})`;
const hItems = hitters?.items ?? [];
const pItems = pitchers?.items ?? [];
const fmt = (it: { playerName: string; recordText: string }) => `${it.playerName}: ${it.recordText}`;
const sections: string[] = [];
if (hItems.length > 0) {
sections.push(`[활약 타자 (경기 WPA 상위)]\n${hItems.slice(0, 5).map(fmt).join("\n")}`);
}
if (pItems.length > 0) {
sections.push(`[활약 투수 (경기 WPA 상위)]\n${pItems.slice(0, 3).map(fmt).join("\n")}`);
}
let note = "";
if (playerName) {
const all = [...hItems, ...pItems];
const found = all.find(
(it) => it.playerName.includes(playerName) || playerName.includes(it.playerName),
);
note = found ?
`\n→ ${playerName}: ${found.recordText} — 이 경기 활약 상위에 들었어.` :
`\n→ ${playerName}는 이 경기 WPA 상위에는 없어(이 경기에서 크게 활약하진 않았다는 뜻).`;
}
if (sections.length === 0) {
return `${head}\n이 경기의 활약 선수 데이터는 못 가져왔어.${note}`;
}
return `${head}\n${sections.join("\n")}${note}`;
}
const NO_TEAM_NOTICE =
"어느 팀인지 알려줘 — 응원팀이 설정돼 있지 않아서 팀을 특정할 수 없어.";
/**
* 요청 컨텍스트에 묶인 도구 목록을 만든다. provider는 이 목록을 모델에 노출하고,
* 모델이 호출하면 run()을 실행해 그 결과를 다시 모델에 돌려준다.
*/
export function buildChatTools(ctx: ChatToolContext): ChatTool[] {
const year = Number(ctx.date.slice(0, 4));
const lineup: ChatTool = {
name: "get_lineup",
description:
"특정 날짜·팀 경기의 선발 라인업(타순)과 선발 투수를 조회한다. " +
"과거 경기는 실제 출전 라인업과 최종 스코어를, 오늘·미래 경기는 발표된 경우에만 " +
"확정 타순을 전한다(미발표면 예고 선발만). 라인업·타순·누가 나왔/나오는지 물을 때 사용한다.",
parameters: {
type: "object",
properties: {
team: {
type: "string",
description: "팀 코드(예: HH, LG, OB, LT). 생략하면 사용자의 응원팀을 사용한다.",
},
date: {
type: "string",
description:
"특정 날짜를 알 때 사용. \"YYYY-MM-DD\" 또는 \"오늘/어제/그저께/내일\". 생략하면 오늘.",
},
dayOffset: {
type: "integer",
description:
"오늘 기준 상대 일수. 0=오늘, -1=어제, -3=사흘 전, +1=내일. " +
"\"며칠 전\"·\"지난 주말\"처럼 상대 표현이면 날짜를 직접 계산하지 말고 이 정수를 넣어라.",
},
},
additionalProperties: false,
},
async run(args) {
try {
const target = resolveRequestedDate(args, ctx.date);
if (!target) return "어느 날짜인지 알려줘 — YYYY-MM-DD나 \"어제·사흘 전\" 같은 표현이면 돼.";
const team = pickTeam(args, ctx);
if (!team) return NO_TEAM_NOTICE;
const [ty, tm, td] = target.split("-").map(Number);
const dateLabel = target === ctx.date ? "오늘" : target;
const games = (await getSchedule(ty, tm, undefined, undefined, td)).games;
const game = findTeamGame(games, team);
if (!game) return `${dateLabel} ${teamLabel(team)} 경기는 없어.`;
if (game.status === "cancelled") {
return `${dateLabel} ${teamLabel(team)} 경기는 취소됐어${game.note ? `(${game.note})` : ""}.`;
}
if (!game.gameId) return `${dateLabel} ${teamLabel(team)} 경기 정보를 못 가져왔어.`;
const detail = await getGameDetail({ gameId: game.gameId });
return formatLineupResult(team, game, detail.lineup, dateLabel);
} catch (err) {
console.warn("[chat-tool] get_lineup 실패", err);
return "라인업 정보를 지금은 못 가져왔어.";
}
},
};
const roster: ChatTool = {
name: "get_roster",
description:
"선수의 올해 시즌 기록 상위 명단을 조회한다. 팀 주요 타자/투수·시즌 성적을 물을 때 사용한다. " +
"league=true면 팀 구분 없이 리그 전체 상위 선수(예: \"올해 타율 1위\", \"리그 다승 1위\")를 돌려준다.",
parameters: {
type: "object",
properties: {
team: {
type: "string",
description: "팀 코드(예: HH, LG). 생략하면 사용자의 응원팀. league=true면 무시된다.",
},
type: {
type: "string",
enum: ["hitter", "pitcher"],
description: "타자(hitter) 또는 투수(pitcher). 생략하면 타자.",
},
league: {
type: "boolean",
description: "리그 전체 상위 선수를 원하면 true(팀 필터 없음). 특정 팀이면 생략.",
},
},
additionalProperties: false,
},
async run(args) {
try {
const league = args.league === true;
const team = league ? null : pickTeam(args, ctx);
if (!league && !team) return NO_TEAM_NOTICE;
const type = args.type === "pitcher" ? "pitcher" : "hitter";
const result = await getPlayerStats({ type, year, team: team ?? undefined, allPages: false });
return formatRosterResult(team, type, result.columns, result.records, year);
} catch (err) {
console.warn("[chat-tool] get_roster 실패", err);
return "선수 명단 정보를 지금은 못 가져왔어.";
}
},
};
const rankSnapshot: ChatTool = {
name: "get_team_rank_snapshot",
description:
"KBO 팀 순위와 성적을 조회한다. team 지정 시 그 팀의 순위·승패·승률·게임차·최근10경기·" +
"연승연패·홈원정 성적을 상세히, all=true면 전체 순위표를 돌려준다. " +
"vs에 상대 팀을 함께 주면 두 팀의 올 시즌 맞대결 상대 전적(승패무)을 돌려준다. " +
"\"우리 몇 위?\", \"LG 순위\", \"전체 순위 보여줘\", \"LG전 상대 전적 어때?\" 류에 사용한다.",
parameters: {
type: "object",
properties: {
team: {
type: "string",
description: "팀 코드(예: HH, LG). 생략하면 사용자의 응원팀. all=true면 무시.",
},
all: {
type: "boolean",
description: "전체 순위표를 원하면 true. 특정 팀 상세면 생략.",
},
vs: {
type: "string",
description:
"상대 전적을 조회할 상대 팀 코드(예: LG). team(생략 시 응원팀)과 이 팀의 " +
"올 시즌 맞대결 승패무를 돌려준다. 두 팀 간 전적 질문에만 사용.",
},
},
additionalProperties: false,
},
async run(args) {
try {
// 상대 전적(h2h) 경로 — vs 지정 시 순위표의 팀간 승패표(vsRecords)에서 꺼낸다
const vsRaw = typeof args.vs === "string" && args.vs.trim() !== "" ? args.vs.trim() : null;
if (vsRaw) {
const vs = resolveTeamCode(vsRaw);
if (!vs) return "상대 팀은 HH, LG, OB 같은 팀 코드로 알려줘.";
const team = pickTeam(args, ctx);
if (!team) return NO_TEAM_NOTICE;
if (team === vs) return "같은 팀끼리는 상대 전적이 없어 — 서로 다른 두 팀을 알려줘.";
const result = await getRank([year]);
if (!result || result.length === 0 || result[0].vsRecords.length === 0) {
return "상대 전적 정보를 지금은 못 가져왔어.";
}
return formatH2hResult(team, vs, result[0].vsRecords);
}
const team = args.all === true ? null : (resolveTeamCode(args.team) ?? ctx.teamCode);
const result = await getRank([year]);
if (!result || result.length === 0 || !result[0].teams) {
return "순위 정보를 지금은 못 가져왔어.";
}
return formatRankResult(result[0].teams, team);
} catch (err) {
console.warn("[chat-tool] get_team_rank_snapshot 실패", err);
return "순위 정보를 지금은 못 가져왔어.";
}
},
};
const attendance: ChatTool = {
name: "get_my_attendance_this_month",
description:
"사용자 본인의 이번 달(또는 지정 월) 출석 기록과 포인트 잔액을 조회한다. " +
"\"이번 달 며칠 출석했어?\", \"내 포인트 얼마야?\" 류에 사용한다. 본인 데이터만.",
parameters: {
type: "object",
properties: {
month: {
type: "string",
description: "조회 월 \"YYYY-MM\". 생략하면 이번 달.",
},
monthOffset: {
type: "integer",
description: "이번 달 기준 상대 개월(0=이번 달, -1=지난달). \"지난달\" 등 상대 표현에 사용.",
},
},
additionalProperties: false,
},
async run(args) {
try {
const month = resolveToolMonth(args, ctx.date);
if (!month) return "월은 \"YYYY-MM\"이나 \"지난달\" 같은 표현으로 알려줘.";
const [doc, balance] = await Promise.all([
getMonthDoc(ctx.uid, month),
getAvailableBalance(ctx.uid),
]);
return formatAttendanceResult(month, doc, balance);
} catch (err) {
console.warn("[chat-tool] get_my_attendance_this_month 실패", err);
return "출석 정보를 지금은 못 가져왔어.";
}
},
};
const predictionBreakdown: ChatTool = {
name: "get_prediction_breakdown_date",
description:
"사용자 본인이 특정 과거 날짜에 한 경기별 예측과 적중 여부를 최종 스코어와 함께 조회한다. " +
"\"어제 내 예측 어땠어?\", \"5월 3일에 뭐 찍었고 맞았어?\" 류에 사용한다. 본인 데이터만, 채점 완료된 과거 날짜용.",
parameters: {
type: "object",
properties: {
date: {
type: "string",
description: "조회 날짜 \"YYYY-MM-DD\" 또는 \"어제/그저께\".",
},
dayOffset: {
type: "integer",
description: "오늘 기준 상대 일수(-1=어제, -3=사흘 전). 상대 표현에 사용.",
},
},
additionalProperties: false,
},
async run(args) {
try {
const target = resolveRequestedDate(args, ctx.date);
if (!target) return "어느 날짜인지 알려줘 — YYYY-MM-DD나 \"어제·사흘 전\" 같은 표현이면 돼.";
const [ty, tm, td] = target.split("-").map(Number);
const dateLabel = target === ctx.date ? "오늘" : target;
const [doc, games] = await Promise.all([
getDay(ctx.uid, target),
getSchedule(ty, tm, undefined, undefined, td).then((r) => r.games).catch(() => [] as ScheduleGame[]),
]);
const gameById = new Map(
games.filter((g) => g.gameId).map((g) => [g.gameId as string, g]),
);
return formatPredictionBreakdown(dateLabel, doc, gameById);
} catch (err) {
console.warn("[chat-tool] get_prediction_breakdown_date 실패", err);
return "예측 기록을 지금은 못 가져왔어.";
}
},
};
const standouts: ChatTool = {
name: "get_game_standouts",
description:
"특정 날짜·팀의 종료된 경기에서 활약한 선수를 조회한다. 경기별 WPA(승리확률 " +
"기여도) 상위 타자·투수를 그 경기 기록과 함께 돌려준다. " +
"특정 선수의 '명경기·가장 활약한 경기'를 검증할 때, 후보 날짜를 넣어 그 경기에서 " +
"실제로 활약했는지 사실 확인하는 용도로 쓴다. player 인자로 특정 선수를 지정하면 " +
"그 선수가 활약 상위에 들었는지 표시한다.",
parameters: {
type: "object",
properties: {
team: {
type: "string",
description: "팀 코드(예: HH, OB, NC). 생략하면 사용자의 응원팀.",
},
date: {
type: "string",
description: "조회 날짜 \"YYYY-MM-DD\" 또는 \"어제/그저께\" 등. 생략하면 오늘.",
},
dayOffset: {
type: "integer",
description: "오늘 기준 상대 일수(0=오늘, -1=어제). 상대 표현이면 이 정수를 쓴다.",
},
player: {
type: "string",
description: "활약 여부를 확인할 선수 이름(선택). 예: \"양의지\".",
},
},
additionalProperties: false,
},
async run(args) {
try {
const target = resolveRequestedDate(args, ctx.date);
if (!target) return "어느 날짜인지 알려줘 — YYYY-MM-DD나 \"어제·사흘 전\" 같은 표현이면 돼.";
const team = pickTeam(args, ctx);
if (!team) return NO_TEAM_NOTICE;
const [ty, tm, td] = target.split("-").map(Number);
const dateLabel = target === ctx.date ? "오늘" : target;
const games = (await getSchedule(ty, tm, undefined, undefined, td)).games;
const game = findTeamGame(games, team);
if (!game) return `${dateLabel} ${teamLabel(team)} 경기는 없어.`;
if (game.status !== "completed") {
return `${dateLabel} ${teamLabel(team)} 경기는 아직 끝나지 않았어 — 활약·기록은 종료 후에 확인돼.`;
}
if (!game.gameId) return `${dateLabel} ${teamLabel(team)} 경기 정보를 못 가져왔어.`;
const detail = await getGameDetail({ gameId: game.gameId });
const player = typeof args.player === "string" && args.player.trim() !== "" ?
args.player.trim() :
undefined;
return formatGameStandouts(
team, game, detail.keyPlayer.hitter, detail.keyPlayer.pitcher, dateLabel, player,
);
} catch (err) {
console.warn("[chat-tool] get_game_standouts 실패", err);
return "그 경기 기록을 지금은 못 가져왔어.";
}
},
};
return [lineup, roster, standouts, rankSnapshot, attendance, predictionBreakdown];
}