Add AI chat tools to fetch KBO game standouts and refine model providers.

- 특정 경기에서 활약한 선수(WPA 상위)를 조회하는 `get_game_standouts` 도구를 추가하고, 시스템 프롬프트에 과거 기록 검증 로직을 강화했습니다.
- Gemini 3.0 이상 모델의 Thinking 설정을 지원하도록 Provider 계층을 개선하여 모델 세대별 최적의 추론 환경을 구성했습니다.
- 도구 우선 사용 원칙을 구체화하여 AI가 경기 데이터의 사실 관계를 스스로 확인하고 답변하도록 환각 방지 로직을 보완했습니다.
This commit is contained in:
윤정민 2026-06-16 08:36:16 +09:00
parent b126c05c82
commit 360c0d356e
4 changed files with 180 additions and 8 deletions

View File

@ -231,6 +231,9 @@ export const SERVER_DIRECTIVE_BLOCK = `[서버 지시 — 사용자에게 노출
. · . ·
9 . ( 9 . (
9 .) 9 .)
- · ,
. "어디를 찾아볼지" ,
· ( ).
- 식별자: ${CHAT_CANARY_TOKEN} - 식별자: ${CHAT_CANARY_TOKEN}
, (·· ) .`; , (·· ) .`;

View File

@ -1,5 +1,5 @@
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk";
import { GoogleGenAI, 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 } from "../types/chat";
@ -214,6 +214,23 @@ 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
@ -237,7 +254,7 @@ 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 isFlash = /flash/i.test(cfg.model); 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 }],
@ -273,8 +290,8 @@ class VertexChatProvider implements ChatProvider {
maxOutputTokens: cfg.maxOutputTokens, maxOutputTokens: cfg.maxOutputTokens,
abortSignal: controller.signal, abortSignal: controller.signal,
...(toolConfig ? { tools: toolConfig } : {}), ...(toolConfig ? { tools: toolConfig } : {}),
// flash 계열은 짧은 캐릭터 응답에 thinking이 불필요 — 지연·비용 절감 // 모델 세대별 thinking 설정(3+: thinkingLevel, 2.5: thinkingBudget)
...(isFlash ? { thinkingConfig: { thinkingBudget: 0 } } : {}), ...thinking,
}, },
}); });
} finally { } finally {

View File

@ -4,7 +4,7 @@ import { getPlayerStats } from "./playerService";
import { resolveTeamCode } from "./chatContextService"; import { resolveTeamCode } from "./chatContextService";
import { TEAM_DISPLAY_NAMES } from "../constants/chatPrompts"; import { TEAM_DISPLAY_NAMES } from "../constants/chatPrompts";
import type { ChatTool } from "./chatProviderService"; import type { ChatTool } from "./chatProviderService";
import type { Lineup } 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 { ScheduleGame } from "../types/kbo"; import type { ScheduleGame } from "../types/kbo";
import { TeamCode } from "../types/panit"; import { TeamCode } from "../types/panit";
@ -163,6 +163,56 @@ export function formatRosterResult(
return `[${teamLabel(team)} ${kind} (시즌 기록 상위, ${year})]\n${lines.join("\n")}`; return `[${teamLabel(team)} ${kind} (시즌 기록 상위, ${year})]\n${lines.join("\n")}`;
} }
/**
* ( ). KBO가 WPA(
* ) (recordText) "활약" .
* @param playerName , .
*/
export function formatGameStandouts(
team: TeamCode,
game: ScheduleGame,
hitters: KeyPlayerRanking | null,
pitchers: KeyPlayerRanking | null,
dateLabel: string,
playerName?: string,
): string {
const isHome = game.homeTeamCode === team;
const oppCode = (isHome ? game.awayTeamCode : game.homeTeamCode) as TeamCode;
const score =
game.awayScore != null && game.homeScore != null ? `${game.awayScore}:${game.homeScore}` : "?";
const my = isHome ? game.homeScore : game.awayScore;
const opp = isHome ? game.awayScore : game.homeScore;
const result = my != null && opp != null ? (my > opp ? "승" : my < opp ? "패" : "무") : "";
const head = `${dateLabel} ${teamLabel(team)} vs ${teamLabel(oppCode)} — 종료 ${score} (${teamLabel(team)} ${result})`;
const hItems = hitters?.items ?? [];
const pItems = pitchers?.items ?? [];
const fmt = (it: { playerName: string; recordText: string }) => `${it.playerName}: ${it.recordText}`;
const sections: string[] = [];
if (hItems.length > 0) {
sections.push(`[활약 타자 (경기 WPA 상위)]\n${hItems.slice(0, 5).map(fmt).join("\n")}`);
}
if (pItems.length > 0) {
sections.push(`[활약 투수 (경기 WPA 상위)]\n${pItems.slice(0, 3).map(fmt).join("\n")}`);
}
let note = "";
if (playerName) {
const all = [...hItems, ...pItems];
const found = all.find(
(it) => it.playerName.includes(playerName) || playerName.includes(it.playerName),
);
note = found ?
`\n→ ${playerName}: ${found.recordText} — 이 경기 활약 상위에 들었어.` :
`\n→ ${playerName}는 이 경기 WPA 상위에는 없어(이 경기에서 크게 활약하진 않았다는 뜻).`;
}
if (sections.length === 0) {
return `${head}\n이 경기의 활약 선수 데이터는 못 가져왔어.${note}`;
}
return `${head}\n${sections.join("\n")}${note}`;
}
const NO_TEAM_NOTICE = const NO_TEAM_NOTICE =
"어느 팀인지 알려줘 — 응원팀이 설정돼 있지 않아서 팀을 특정할 수 없어."; "어느 팀인지 알려줘 — 응원팀이 설정돼 있지 않아서 팀을 특정할 수 없어.";
@ -258,5 +308,64 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] {
}, },
}; };
return [lineup, roster]; const standouts: ChatTool = {
name: "get_game_standouts",
description:
"특정 날짜·팀의 종료된 경기에서 활약한 선수를 조회한다. 경기별 WPA(승리확률 " +
"기여도) 상위 타자·투수를 그 경기 기록과 함께 돌려준다. " +
"특정 선수의 '명경기·가장 활약한 경기'를 검증할 때, 후보 날짜를 넣어 그 경기에서 " +
"실제로 활약했는지 사실 확인하는 용도로 쓴다. player 인자로 특정 선수를 지정하면 " +
"그 선수가 활약 상위에 들었는지 표시한다.",
parameters: {
type: "object",
properties: {
team: {
type: "string",
description: "팀 코드(예: HH, OB, NC). 생략하면 사용자의 응원팀.",
},
date: {
type: "string",
description: "조회 날짜 \"YYYY-MM-DD\" 또는 \"어제/그저께\" 등. 생략하면 오늘.",
},
dayOffset: {
type: "integer",
description: "오늘 기준 상대 일수(0=오늘, -1=어제). 상대 표현이면 이 정수를 쓴다.",
},
player: {
type: "string",
description: "활약 여부를 확인할 선수 이름(선택). 예: \"양의지\".",
},
},
additionalProperties: false,
},
async run(args) {
try {
const target = resolveRequestedDate(args, ctx.date);
if (!target) return "어느 날짜인지 알려줘 — YYYY-MM-DD나 \"어제·사흘 전\" 같은 표현이면 돼.";
const team = pickTeam(args, ctx);
if (!team) return NO_TEAM_NOTICE;
const [ty, tm, td] = target.split("-").map(Number);
const dateLabel = target === ctx.date ? "오늘" : target;
const games = (await getSchedule(ty, tm, undefined, undefined, td)).games;
const game = findTeamGame(games, team);
if (!game) return `${dateLabel} ${teamLabel(team)} 경기는 없어.`;
if (game.status !== "completed") {
return `${dateLabel} ${teamLabel(team)} 경기는 아직 끝나지 않았어 — 활약·기록은 종료 후에 확인돼.`;
}
if (!game.gameId) return `${dateLabel} ${teamLabel(team)} 경기 정보를 못 가져왔어.`;
const detail = await getGameDetail({ gameId: game.gameId });
const player = typeof args.player === "string" && args.player.trim() !== "" ?
args.player.trim() :
undefined;
return formatGameStandouts(
team, game, detail.keyPlayer.hitter, detail.keyPlayer.pitcher, dateLabel, player,
);
} catch (err) {
console.warn("[chat-tool] get_game_standouts 실패", err);
return "그 경기 기록을 지금은 못 가져왔어.";
}
},
};
return [lineup, roster, standouts];
} }

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
buildChatTools, buildChatTools,
formatGameStandouts,
formatLineupResult, formatLineupResult,
formatRosterResult, formatRosterResult,
pickTeam, pickTeam,
@ -8,6 +9,7 @@ import {
resolveToolDate, resolveToolDate,
type ChatToolContext, type ChatToolContext,
} from "../../src/services/chatToolService"; } from "../../src/services/chatToolService";
import type { KeyPlayerRanking } from "../../src/kbo/game-detail";
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";
@ -218,10 +220,51 @@ describe("chatToolService", () => {
}); });
}); });
describe("formatGameStandouts — 경기별 WPA 상위(활약) 검증", () => {
function ranking(items: { playerName: string; recordText: string }[]): KeyPlayerRanking {
return {
groupSc: "GAME_WPA_RT",
items: items.map((it, i) => ({
rank: i + 1,
playerId: i + 1,
playerName: it.playerName,
teamId: "OB",
recordText: it.recordText,
playerImage: "",
})),
};
}
const completed = game({ status: "completed", awayScore: 3, homeScore: 8 });
const hitters = ranking([
{ playerName: "양의지", recordText: "4타수 3안타 2타점 1홈런" },
{ playerName: "김재환", recordText: "4타수 2안타 1타점" },
]);
const pitchers = ranking([{ playerName: "곽빈", recordText: "6이닝 1실점 7K" }]);
it("종료 스코어와 WPA 상위 타자·투수를 기록과 함께 전한다", () => {
const out = formatGameStandouts(TeamCode.HH, completed, hitters, pitchers, "2026-05-01");
expect(out).toContain("종료 3:8");
expect(out).toContain("양의지: 4타수 3안타 2타점 1홈런");
expect(out).toContain("곽빈: 6이닝 1실점 7K");
});
it("player 인자가 활약 상위에 있으면 그 사실을 표시한다", () => {
const out = formatGameStandouts(TeamCode.HH, completed, hitters, pitchers, "2026-05-01", "양의지");
expect(out).toContain("양의지: 4타수 3안타 2타점 1홈런 — 이 경기 활약 상위");
});
it("player가 활약 상위에 없으면 단정하지 않고 사실대로 알린다", () => {
const out = formatGameStandouts(TeamCode.HH, completed, hitters, pitchers, "2026-05-01", "최주환");
expect(out).toContain("최주환는 이 경기 WPA 상위에는 없어");
});
});
describe("buildChatTools — 노출 계약", () => { describe("buildChatTools — 노출 계약", () => {
it("get_lineup·get_roster 두 도구를 만든다(get_manager는 TODO 제외)", () => { it("get_lineup·get_roster·get_game_standouts 세 도구를 만든다(get_manager는 TODO 제외)", () => {
const tools = buildChatTools(ctx); const tools = buildChatTools(ctx);
expect(tools.map((t) => t.name).sort()).toEqual(["get_lineup", "get_roster"]); expect(tools.map((t) => t.name).sort()).toEqual(
["get_game_standouts", "get_lineup", "get_roster"],
);
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 });
} }