From 5f519afcbfb67955aad425981c632dedbcfe5fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Fri, 26 Jun 2026 09:22:08 +0900 Subject: [PATCH] Add human-readable labels to chat tool calls for UI rendering. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ChatToolCallInfo` 타입에 `label` 필드를 추가하여 클라이언트가 도구 호출을 직관적인 칩 형태로 표시할 수 있도록 개선했습니다. - `chatToolService`에 도구별 라벨 매핑 테이블(`TOOL_LABELS`)과 이를 적용하는 `withToolLabels` 함수를 구현하여, API 응답 시점에만 라벨이 동적으로 결합되도록 설계했습니다. - 기존 저장소(Firestore) 로직에는 영향을 주지 않으면서, 메시지 조회 및 전송 결과 반환 시점에 라벨을 주입하여 표현 계층의 유연성을 확보했습니다. - 테스트 코드를 수정하여 도구 호출 응답 및 이력 조회 시 라벨이 정확하게 포함되는지 검증했습니다. --- src/services/chatService.ts | 6 +++--- src/services/chatToolService.ts | 22 ++++++++++++++++++++++ src/types/chat.ts | 2 ++ tests/services/chatService.test.ts | 8 +++++--- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 9a9f69d..a274c70 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -31,7 +31,7 @@ import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilter import { logChatEvent } from "./chatAnalyticsService"; import { extractNavActions } from "./chatNavService"; import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService"; -import { buildChatTools } from "./chatToolService"; +import { buildChatTools, withToolLabels } 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, @@ -100,7 +100,7 @@ async function buildSendResult( remainingCount: Math.max(0, config.dailyLimit - used), limit: config.dailyLimit, createdAt: toKstIso(createdAt), - ...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}), + ...(toolCalls && toolCalls.length > 0 ? { toolCalls: withToolLabels(toolCalls) } : {}), ...(actions && actions.length > 0 ? { actions } : {}), }; } @@ -465,7 +465,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 } : {}), + ...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: withToolLabels(m.toolCalls) } : {}), ...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}), })); diff --git a/src/services/chatToolService.ts b/src/services/chatToolService.ts index 287e0a5..ce02959 100644 --- a/src/services/chatToolService.ts +++ b/src/services/chatToolService.ts @@ -8,6 +8,7 @@ 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 { ChatToolCallInfo } from "../types/chat"; import type { Lineup, KeyPlayerRanking } from "../kbo/game-detail"; import type { PlayerRecord } from "../kbo/player/common"; import type { TeamRank } from "../kbo/team-rank"; @@ -15,6 +16,27 @@ import type { ScheduleGame } from "../types/kbo"; import { TeamCode, type AttendanceMonthDoc, type VoteHistoryDoc } from "../types/panit"; import { addDaysKst, type DateString } from "../types/dateString"; +/** + * 도구 이름 → 사람이 읽을 라벨. 클라이언트가 "🔍 순위 조회" 같은 칩으로 표시하기 위함. + * 저장(Firestore)은 이름·인자만, 라벨은 API 응답 시점에만 부여한다(표현 계층, 변경 자유). + */ +const TOOL_LABELS: Record = { + get_lineup: "라인업 조회", + get_roster: "로스터 조회", + get_game_standouts: "경기 활약 선수 조회", + get_team_rank_snapshot: "순위 조회", + get_my_attendance_this_month: "내 출석·포인트 조회", + get_prediction_breakdown_date: "내 예측 내역 조회", +}; + +/** toolCalls에 사람이 읽을 라벨을 부여한다(매핑 없는 도구는 라벨 생략). API 경계에서만 호출. */ +export function withToolLabels(calls: ChatToolCallInfo[]): ChatToolCallInfo[] { + return calls.map((c) => { + const label = TOOL_LABELS[c.name]; + return label ? { ...c, label } : c; + }); +} + /** * AI 채팅 도구(함수 호출) 레이어 — "질문 시점에만 알 수 있는" 정보를 모델이 * 필요할 때만 DB/KBO에서 조회하도록 한다(§5 컨텍스트 상시 주입의 보완). diff --git a/src/types/chat.ts b/src/types/chat.ts index 3213d1f..4b51b4e 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -18,6 +18,8 @@ export type ChatRole = "user" | "assistant"; export interface ChatToolCallInfo { name: string; args: Record; + /** 사람이 읽을 도구 라벨(예: "순위 조회"). API 응답에서만 부여 — 저장은 안 함. 매핑 없으면 생략. */ + label?: string; } /** diff --git a/tests/services/chatService.test.ts b/tests/services/chatService.test.ts index 60878ca..8b274d8 100644 --- a/tests/services/chatService.test.ts +++ b/tests/services/chatService.test.ts @@ -176,8 +176,10 @@ describe("chatService", () => { expect((await readQuota()).used).toBe(1); }); - it("도구 호출 메타(이름+인자)를 응답과 이력에 노출한다", async () => { + it("도구 호출 메타(이름+인자+사람이 읽을 라벨)를 응답과 이력에 노출한다", async () => { const calls = [{ name: "get_lineup", args: { date: "2026-05-01" } }]; + // API 경계에서 도구 이름에 사람이 읽을 라벨이 부여된다. + const labeled = [{ name: "get_lineup", args: { date: "2026-05-01" }, label: "라인업 조회" }]; setTestProvider({ complete: async () => ({ reply: "오늘 선발은 김민준이야 짹", @@ -187,12 +189,12 @@ describe("chatService", () => { }), }); const result = await sendMessage(uid, { message: "오늘 선발 누구?", clientMessageId: newUuid() }); - expect(result.toolCalls).toEqual(calls); + expect(result.toolCalls).toEqual(labeled); // GET 이력의 assistant 메시지에도 toolCalls가 보인다(나중에 클라 활용) const page = await getMessages(uid, undefined, undefined); const assistant = page.messages.find((m) => m.role === "assistant"); - expect(assistant?.toolCalls).toEqual(calls); + expect(assistant?.toolCalls).toEqual(labeled); }); it("출력이 교체(crisis/filtered)되면 도구 메타를 붙이지 않는다", async () => {