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 = { 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, 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, 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 { 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, 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]; }