Add human-readable labels to chat tool calls for UI rendering.
- `ChatToolCallInfo` 타입에 `label` 필드를 추가하여 클라이언트가 도구 호출을 직관적인 칩 형태로 표시할 수 있도록 개선했습니다. - `chatToolService`에 도구별 라벨 매핑 테이블(`TOOL_LABELS`)과 이를 적용하는 `withToolLabels` 함수를 구현하여, API 응답 시점에만 라벨이 동적으로 결합되도록 설계했습니다. - 기존 저장소(Firestore) 로직에는 영향을 주지 않으면서, 메시지 조회 및 전송 결과 반환 시점에 라벨을 주입하여 표현 계층의 유연성을 확보했습니다. - 테스트 코드를 수정하여 도구 호출 응답 및 이력 조회 시 라벨이 정확하게 포함되는지 검증했습니다.
This commit is contained in:
parent
e466676210
commit
5f519afcbf
@ -31,7 +31,7 @@ import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilter
|
|||||||
import { logChatEvent } from "./chatAnalyticsService";
|
import { logChatEvent } from "./chatAnalyticsService";
|
||||||
import { extractNavActions } from "./chatNavService";
|
import { extractNavActions } from "./chatNavService";
|
||||||
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
|
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 { 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,
|
||||||
@ -100,7 +100,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 } : {}),
|
...(toolCalls && toolCalls.length > 0 ? { toolCalls: withToolLabels(toolCalls) } : {}),
|
||||||
...(actions && actions.length > 0 ? { actions } : {}),
|
...(actions && actions.length > 0 ? { actions } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -465,7 +465,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 } : {}),
|
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: withToolLabels(m.toolCalls) } : {}),
|
||||||
...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}),
|
...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { getMonthDoc } from "../repositories/attendanceRepository";
|
|||||||
import { getLatestBalance } from "../repositories/pointLedgerRepository";
|
import { getLatestBalance } from "../repositories/pointLedgerRepository";
|
||||||
import { TEAM_DISPLAY_NAMES, KBO_RANK_TEAM_NAMES } from "../constants/chatPrompts";
|
import { TEAM_DISPLAY_NAMES, KBO_RANK_TEAM_NAMES } from "../constants/chatPrompts";
|
||||||
import type { ChatTool } from "./chatProviderService";
|
import type { ChatTool } from "./chatProviderService";
|
||||||
|
import type { ChatToolCallInfo } from "../types/chat";
|
||||||
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 { 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 { TeamCode, type AttendanceMonthDoc, type VoteHistoryDoc } from "../types/panit";
|
||||||
import { addDaysKst, type DateString } from "../types/dateString";
|
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 채팅 도구(함수 호출) 레이어 — "질문 시점에만 알 수 있는" 정보를 모델이
|
* AI 채팅 도구(함수 호출) 레이어 — "질문 시점에만 알 수 있는" 정보를 모델이
|
||||||
* 필요할 때만 DB/KBO에서 조회하도록 한다(§5 컨텍스트 상시 주입의 보완).
|
* 필요할 때만 DB/KBO에서 조회하도록 한다(§5 컨텍스트 상시 주입의 보완).
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export type ChatRole = "user" | "assistant";
|
|||||||
export interface ChatToolCallInfo {
|
export interface ChatToolCallInfo {
|
||||||
name: string;
|
name: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
|
/** 사람이 읽을 도구 라벨(예: "순위 조회"). API 응답에서만 부여 — 저장은 안 함. 매핑 없으면 생략. */
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -176,8 +176,10 @@ describe("chatService", () => {
|
|||||||
expect((await readQuota()).used).toBe(1);
|
expect((await readQuota()).used).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("도구 호출 메타(이름+인자)를 응답과 이력에 노출한다", async () => {
|
it("도구 호출 메타(이름+인자+사람이 읽을 라벨)를 응답과 이력에 노출한다", async () => {
|
||||||
const calls = [{ name: "get_lineup", args: { date: "2026-05-01" } }];
|
const calls = [{ name: "get_lineup", args: { date: "2026-05-01" } }];
|
||||||
|
// API 경계에서 도구 이름에 사람이 읽을 라벨이 부여된다.
|
||||||
|
const labeled = [{ name: "get_lineup", args: { date: "2026-05-01" }, label: "라인업 조회" }];
|
||||||
setTestProvider({
|
setTestProvider({
|
||||||
complete: async () => ({
|
complete: async () => ({
|
||||||
reply: "오늘 선발은 김민준이야 짹",
|
reply: "오늘 선발은 김민준이야 짹",
|
||||||
@ -187,12 +189,12 @@ describe("chatService", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = await sendMessage(uid, { message: "오늘 선발 누구?", clientMessageId: newUuid() });
|
const result = await sendMessage(uid, { message: "오늘 선발 누구?", clientMessageId: newUuid() });
|
||||||
expect(result.toolCalls).toEqual(calls);
|
expect(result.toolCalls).toEqual(labeled);
|
||||||
|
|
||||||
// GET 이력의 assistant 메시지에도 toolCalls가 보인다(나중에 클라 활용)
|
// GET 이력의 assistant 메시지에도 toolCalls가 보인다(나중에 클라 활용)
|
||||||
const page = await getMessages(uid, undefined, undefined);
|
const page = await getMessages(uid, undefined, undefined);
|
||||||
const assistant = page.messages.find((m) => m.role === "assistant");
|
const assistant = page.messages.find((m) => m.role === "assistant");
|
||||||
expect(assistant?.toolCalls).toEqual(calls);
|
expect(assistant?.toolCalls).toEqual(labeled);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("출력이 교체(crisis/filtered)되면 도구 메타를 붙이지 않는다", async () => {
|
it("출력이 교체(crisis/filtered)되면 도구 메타를 붙이지 않는다", async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user