Add AI chat tools for team standings, attendance, and predictions.

순위(get_team_rank_snapshot)·출석(get_my_attendance_this_month)·예측 복기
(get_prediction_breakdown_date) 도구를 추가하고, 상대 날짜(dayOffset)와
리그 전체 로스터 조회를 지원한다.

모델이 호출한 도구(이름·인자)를 전송 응답과 메시지 이력에 함께 실어
클라이언트가 나중에 활용할 수 있게 한다.
This commit is contained in:
윤정민 2026-06-16 16:51:53 +09:00
parent 360c0d356e
commit 1ab744e660
7 changed files with 425 additions and 55 deletions

View File

@ -11,6 +11,7 @@ import type {
ChatReportReason, ChatReportReason,
ChatRequestDoc, ChatRequestDoc,
ChatThreadDoc, ChatThreadDoc,
ChatToolCallInfo,
} from "../types/chat"; } from "../types/chat";
import type { TeamCode } from "../types/panit"; import type { TeamCode } from "../types/panit";
import { addDaysKst, type DateString } from "../types/dateString"; import { addDaysKst, type DateString } from "../types/dateString";
@ -293,6 +294,8 @@ export interface ExchangeParams {
promptVersion?: string; promptVersion?: string;
filtered: boolean; filtered: boolean;
crisis: boolean; crisis: boolean;
/** assistant 응답 생성 중 호출한 도구(이름+인자). 비었으면 미저장. */
toolCalls?: ChatToolCallInfo[];
retentionDays: number; retentionDays: number;
} }
@ -337,6 +340,7 @@ export async function finalizeExchange(params: ExchangeParams): Promise<Exchange
expireAt, expireAt,
...(params.model ? { model: params.model } : {}), ...(params.model ? { model: params.model } : {}),
...(params.promptVersion ? { promptVersion: params.promptVersion } : {}), ...(params.promptVersion ? { promptVersion: params.promptVersion } : {}),
...(params.toolCalls && params.toolCalls.length > 0 ? { toolCalls: params.toolCalls } : {}),
}; };
batch.set(messagesCol(params.uid, params.threadId).doc(assistantMessageId), assistantDoc); batch.set(messagesCol(params.uid, params.threadId).doc(assistantMessageId), assistantDoc);

View File

