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:
parent
360c0d356e
commit
1ab744e660
@ -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<Exchange
|
||||
expireAt,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(params.promptVersion ? { promptVersion: params.promptVersion } : {}),
|
||||
...(params.toolCalls && params.toolCalls.length > 0 ? { toolCalls: params.toolCalls } : {}),
|
||||
};
|
||||
batch.set(messagesCol(params.uid, params.threadId).doc(assistantMessageId), assistantDoc);
|
||||
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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<string, unknown>) :
|
||||
`알 수 없는 도구: ${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<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)로 인증되므로
|
||||
* 별도 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ChatSendResult> {
|
||||
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<ChatSend
|
||||
let saved: ExchangeResult | null = null;
|
||||
let finalReply = "";
|
||||
let finalCrisis = false;
|
||||
let finalToolCalls: ChatToolCallInfo[] = [];
|
||||
try {
|
||||
// 6) 컨텍스트 조립(§5)
|
||||
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) — 예산 기반 재시도. 시점 의존 정보(라인업·로스터)는
|
||||
// 상시 주입 대신 도구로 노출해 모델이 필요할 때만 조회하게 한다(미지원 provider는 무시)
|
||||
await incrementGlobalUsage(date);
|
||||
const tools = buildChatTools({ teamCode: activeTeamCode, date });
|
||||
const tools = buildChatTools({ uid, teamCode: activeTeamCode, date });
|
||||
let result;
|
||||
try {
|
||||
result = await callProviderWithBudget(provider, {
|
||||
@ -311,6 +316,9 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
filtered = true;
|
||||
}
|
||||
|
||||
// 도구 호출 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 crisis/filtered는 제외)
|
||||
const toolCalls = crisisOut || filtered ? [] : result.toolCalls;
|
||||
|
||||
// 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지
|
||||
saved = await finalizeExchange({
|
||||
uid,
|
||||
@ -323,10 +331,12 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
promptVersion: config.promptVersion,
|
||||
filtered,
|
||||
crisis: crisisOut,
|
||||
toolCalls,
|
||||
retentionDays: config.retentionDays,
|
||||
});
|
||||
finalReply = reply;
|
||||
finalCrisis = crisisOut;
|
||||
finalToolCalls = toolCalls;
|
||||
} catch (err) {
|
||||
// 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미
|
||||
// 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지).
|
||||
@ -336,7 +346,9 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
}
|
||||
|
||||
// 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) ──
|
||||
@ -389,6 +401,7 @@ export async function getMessages(
|
||||
crisis: m.crisis,
|
||||
createdAt: toKstIso(m.createdAt),
|
||||
...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
|
||||
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: m.toolCalls } : {}),
|
||||
}));
|
||||
|
||||
const last = items[items.length - 1];
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
import { getSchedule } from "./scheduleService";
|
||||
import { getGameDetail } from "./gameDetailService";
|
||||
import { getPlayerStats } from "./playerService";
|
||||
import { getRank } from "./rankService";
|
||||
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 { Lineup, KeyPlayerRanking } from "../kbo/game-detail";
|
||||
import type { PlayerRecord } from "../kbo/player/common";
|
||||
import type { TeamRank } from "../kbo/team-rank";
|
||||
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";
|
||||
|
||||
/**
|
||||
@ -26,8 +31,10 @@ import { addDaysKst, type DateString } from "../types/dateString";
|
||||
* 검토. 그 전까지 감독 질문은 9절(모르는 정보) 규칙으로 답한다.
|
||||
*/
|
||||
|
||||
/** 도구 실행에 필요한 요청 컨텍스트(사용자 응원팀·KST 오늘). */
|
||||
/** 도구 실행에 필요한 요청 컨텍스트(호출자 uid·응원팀·KST 오늘). */
|
||||
export interface ChatToolContext {
|
||||
/** 호출자 uid — per-uid 데이터(출석·예측 이력) 조회 경계. */
|
||||
uid: string;
|
||||
/** 활성 스레드 기준 응원팀(없으면 null — 도구 인자로 팀을 받아야 동작). */
|
||||
teamCode: TeamCode | null;
|
||||
/** KST 오늘(YYYY-MM-DD). */
|
||||
@ -141,9 +148,12 @@ const ROSTER_COLUMNS: Record<"hitter" | "pitcher", readonly string[]> = {
|
||||
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, 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 = {
|
||||
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];
|
||||
}
|
||||
|
||||
@ -11,6 +11,15 @@ import type { TeamCode } from "./panit";
|
||||
|
||||
export type ChatRole = "user" | "assistant";
|
||||
|
||||
/**
|
||||
* 모델이 응답 생성 중 호출한 도구 1건 — 이름 + 인자(모델이 고른 값).
|
||||
* 도구 출력(내부 그라운딩 텍스트)은 담지 않는다. assistant 메시지에만 부착.
|
||||
*/
|
||||
export interface ChatToolCallInfo {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** `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 {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>): 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>): 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 });
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user