Add human-readable labels to chat tool calls for UI rendering.

- `ChatToolCallInfo` 타입에 `label` 필드를 추가하여 클라이언트가 도구 호출을 직관적인 칩 형태로 표시할 수 있도록 개선했습니다.
- `chatToolService`에 도구별 라벨 매핑 테이블(`TOOL_LABELS`)과 이를 적용하는 `withToolLabels` 함수를 구현하여, API 응답 시점에만 라벨이 동적으로 결합되도록 설계했습니다.
- 기존 저장소(Firestore) 로직에는 영향을 주지 않으면서, 메시지 조회 및 전송 결과 반환 시점에 라벨을 주입하여 표현 계층의 유연성을 확보했습니다.
- 테스트 코드를 수정하여 도구 호출 응답 및 이력 조회 시 라벨이 정확하게 포함되는지 검증했습니다.
This commit is contained in:
윤정민 2026-06-26 09:22:08 +09:00
parent e466676210
commit 5f519afcbf
4 changed files with 32 additions and 6 deletions

View File

@ -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 } : {}),
}));

View File

@ -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<string, string> = {
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 ).

View File

@ -18,6 +18,8 @@ export type ChatRole = "user" | "assistant";
export interface ChatToolCallInfo {
name: string;
args: Record<string, unknown>;
/** 사람이 읽을 도구 라벨(예: "순위 조회"). API 응답에서만 부여 — 저장은 안 함. 매핑 없으면 생략. */
label?: string;
}
/**

View File

@ -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 () => {