@ -2,7 +2,7 @@ import Anthropic from "@anthropic-ai/sdk";
import { GoogleGenAI, ThinkingLevel, type Content } from "@google/genai"; import { GoogleGenAI, ThinkingLevel, type Content } from "@google/genai";
import { HttpError } from "../middleware/errors"; import { HttpError } from "../middleware/errors";
import { backoffSumMs, TOTAL_AI_BUDGET_MS } from "./chatConfigService"; import { backoffSumMs, TOTAL_AI_BUDGET_MS } from "./chatConfigService";
import type { ChatProviderConfig } from "../types/chat"; import type { ChatProviderConfig, ChatToolCallInfo } from "../types/chat";
/** /**
* AI Provider (§8) . * AI Provider (§8) .
@ -45,7 +45,10 @@ const MAX_TOOL_ROUNDS = 3;
export interface ChatProviderResult { export interface ChatProviderResult {
reply: string; reply: string;
finishReason: "stop" | "length" | "filtered" | "error"; 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 { export interface ChatProvider {
@ -84,7 +87,8 @@ class MockChatProvider implements ChatProvider {
return { return {
reply: `오! 알아봤어. ${out} 짹!`, reply: `오! 알아봤어. ${out} 짹!`,
finishReason: "stop", 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 { return {
reply, reply,
finishReason: "stop", 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 inputTokens = 0;
let outputTokens = 0; let outputTokens = 0;
const toolCalls: ChatToolCallInfo[] = [];
for (let round = 0; ; round++) { for (let round = 0; ; round++) {
const remaining = deadline - Date.now(); const remaining = deadline - Date.now();
@ -174,10 +180,10 @@ class AnthropicChatProvider implements ChatProvider {
const toolResults: Anthropic.ToolResultBlockParam[] = []; const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) { for (const block of res.content) {
if (block.type === "tool_use") { if (block.type === "tool_use") {
const args = (block.input ?? {}) as Record<string, unknown>;
toolCalls.push({ name: block.name, args });
const tool = input.tools?.find((t) => t.name === block.name); const tool = input.tools?.find((t) => t.name === block.name);
const out = tool ? const out = tool ? await tool.run(args) : `알 수 없는 도구: ${block.name}`;
await tool.run((block.input ?? {}) as Record<string, unknown>) :
`알 수 없는 도구: ${block.name}`;
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: out }); toolResults.push({ type: "tool_result", tool_use_id: block.id, content: out });
} }
} }
@ -206,7 +212,8 @@ class AnthropicChatProvider implements ChatProvider {
return { return {
reply, reply,
finishReason, 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) ── // ── 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<string, unknown> {
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) * Cloud Functions ADC(Application Default Credentials)
* API . GCLOUD_PROJECT, VERTEX_LOCATION * API . GCLOUD_PROJECT, VERTEX_LOCATION
@ -254,7 +244,6 @@ class VertexChatProvider implements ChatProvider {
const client = this.getClient(); const client = this.getClient();
const cfg = input.config; const cfg = input.config;
const deadline = input.deadlineMs ?? Date.now() + cfg.timeoutMs; const deadline = input.deadlineMs ?? Date.now() + cfg.timeoutMs;
const thinking = thinkingConfigFor(cfg.model);
const contents: Content[] = input.messages.map((m) => ({ const contents: Content[] = input.messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user", role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }], parts: [{ text: m.content }],
@ -272,6 +261,8 @@ class VertexChatProvider implements ChatProvider {
let inputTokens = 0; let inputTokens = 0;
let outputTokens = 0; let outputTokens = 0;
let cachedTokens = 0;
const toolCalls: ChatToolCallInfo[] = [];
for (let round = 0; ; round++) { for (let round = 0; ; round++) {
const remaining = deadline - Date.now(); const remaining = deadline - Date.now();
@ -290,8 +281,9 @@ class VertexChatProvider implements ChatProvider {
maxOutputTokens: cfg.maxOutputTokens, maxOutputTokens: cfg.maxOutputTokens,
abortSignal: controller.signal, abortSignal: controller.signal,
...(toolConfig ? { tools: toolConfig } : {}), ...(toolConfig ? { tools: toolConfig } : {}),
// 모델 세대별 thinking 설정(3+: thinkingLevel, 2.5: thinkingBudget) // Gemini 3.0+ 전용 — thinkingLevel만 사용(thinkingBudget은 2.x 레거시).
...thinking, // 도구 호출·다단계 검증(CoT)을 살리되 짧은 응답이라 LOW.
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
}, },
}); });
} finally { } finally {
@ -299,6 +291,7 @@ class VertexChatProvider implements ChatProvider {
} }
inputTokens += res.usageMetadata?.promptTokenCount ?? 0; inputTokens += res.usageMetadata?.promptTokenCount ?? 0;
outputTokens += res.usageMetadata?.candidatesTokenCount ?? 0; outputTokens += res.usageMetadata?.candidatesTokenCount ?? 0;
cachedTokens += res.usageMetadata?.cachedContentTokenCount ?? 0;
// 함수 호출 요청 — 실행 후 functionResponse로 돌려주고 루프 계속 // 함수 호출 요청 — 실행 후 functionResponse로 돌려주고 루프 계속
const calls = res.functionCalls; const calls = res.functionCalls;
@ -309,10 +302,10 @@ class VertexChatProvider implements ChatProvider {
); );
const parts = []; const parts = [];
for (const call of calls) { 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 tool = input.tools.find((t) => t.name === call.name);
const out = tool ? const out = tool ? await tool.run(args) : `알 수 없는 도구: ${call.name}`;
await tool.run(call.args ?? {}) :
`알 수 없는 도구: ${call.name}`;
parts.push({ functionResponse: { name: call.name ?? "", response: { output: out } } }); parts.push({ functionResponse: { name: call.name ?? "", response: { output: out } } });
} }
contents.push({ role: "user", parts }); contents.push({ role: "user", parts });
@ -340,7 +333,8 @@ class VertexChatProvider implements ChatProvider {
return { return {
reply, reply,
finishReason, finishReason,
usage: { inputTokens, outputTokens }, usage: { inputTokens, outputTokens, cachedTokens },
toolCalls,
}; };
} }
} }

View File

@ -33,7 +33,7 @@ import { buildChatTools } from "./chatToolService";
import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts"; import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts";
import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatMessageView, import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatMessageView,
type ChatQuotaView, type ChatReportReason, type ChatSendResult, type ChatSuggestion, 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 type { User } from "../types/panit";
import { todayKst, type DateString } from "../types/dateString"; import { todayKst, type DateString } from "../types/dateString";
@ -87,6 +87,7 @@ async function buildSendResult(
reply: string, reply: string,
crisis: boolean, crisis: boolean,
createdAt: Timestamp, createdAt: Timestamp,
toolCalls?: ChatToolCallInfo[],
): Promise<ChatSendResult> { ): Promise<ChatSendResult> {
const { used } = await quotaView(uid, date); const { used } = await quotaView(uid, date);
return { return {
@ -96,6 +97,7 @@ async function buildSendResult(
remainingCount: Math.max(0, config.dailyLimit - used), remainingCount: Math.max(0, config.dailyLimit - used),
limit: config.dailyLimit, limit: config.dailyLimit,
createdAt: toKstIso(createdAt), createdAt: toKstIso(createdAt),
...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}),
}; };
} }
@ -112,7 +114,9 @@ async function replayDone(
// done 마킹과 메시지 저장은 단일 배치이므로 정상 경로에서는 도달 불가 // done 마킹과 메시지 저장은 단일 배치이므로 정상 경로에서는 도달 불가
throw new HttpError(503, "stored reply missing", "AI_UNAVAILABLE"); 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턴, 최대 나이, 위기/필터 제외. */ /** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */
@ -255,6 +259,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
let saved: ExchangeResult | null = null; let saved: ExchangeResult | null = null;
let finalReply = ""; let finalReply = "";
let finalCrisis = false; let finalCrisis = false;
let finalToolCalls: ChatToolCallInfo[] = [];
try { try {
// 6) 컨텍스트 조립(§5) // 6) 컨텍스트 조립(§5)
const ctx: UserContext = await gatherUserContext(uid, user, config); const ctx: UserContext = await gatherUserContext(uid, user, config);
@ -279,7 +284,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
// 8) AI Provider 호출(§8) — 예산 기반 재시도. 시점 의존 정보(라인업·로스터)는 // 8) AI Provider 호출(§8) — 예산 기반 재시도. 시점 의존 정보(라인업·로스터)는
// 상시 주입 대신 도구로 노출해 모델이 필요할 때만 조회하게 한다(미지원 provider는 무시) // 상시 주입 대신 도구로 노출해 모델이 필요할 때만 조회하게 한다(미지원 provider는 무시)
await incrementGlobalUsage(date); await incrementGlobalUsage(date);
const tools = buildChatTools({ teamCode: activeTeamCode, date }); const tools = buildChatTools({ uid, teamCode: activeTeamCode, date });
let result; let result;
try { try {
result = await callProviderWithBudget(provider, { result = await callProviderWithBudget(provider, {
@ -311,6 +316,9 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
filtered = true; filtered = true;
} }
// 도구 호출 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 crisis/filtered는 제외)
const toolCalls = crisisOut || filtered ? [] : result.toolCalls;
// 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지 // 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지
saved = await finalizeExchange({ saved = await finalizeExchange({
uid, uid,
@ -323,10 +331,12 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
promptVersion: config.promptVersion, promptVersion: config.promptVersion,
filtered, filtered,
crisis: crisisOut, crisis: crisisOut,
toolCalls,
retentionDays: config.retentionDays, retentionDays: config.retentionDays,
}); });
finalReply = reply; finalReply = reply;
finalCrisis = crisisOut; finalCrisis = crisisOut;
finalToolCalls = toolCalls;
} catch (err) { } catch (err) {
// 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미 // 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미
// 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지). // 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지).
@ -336,7 +346,9 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
} }
// 11) 응답 반환 — 저장 성공 이후의 쿼터 재조회 실패는 복원 대상이 아니다(§6.2) // 11) 응답 반환 — 저장 성공 이후의 쿼터 재조회 실패는 복원 대상이 아니다(§6.2)
return buildSendResult(uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt); return buildSendResult(
uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt, finalToolCalls,
);
} }
// ── GET /chat/messages(§3.2) ── // ── GET /chat/messages(§3.2) ──
@ -389,6 +401,7 @@ export async function getMessages(
crisis: m.crisis, crisis: m.crisis,
createdAt: toKstIso(m.createdAt), createdAt: toKstIso(m.createdAt),
...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}), ...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: m.toolCalls } : {}),
})); }));
const last = items[items.length - 1]; const last = items[items.length - 1];

