diff --git a/src/repositories/chatRepository.ts b/src/repositories/chatRepository.ts index a9c1980..5d9d2d1 100644 --- a/src/repositories/chatRepository.ts +++ b/src/repositories/chatRepository.ts @@ -11,6 +11,7 @@ import type { ChatReportReason, ChatRequestDoc, ChatThreadDoc, + ChatToolCallInfo, } from "../types/chat"; import type { TeamCode } from "../types/panit"; import { addDaysKst, type DateString } from "../types/dateString"; @@ -293,6 +294,8 @@ export interface ExchangeParams { promptVersion?: string; filtered: boolean; crisis: boolean; + /** assistant 응답 생성 중 호출한 도구(이름+인자). 비었으면 미저장. */ + toolCalls?: ChatToolCallInfo[]; retentionDays: number; } @@ -337,6 +340,7 @@ export async function finalizeExchange(params: ExchangeParams): Promise 0 ? { toolCalls: params.toolCalls } : {}), }; batch.set(messagesCol(params.uid, params.threadId).doc(assistantMessageId), assistantDoc); diff --git a/src/services/chatProviderService.ts b/src/services/chatProviderService.ts index 4ad3d5d..17df7e6 100644 --- a/src/services/chatProviderService.ts +++ b/src/services/chatProviderService.ts @@ -2,7 +2,7 @@ import Anthropic from "@anthropic-ai/sdk"; import { GoogleGenAI, ThinkingLevel, type Content } from "@google/genai"; import { HttpError } from "../middleware/errors"; import { backoffSumMs, TOTAL_AI_BUDGET_MS } from "./chatConfigService"; -import type { ChatProviderConfig } from "../types/chat"; +import type { ChatProviderConfig, ChatToolCallInfo } from "../types/chat"; /** * AI Provider 추상화(§8) — 특정 벤더에 묶지 않는다. @@ -45,7 +45,10 @@ const MAX_TOOL_ROUNDS = 3; export interface ChatProviderResult { reply: string; finishReason: "stop" | "length" | "filtered" | "error"; - usage: { inputTokens: number; outputTokens: number }; + /** cachedTokens: inputTokens 중 캐시 히트로 할인되는 토큰 수(암묵적/명시적 캐싱). */ + usage: { inputTokens: number; outputTokens: number; cachedTokens: number }; + /** 응답 생성 중 모델이 호출한 도구(이름+인자, 라운드 누적). 미사용 provider는 빈 배열. */ + toolCalls: ChatToolCallInfo[]; } export interface ChatProvider { @@ -84,7 +87,8 @@ class MockChatProvider implements ChatProvider { return { reply: `오! 알아봤어. ${out} 짹!`, finishReason: "stop", - usage: { inputTokens: 0, outputTokens: 0 }, + usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }, + toolCalls: [{ name: tool.name, args: toolArgs }], }; } } @@ -101,7 +105,8 @@ class MockChatProvider implements ChatProvider { return { reply, finishReason: "stop", - usage: { inputTokens: 0, outputTokens: 0 }, + usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }, + toolCalls: [], }; } } @@ -147,6 +152,7 @@ class AnthropicChatProvider implements ChatProvider { let inputTokens = 0; let outputTokens = 0; + const toolCalls: ChatToolCallInfo[] = []; for (let round = 0; ; round++) { const remaining = deadline - Date.now(); @@ -174,10 +180,10 @@ class AnthropicChatProvider implements ChatProvider { const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of res.content) { if (block.type === "tool_use") { + const args = (block.input ?? {}) as Record; + toolCalls.push({ name: block.name, args }); const tool = input.tools?.find((t) => t.name === block.name); - const out = tool ? - await tool.run((block.input ?? {}) as Record) : - `알 수 없는 도구: ${block.name}`; + const out = tool ? await tool.run(args) : `알 수 없는 도구: ${block.name}`; toolResults.push({ type: "tool_result", tool_use_id: block.id, content: out }); } } @@ -206,7 +212,8 @@ class AnthropicChatProvider implements ChatProvider { return { reply, finishReason, - usage: { inputTokens, outputTokens }, + usage: { inputTokens, outputTokens, cachedTokens: 0 }, + toolCalls, }; } } @@ -214,23 +221,6 @@ class AnthropicChatProvider implements ChatProvider { // ── Vertex AI provider (기본 벤더 — Gemini on Vertex, 공식 @google/genai SDK) ── -/** - * 모델 세대별 thinking 설정(§8). Gemini 3+는 `thinkingLevel`, 2.5는 `thinkingBudget`을 - * 쓴다 — 3 모델에 둘을 함께 주거나 레거시 `thinkingBudget`만 줘도 동작이 달라지므로 - * 분기한다(둘 다 지정 시 400). 짧은 캐릭터 응답이지만 도구 호출·다단계 검증(CoT)을 - * 살려야 하므로 3+는 완전 비활성(MINIMAL) 대신 LOW로 둔다. - */ -function thinkingConfigFor(model: string): Record { - if (/gemini-3/i.test(model)) { - return { thinkingConfig: { thinkingLevel: ThinkingLevel.LOW } }; - } - // 2.5 이하 flash 계열: thinkingBudget 0 = 비활성(지연·비용 절감) - if (/flash/i.test(model)) { - return { thinkingConfig: { thinkingBudget: 0 } }; - } - return {}; -} - /** * Cloud Functions 환경에서는 ADC(Application Default Credentials)로 인증되므로 * 별도 API 키가 필요 없다. 프로젝트는 GCLOUD_PROJECT, 리전은 VERTEX_LOCATION @@ -254,7 +244,6 @@ class VertexChatProvider implements ChatProvider { const client = this.getClient(); const cfg = input.config; const deadline = input.deadlineMs ?? Date.now() + cfg.timeoutMs; - const thinking = thinkingConfigFor(cfg.model); const contents: Content[] = input.messages.map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }], @@ -272,6 +261,8 @@ class VertexChatProvider implements ChatProvider { let inputTokens = 0; let outputTokens = 0; + let cachedTokens = 0; + const toolCalls: ChatToolCallInfo[] = []; for (let round = 0; ; round++) { const remaining = deadline - Date.now(); @@ -290,8 +281,9 @@ class VertexChatProvider implements ChatProvider { maxOutputTokens: cfg.maxOutputTokens, abortSignal: controller.signal, ...(toolConfig ? { tools: toolConfig } : {}), - // 모델 세대별 thinking 설정(3+: thinkingLevel, 2.5: thinkingBudget) - ...thinking, + // Gemini 3.0+ 전용 — thinkingLevel만 사용(thinkingBudget은 2.x 레거시). + // 도구 호출·다단계 검증(CoT)을 살리되 짧은 응답이라 LOW. + thinkingConfig: { thinkingLevel: ThinkingLevel.LOW }, }, }); } finally { @@ -299,6 +291,7 @@ class VertexChatProvider implements ChatProvider { } inputTokens += res.usageMetadata?.promptTokenCount ?? 0; outputTokens += res.usageMetadata?.candidatesTokenCount ?? 0; + cachedTokens += res.usageMetadata?.cachedContentTokenCount ?? 0; // 함수 호출 요청 — 실행 후 functionResponse로 돌려주고 루프 계속 const calls = res.functionCalls; @@ -309,10 +302,10 @@ class VertexChatProvider implements ChatProvider { ); const parts = []; for (const call of calls) { + const args = call.args ?? {}; + toolCalls.push({ name: call.name ?? "", args }); const tool = input.tools.find((t) => t.name === call.name); - const out = tool ? - await tool.run(call.args ?? {}) : - `알 수 없는 도구: ${call.name}`; + const out = tool ? await tool.run(args) : `알 수 없는 도구: ${call.name}`; parts.push({ functionResponse: { name: call.name ?? "", response: { output: out } } }); } contents.push({ role: "user", parts }); @@ -340,7 +333,8 @@ class VertexChatProvider implements ChatProvider { return { reply, finishReason, - usage: { inputTokens, outputTokens }, + usage: { inputTokens, outputTokens, cachedTokens }, + toolCalls, }; } } diff --git a/src/services/chatService.ts b/src/services/chatService.ts index fc14322..d7b143b 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -33,7 +33,7 @@ import { buildChatTools } from "./chatToolService"; import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts"; import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatMessageView, type ChatQuotaView, type ChatReportReason, type ChatSendResult, type ChatSuggestion, - type ChatSuggestionsView } from "../types/chat"; + type ChatSuggestionsView, type ChatToolCallInfo } from "../types/chat"; import type { User } from "../types/panit"; import { todayKst, type DateString } from "../types/dateString"; @@ -87,6 +87,7 @@ async function buildSendResult( reply: string, crisis: boolean, createdAt: Timestamp, + toolCalls?: ChatToolCallInfo[], ): Promise { const { used } = await quotaView(uid, date); return { @@ -96,6 +97,7 @@ async function buildSendResult( remainingCount: Math.max(0, config.dailyLimit - used), limit: config.dailyLimit, createdAt: toKstIso(createdAt), + ...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}), }; } @@ -112,7 +114,9 @@ async function replayDone( // done 마킹과 메시지 저장은 단일 배치이므로 정상 경로에서는 도달 불가 throw new HttpError(503, "stored reply missing", "AI_UNAVAILABLE"); } - return buildSendResult(uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt); + return buildSendResult( + uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt, stored.toolCalls, + ); } /** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */ @@ -255,6 +259,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise = { pitcher: ["era", "wins", "losses", "saves", "holds", "so"], }; -/** 로스터 결과 포맷(순수 함수). 시즌 기록 상위 N명을 핵심 스탯과 함께 전한다. */ +/** + * 로스터 결과 포맷(순수 함수). 시즌 기록 상위 N명을 핵심 스탯과 함께 전한다. + * @param team null이면 리그 전체(팀 필터 없음) — "올해 타율 1위" 등 리그 랭킹용. + */ export function formatRosterResult( - team: TeamCode, + team: TeamCode | null, type: "hitter" | "pitcher", columns: readonly string[], records: PlayerRecord[], @@ -152,15 +162,101 @@ export function formatRosterResult( ): string { const top = records.slice(0, limit); const kind = type === "pitcher" ? "주요 투수" : "주요 타자"; + const who = team ? teamLabel(team) : "리그"; if (top.length === 0) { - return `${teamLabel(team)} ${kind} 시즌 기록을 못 찾았어.`; + 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 `[${teamLabel(team)} ${kind} (시즌 기록 상위, ${year})]\n${lines.join("\n")}`; + 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}` + ); +} + +/** 출석 결과 포맷(순수 함수). 이번 달 출석 일자·누적·포인트 잔액. */ +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); + return `- ${matchup}${score} → ${pick} 픽: ${e.result ? "적중" : "오답"}`; + }); + 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; } /** @@ -277,29 +373,34 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] { const roster: ChatTool = { name: "get_roster", description: - "특정 팀의 주요 선수(타자 또는 투수)와 올해 시즌 기록을 조회한다. " + - "팀 선수 명단·핵심 타자/투수·시즌 성적을 물을 때 사용한다.", + "선수의 올해 시즌 기록 상위 명단을 조회한다. 팀 주요 타자/투수·시즌 성적을 물을 때 사용한다. " + + "league=true면 팀 구분 없이 리그 전체 상위 선수(예: \"올해 타율 1위\", \"리그 다승 1위\")를 돌려준다.", parameters: { type: "object", properties: { team: { type: "string", - description: "팀 코드(예: HH, LG). 생략하면 사용자의 응원팀을 사용한다.", + 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 team = pickTeam(args, ctx); - if (!team) return NO_TEAM_NOTICE; + 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, allPages: false }); + 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); @@ -308,6 +409,116 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] { }, }; + const rankSnapshot: ChatTool = { + name: "get_team_rank_snapshot", + description: + "KBO 팀 순위와 성적을 조회한다. team 지정 시 그 팀의 순위·승패·승률·게임차·최근10경기·" + + "연승연패·홈원정 성적을 상세히, all=true면 전체 순위표를 돌려준다. " + + "\"우리 몇 위?\", \"LG 순위\", \"전체 순위 보여줘\" 류에 사용한다.", + parameters: { + type: "object", + properties: { + team: { + type: "string", + description: "팀 코드(예: HH, LG). 생략하면 사용자의 응원팀. all=true면 무시.", + }, + all: { + type: "boolean", + description: "전체 순위표를 원하면 true. 특정 팀 상세면 생략.", + }, + }, + additionalProperties: false, + }, + async run(args) { + try { + 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), + getLatestBalance(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: @@ -367,5 +578,5 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] { }, }; - return [lineup, roster, standouts]; + return [lineup, roster, standouts, rankSnapshot, attendance, predictionBreakdown]; } diff --git a/src/types/chat.ts b/src/types/chat.ts index 7e9095a..b870bdf 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -11,6 +11,15 @@ import type { TeamCode } from "./panit"; export type ChatRole = "user" | "assistant"; +/** + * 모델이 응답 생성 중 호출한 도구 1건 — 이름 + 인자(모델이 고른 값). + * 도구 출력(내부 그라운딩 텍스트)은 담지 않는다. assistant 메시지에만 부착. + */ +export interface ChatToolCallInfo { + name: string; + args: Record; +} + /** `users/{uid}/chatThreads/{threadId}` — threadId는 팀 코드 또는 "default". */ export interface ChatThreadDoc { teamCode?: TeamCode; @@ -38,6 +47,8 @@ export interface ChatMessageDoc { filtered: boolean; /** 위기 전환 응답 여부(§7.3). */ crisis: boolean; + /** assistant 메시지만 — 응답 생성 중 호출한 도구 목록(이름+인자). 호출 없으면 미저장. */ + toolCalls?: ChatToolCallInfo[]; /** TTL 삭제 시각 = createdAt + 보관 기간(§4.3). */ expireAt: Timestamp; } @@ -150,6 +161,7 @@ export interface ChatConfig { crisisThresholdPerDay: number; /** 보관 기간(일) — expireAt 계산 상수(§4.3, D-4 회신 시 변경). */ retentionDays: number; + /** 라이브 provider(DB 미설정 시 코드 기본 = lite). 모든 모델 공평 타임아웃이라 단일 필드로 충분. */ provider: ChatProviderConfig; promptVersion: string; /** 공통 시스템 프롬프트 — 빈 문자열이면 내장 기본문(페르소나 문서 2.3) 사용. */ @@ -174,6 +186,8 @@ export interface ChatSendResult { remainingCount: number | null; limit: number | null; createdAt: string; + /** 응답 생성 중 호출한 도구(이름+인자). 호출 없으면 생략. 클라 활용 선택. */ + toolCalls?: ChatToolCallInfo[]; } export interface ChatMessageView { @@ -183,6 +197,8 @@ export interface ChatMessageView { crisis: boolean; createdAt: string; clientMessageId?: string; + /** assistant 메시지의 도구 호출(이름+인자). 없으면 생략. */ + toolCalls?: ChatToolCallInfo[]; } export interface ChatMessagesPage { diff --git a/tests/services/chatService.test.ts b/tests/services/chatService.test.ts index 789c438..9b084a6 100644 --- a/tests/services/chatService.test.ts +++ b/tests/services/chatService.test.ts @@ -171,6 +171,39 @@ describe("chatService", () => { expect((await readQuota()).used).toBe(1); }); + it("도구 호출 메타(이름+인자)를 응답과 이력에 노출한다", async () => { + const calls = [{ name: "get_lineup", args: { date: "2026-05-01" } }]; + setTestProvider({ + complete: async () => ({ + reply: "오늘 선발은 김민준이야 짹", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, cachedTokens: 0 }, + toolCalls: calls, + }), + }); + const result = await sendMessage(uid, { message: "오늘 선발 누구?", clientMessageId: newUuid() }); + expect(result.toolCalls).toEqual(calls); + + // GET 이력의 assistant 메시지에도 toolCalls가 보인다(나중에 클라 활용) + const page = await getMessages(uid, undefined, undefined); + const assistant = page.messages.find((m) => m.role === "assistant"); + expect(assistant?.toolCalls).toEqual(calls); + }); + + it("출력이 교체(crisis/filtered)되면 도구 메타를 붙이지 않는다", async () => { + setTestProvider({ + complete: async () => ({ + reply: "[[CRISIS]] 잠깐, 진지하게…", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, cachedTokens: 0 }, + toolCalls: [{ name: "get_lineup", args: {} }], + }), + }); + const result = await sendMessage(uid, { message: "오늘 선발 누구?", clientMessageId: newUuid() }); + expect(result.crisis).toBe(true); + expect(result.toolCalls).toBeUndefined(); + }); + it("시스템 프롬프트에 검증된 컨텍스트를 주입한다(§5.4)", async () => { fixtures.todayGames = [HH_GAME]; fixtures.rank = { diff --git a/tests/services/chatToolService.test.ts b/tests/services/chatToolService.test.ts index 00facc3..40e8eea 100644 --- a/tests/services/chatToolService.test.ts +++ b/tests/services/chatToolService.test.ts @@ -1,15 +1,21 @@ import { describe, expect, it, vi } from "vitest"; import { buildChatTools, + formatAttendanceResult, formatGameStandouts, formatLineupResult, + formatPredictionBreakdown, + formatRankResult, formatRosterResult, pickTeam, resolveRequestedDate, resolveToolDate, + resolveToolMonth, type ChatToolContext, } from "../../src/services/chatToolService"; import type { KeyPlayerRanking } from "../../src/kbo/game-detail"; +import type { TeamRank } from "../../src/kbo/team-rank"; +import type { AttendanceMonthDoc, VoteHistoryDoc } from "../../src/types/panit"; import { getChatProvider } from "../../src/services/chatProviderService"; import { DEFAULT_PROVIDER_CONFIG } from "../../src/services/chatConfigService"; import { TeamCode } from "../../src/types/panit"; @@ -52,7 +58,7 @@ function lineup(overrides: Partial): Lineup { }; } -const ctx: ChatToolContext = { teamCode: TeamCode.HH, date: "2026-06-15" as DateString }; +const ctx: ChatToolContext = { uid: "u1", teamCode: TeamCode.HH, date: "2026-06-15" as DateString }; describe("chatToolService", () => { describe("pickTeam — 인자 우선, 컨텍스트 fallback(§5.4 화이트리스트)", () => { @@ -186,6 +192,94 @@ describe("chatToolService", () => { it("기록이 없으면 부재를 알린다", () => { expect(formatRosterResult(TeamCode.HH, "hitter", hitterCols, [], 2026)).toContain("못 찾았어"); }); + + it("team=null이면 리그 전체로 표기한다(get_roster league 확장)", () => { + const out = formatRosterResult(null, "hitter", hitterCols, records, 2026); + expect(out).toContain("[리그 주요 타자"); + expect(out).toContain("1번타자"); + }); + }); + + describe("formatRankResult — 순위 스냅샷", () => { + function rankRow(over: Partial): TeamRank { + return { + rank: 1, team: "한화", games: 50, wins: 30, losses: 18, draws: 2, winRate: 0.625, + gamesBehind: 0, last10: { wins: 7, losses: 3 }, streak: 3, + home: { wins: 18, losses: 7, draws: 1 }, away: { wins: 12, losses: 11, draws: 1 }, + ...over, + }; + } + const teams = [rankRow({}), rankRow({ rank: 2, team: "LG", wins: 28, losses: 20, winRate: 0.583, gamesBehind: 2, streak: -2 })]; + + it("팀 지정 시 상세(순위·승패·승률·최근10·연승·홈원정)를 전한다", () => { + const out = formatRankResult(teams, TeamCode.HH); + expect(out).toContain("1위"); + expect(out).toContain("30승 18패 2무"); + expect(out).toContain("3연승"); + expect(out).toContain("홈 18-7-1 / 원정 12-11-1"); + }); + + it("음수 streak은 연패로 표기한다", () => { + expect(formatRankResult(teams, TeamCode.LG)).toContain("2연패"); + }); + + it("team=null이면 전체 순위표를 전한다", () => { + const out = formatRankResult(teams, null); + expect(out).toContain("[KBO 순위]"); + expect(out).toContain("1. 한화"); + expect(out).toContain("2. LG"); + }); + }); + + describe("formatAttendanceResult — 출석·포인트", () => { + it("출석 일자·누적·잔액을 전한다", () => { + const doc = { days: [3, 1, 2], lastCheckedInAt: null } as unknown as AttendanceMonthDoc; + const out = formatAttendanceResult("2026-06", doc, 240); + expect(out).toContain("출석 3일"); + expect(out).toContain("1, 2, 3일"); // 정렬됨 + expect(out).toContain("240점"); + }); + it("기록이 없으면 잔액만 알린다", () => { + expect(formatAttendanceResult("2026-06", null, 50)).toContain("아직 없어"); + }); + }); + + describe("formatPredictionBreakdown — 과거 예측 복기 + 스코어 조인", () => { + const doc: VoteHistoryDoc = { + data: [ + { gameId: "20260501LGHH0", team: "HH", result: true }, + { gameId: "20260501OBNC0", team: "OB", result: false }, + ], + correctCount: 1, + completedCount: 2, + }; + it("경기별 픽·적중과 최종 스코어를 함께 전한다", () => { + const gameById = new Map([ + ["20260501LGHH0", game({ gameId: "20260501LGHH0", status: "completed", awayScore: 2, homeScore: 5, awayTeamCode: "LG", homeTeamCode: "HH" })], + ]); + const out = formatPredictionBreakdown("2026-05-01", doc, gameById); + expect(out).toContain("1/2 적중"); + expect(out).toContain("2:5"); + expect(out).toContain("픽: 적중"); + expect(out).toContain("픽: 오답"); + }); + it("기록이 없으면 부재를 알린다", () => { + expect(formatPredictionBreakdown("2026-05-01", null, new Map())).toContain("예측 기록이 없어"); + }); + }); + + describe("resolveToolMonth — 월 정규화", () => { + const today = "2026-06-15" as DateString; + it("생략 시 이번 달", () => { + expect(resolveToolMonth({}, today)).toBe("2026-06"); + }); + it("YYYY-MM 절대 월", () => { + expect(resolveToolMonth({ month: "2026-03" }, today)).toBe("2026-03"); + }); + it("monthOffset 상대(연 경계 포함)", () => { + expect(resolveToolMonth({ monthOffset: -1 }, today)).toBe("2026-05"); + expect(resolveToolMonth({ monthOffset: -6 }, today)).toBe("2025-12"); + }); }); describe("provider tool-loop 배선 — mock provider가 run()을 실제 실행한다", () => { @@ -260,11 +354,16 @@ describe("chatToolService", () => { }); describe("buildChatTools — 노출 계약", () => { - it("get_lineup·get_roster·get_game_standouts 세 도구를 만든다(get_manager는 TODO 제외)", () => { + it("6개 도구를 만든다(get_manager는 TODO 제외)", () => { const tools = buildChatTools(ctx); - expect(tools.map((t) => t.name).sort()).toEqual( - ["get_game_standouts", "get_lineup", "get_roster"], - ); + expect(tools.map((t) => t.name).sort()).toEqual([ + "get_game_standouts", + "get_lineup", + "get_my_attendance_this_month", + "get_prediction_breakdown_date", + "get_roster", + "get_team_rank_snapshot", + ]); for (const t of tools) { expect(t.parameters).toMatchObject({ type: "object", additionalProperties: false }); }