View File

@ -1,13 +1,18 @@
import { getSchedule } from "./scheduleService"; import { getSchedule } from "./scheduleService";
import { getGameDetail } from "./gameDetailService"; import { getGameDetail } from "./gameDetailService";
import { getPlayerStats } from "./playerService"; import { getPlayerStats } from "./playerService";
import { getRank } from "./rankService";
import { resolveTeamCode } from "./chatContextService"; import { resolveTeamCode } from "./chatContextService";
import { TEAM_DISPLAY_NAMES } from "../constants/chatPrompts"; import { getDay } from "../repositories/voteHistoryRepository";
import { getMonthDoc } from "../repositories/attendanceRepository";
import { getLatestBalance } from "../repositories/pointLedgerRepository";
import { TEAM_DISPLAY_NAMES, KBO_RANK_TEAM_NAMES } from "../constants/chatPrompts";
import type { ChatTool } from "./chatProviderService"; import type { ChatTool } from "./chatProviderService";
import type { Lineup, KeyPlayerRanking } from "../kbo/game-detail"; import type { Lineup, KeyPlayerRanking } from "../kbo/game-detail";
import type { PlayerRecord } from "../kbo/player/common"; import type { PlayerRecord } from "../kbo/player/common";
import type { TeamRank } from "../kbo/team-rank";
import type { ScheduleGame } from "../types/kbo"; import type { ScheduleGame } from "../types/kbo";
import { TeamCode } from "../types/panit"; import { TeamCode, type AttendanceMonthDoc, type VoteHistoryDoc } from "../types/panit";
import { addDaysKst, type DateString } from "../types/dateString"; import { addDaysKst, type DateString } from "../types/dateString";
/** /**
@ -26,8 +31,10 @@ import { addDaysKst, type DateString } from "../types/dateString";
* . 9( ) . * . 9( ) .
*/ */
/** 도구 실행에 필요한 요청 컨텍스트(사용자 응원팀·KST 오늘). */ /** 도구 실행에 필요한 요청 컨텍스트(호출자 uid·응원팀·KST 오늘). */
export interface ChatToolContext { export interface ChatToolContext {
/** 호출자 uid — per-uid 데이터(출석·예측 이력) 조회 경계. */
uid: string;
/** 활성 스레드 기준 응원팀(없으면 null — 도구 인자로 팀을 받아야 동작). */ /** 활성 스레드 기준 응원팀(없으면 null — 도구 인자로 팀을 받아야 동작). */
teamCode: TeamCode | null; teamCode: TeamCode | null;
/** KST 오늘(YYYY-MM-DD). */ /** KST 오늘(YYYY-MM-DD). */
@ -141,9 +148,12 @@ const ROSTER_COLUMNS: Record<"hitter" | "pitcher", readonly string[]> = {
pitcher: ["era", "wins", "losses", "saves", "holds", "so"], pitcher: ["era", "wins", "losses", "saves", "holds", "so"],
}; };
/** 로스터 결과 포맷(순수 함수). 시즌 기록 상위 N명을 핵심 스탯과 함께 전한다. */ /**
* ( ). N명을 .
* @param team null이면 ( ) "올해 타율 1위" .
*/
export function formatRosterResult( export function formatRosterResult(
team: TeamCode, team: TeamCode | null,
type: "hitter" | "pitcher", type: "hitter" | "pitcher",
columns: readonly string[], columns: readonly string[],
records: PlayerRecord[], records: PlayerRecord[],
@ -152,15 +162,101 @@ export function formatRosterResult(
): string { ): string {
const top = records.slice(0, limit); const top = records.slice(0, limit);
const kind = type === "pitcher" ? "주요 투수" : "주요 타자"; const kind = type === "pitcher" ? "주요 투수" : "주요 타자";
const who = team ? teamLabel(team) : "리그";
if (top.length === 0) { 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 cols = ROSTER_COLUMNS[type].filter((c) => columns.includes(c)).slice(0, 4);
const lines = top.map((r) => { const lines = top.map((r) => {
const stats = cols.map((c) => `${c}=${r[c] ?? "-"}`).join(", "); const stats = cols.map((c) => `${c}=${r[c] ?? "-"}`).join(", ");
return stats ? `- ${r.player ?? "?"}: ${stats}` : `- ${r.player ?? "?"}`; 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, 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);
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<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;
} }
/** /**
@ -277,29 +373,34 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] {
const roster: ChatTool = { const roster: ChatTool = {
name: "get_roster", name: "get_roster",
description: description:
"특정 팀의 주요 선수(타자 또는 투수)와 올해 시즌 기록을 조회한다. " + "선수의 올해 시즌 기록 상위 명단을 조회한다. 팀 주요 타자/투수·시즌 성적을 물을 때 사용한다. " +
"팀 선수 명단·핵심 타자/투수·시즌 성적을 물을 때 사용한다.", "league=true면 팀 구분 없이 리그 전체 상위 선수(예: \"올해 타율 1위\", \"리그 다승 1위\")를 돌려준다.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
team: { team: {
type: "string", type: "string",
description: "팀 코드(예: HH, LG). 생략하면 사용자의 응원팀을 사용한다.", description: "팀 코드(예: HH, LG). 생략하면 사용자의 응원팀. league=true면 무시된다.",
}, },
type: { type: {
type: "string", type: "string",
enum: ["hitter", "pitcher"], enum: ["hitter", "pitcher"],
description: "타자(hitter) 또는 투수(pitcher). 생략하면 타자.", description: "타자(hitter) 또는 투수(pitcher). 생략하면 타자.",
}, },
league: {
type: "boolean",
description: "리그 전체 상위 선수를 원하면 true(팀 필터 없음). 특정 팀이면 생략.",
},
}, },
additionalProperties: false, additionalProperties: false,
}, },
async run(args) { async run(args) {
try { try {
const team = pickTeam(args, ctx); const league = args.league === true;
if (!team) return NO_TEAM_NOTICE; const team = league ? null : pickTeam(args, ctx);
if (!league && !team) return NO_TEAM_NOTICE;
const type = args.type === "pitcher" ? "pitcher" : "hitter"; 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); return formatRosterResult(team, type, result.columns, result.records, year);
} catch (err) { } catch (err) {
console.warn("[chat-tool] get_roster 실패", 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 = { const standouts: ChatTool = {
name: "get_game_standouts", name: "get_game_standouts",
description: description:
@ -367,5 +578,5 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] {
}, },
}; };
return [lineup, roster, standouts]; return [lineup, roster, standouts, rankSnapshot, attendance, predictionBreakdown];
} }

View File

@ -11,6 +11,15 @@ import type { TeamCode } from "./panit";
export type ChatRole = "user" | "assistant"; export type ChatRole = "user" | "assistant";
/**
* 1 + ( ).
* ( ) . assistant .
*/
export interface ChatToolCallInfo {
name: string;
args: Record<string, unknown>;
}
/** `users/{uid}/chatThreads/{threadId}` — threadId는 팀 코드 또는 "default". */ /** `users/{uid}/chatThreads/{threadId}` — threadId는 팀 코드 또는 "default". */
export interface ChatThreadDoc { export interface ChatThreadDoc {
teamCode?: TeamCode; teamCode?: TeamCode;
@ -38,6 +47,8 @@ export interface ChatMessageDoc {
filtered: boolean; filtered: boolean;
/** 위기 전환 응답 여부(§7.3). */ /** 위기 전환 응답 여부(§7.3). */
crisis: boolean; crisis: boolean;
/** assistant 메시지만 — 응답 생성 중 호출한 도구 목록(이름+인자). 호출 없으면 미저장. */
toolCalls?: ChatToolCallInfo[];
/** TTL 삭제 시각 = createdAt + 보관 기간(§4.3). */ /** TTL 삭제 시각 = createdAt + 보관 기간(§4.3). */
expireAt: Timestamp; expireAt: Timestamp;
} }
@ -150,6 +161,7 @@ export interface ChatConfig {
crisisThresholdPerDay: number; crisisThresholdPerDay: number;
/** 보관 기간(일) — expireAt 계산 상수(§4.3, D-4 회신 시 변경). */ /** 보관 기간(일) — expireAt 계산 상수(§4.3, D-4 회신 시 변경). */
retentionDays: number; retentionDays: number;
/** 라이브 provider(DB 미설정 시 코드 기본 = lite). 모든 모델 공평 타임아웃이라 단일 필드로 충분. */
provider: ChatProviderConfig; provider: ChatProviderConfig;
promptVersion: string; promptVersion: string;
/** 공통 시스템 프롬프트 — 빈 문자열이면 내장 기본문(페르소나 문서 2.3) 사용. */ /** 공통 시스템 프롬프트 — 빈 문자열이면 내장 기본문(페르소나 문서 2.3) 사용. */
@ -174,6 +186,8 @@ export interface ChatSendResult {
remainingCount: number | null; remainingCount: number | null;
limit: number | null; limit: number | null;
createdAt: string; createdAt: string;
/** 응답 생성 중 호출한 도구(이름+인자). 호출 없으면 생략. 클라 활용 선택. */
toolCalls?: ChatToolCallInfo[];
} }
export interface ChatMessageView { export interface ChatMessageView {
@ -183,6 +197,8 @@ export interface ChatMessageView {
crisis: boolean; crisis: boolean;
createdAt: string; createdAt: string;
clientMessageId?: string; clientMessageId?: string;
/** assistant 메시지의 도구 호출(이름+인자). 없으면 생략. */
toolCalls?: ChatToolCallInfo[];
} }
export interface ChatMessagesPage { export interface ChatMessagesPage {

View File

@ -171,6 +171,39 @@ describe("chatService", () => {
expect((await readQuota()).used).toBe(1); 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 () => { it("시스템 프롬프트에 검증된 컨텍스트를 주입한다(§5.4)", async () => {
fixtures.todayGames = [HH_GAME]; fixtures.todayGames = [HH_GAME];
fixtures.rank = { fixtures.rank = {

View File

@ -1,15 +1,21 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
buildChatTools, buildChatTools,
formatAttendanceResult,
formatGameStandouts, formatGameStandouts,
formatLineupResult, formatLineupResult,
formatPredictionBreakdown,
formatRankResult,
formatRosterResult, formatRosterResult,
pickTeam, pickTeam,
resolveRequestedDate, resolveRequestedDate,
resolveToolDate, resolveToolDate,
resolveToolMonth,
type ChatToolContext, type ChatToolContext,
} from "../../src/services/chatToolService"; } from "../../src/services/chatToolService";
import type { KeyPlayerRanking } from "../../src/kbo/game-detail"; 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 { getChatProvider } from "../../src/services/chatProviderService";
import { DEFAULT_PROVIDER_CONFIG } from "../../src/services/chatConfigService"; import { DEFAULT_PROVIDER_CONFIG } from "../../src/services/chatConfigService";
import { TeamCode } from "../../src/types/panit"; import { TeamCode } from "../../src/types/panit";
@ -52,7 +58,7 @@ function lineup(overrides: Partial<Lineup>): 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("chatToolService", () => {
describe("pickTeam — 인자 우선, 컨텍스트 fallback(§5.4 화이트리스트)", () => { describe("pickTeam — 인자 우선, 컨텍스트 fallback(§5.4 화이트리스트)", () => {
@ -186,6 +192,94 @@ describe("chatToolService", () => {
it("기록이 없으면 부재를 알린다", () => { it("기록이 없으면 부재를 알린다", () => {
expect(formatRosterResult(TeamCode.HH, "hitter", hitterCols, [], 2026)).toContain("못 찾았어"); 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>): 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()을 실제 실행한다", () => { describe("provider tool-loop 배선 — mock provider가 run()을 실제 실행한다", () => {
@ -260,11 +354,16 @@ describe("chatToolService", () => {
}); });
describe("buildChatTools — 노출 계약", () => { describe("buildChatTools — 노출 계약", () => {
it("get_lineup·get_roster·get_game_standouts 세 도구를 만든다(get_manager는 TODO 제외)", () => { it("6개 도구를 만든다(get_manager는 TODO 제외)", () => {
const tools = buildChatTools(ctx); const tools = buildChatTools(ctx);
expect(tools.map((t) => t.name).sort()).toEqual( expect(tools.map((t) => t.name).sort()).toEqual([
["get_game_standouts", "get_lineup", "get_roster"], "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) { for (const t of tools) {
expect(t.parameters).toMatchObject({ type: "object", additionalProperties: false }); expect(t.parameters).toMatchObject({ type: "object", additionalProperties: false });
} }