Add debug endpoints and scripts to evaluate chat tools and models.

특정 uid로 실제 도구·모델 루프를 돌려 호출된 도구·토큰·속도·캐시 히트를
보고하는 /debug/chatToolsSheet, /debug/chatProbe(Sheet) 엔드포인트를 추가한다
(요청별 model 오버라이드로 A/B 가능). prod에 같은 검증을 돌리는
scripts/chat-tools-sheet.ts도 추가한다.

무인증 + 임의 uid의 개인 데이터를 노출하는 검수용 임시 엔드포인트다.
This commit is contained in:
윤정민 2026-06-16 16:52:21 +09:00
parent d3c6fddb58
commit 40dcb41d99
7 changed files with 677 additions and 5 deletions

View File

@ -10,7 +10,8 @@
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"", "test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"",
"test:watch": "vitest" "test:watch": "vitest",
"tools:sheet": "npx tsx scripts/chat-tools-sheet.ts"
}, },
"engines": { "engines": {
"node": "24" "node": "24"

23
scripts/_bootstrap.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* prod Firebase Admin (side-effect ).
*
* src/* "먼저" import해야 src/firebase의
* initializeApp() (projectId·databaseURL )
* . ADC(applicationDefault) :
* gcloud auth application-default login
* GOOGLE_APPLICATION_CREDENTIALS=<서비스계정.json>
*
* RTDB를 src/firebase가 getDatabase()
* databaseURL이 import throw한다 URL을 .
*/
import { initializeApp, getApps } from "firebase-admin/app";
const projectId = process.env.FIREBASE_PROJECT ?? process.env.GCLOUD_PROJECT ?? "mmday-panit";
const databaseURL =
process.env.FIREBASE_DATABASE_URL ?? `https://${projectId}-default-rtdb.firebaseio.com`;
if (getApps().length === 0) {
initializeApp({ projectId, databaseURL });
}
export const BOOTSTRAP_PROJECT = projectId;

View File

@ -0,0 +1,64 @@
/**
* () standalone .
*
* Cloud Function(`/debug/chatToolsSheet`) (chatToolsSheetService)
* prod , tmp/ Markdown/JSON으로 .
*
* prod KBO ( read-only). .
*
* :
* gcloud auth application-default login # GOOGLE_APPLICATION_CREDENTIALS=<sa.json>
* npx tsx scripts/chat-tools-sheet.ts <uid>
*
* (env): FIREBASE_PROJECT( mmday-panit), CHAT_TOOLS_PLAYER, CHAT_TOOLS_DATE
*/
// 반드시 src/* 보다 먼저 — prod 앱을 초기화한다(import 순서가 곧 실행 순서).
import { BOOTSTRAP_PROJECT } from "./_bootstrap";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
runChatToolsSheet,
renderSheetMarkdown,
type SheetOptions,
} from "../src/services/chatToolsSheetService";
async function main(): Promise<void> {
const uid = (process.argv[2] ?? process.env.CHAT_TOOLS_UID ?? "").trim();
if (!uid) {
console.error("사용법: npx tsx scripts/chat-tools-sheet.ts <uid> (또는 CHAT_TOOLS_UID=<uid>)");
process.exit(1);
}
const opts: SheetOptions = {
player: process.env.CHAT_TOOLS_PLAYER?.trim() || undefined,
date: process.env.CHAT_TOOLS_DATE?.trim() || undefined,
};
console.log(`[chat-tools-sheet] project=${BOOTSTRAP_PROJECT} uid=${uid} — 도구 실호출 중…`);
const sheet = await runChatToolsSheet(uid, opts);
for (const r of sheet.results) {
console.log(` ${r.ok ? "✓" : "✗"} ${r.tool} [${r.label}] ${r.elapsedMs}ms`);
}
const md = renderSheetMarkdown(sheet);
const outDir = join(process.cwd(), "tmp");
mkdirSync(outDir, { recursive: true });
const stamp = sheet.meta.generatedAt.replace(/[:.]/g, "-");
const base = `chat-tools-sheet-${uid}-${stamp}`;
const mdPath = join(outDir, `${base}.md`);
const jsonPath = join(outDir, `${base}.json`);
writeFileSync(mdPath, md, "utf8");
writeFileSync(jsonPath, JSON.stringify(sheet, null, 2), "utf8");
const okCount = sheet.results.filter((r) => r.ok).length;
console.log(`\n[chat-tools-sheet] 완료: ${okCount}/${sheet.results.length} 성공`);
console.log(` • Markdown: ${mdPath}`);
console.log(` • JSON: ${jsonPath}`);
}
main().catch((err) => {
console.error("[chat-tools-sheet] 치명적 오류:", err);
process.exit(1);
});

View File

@ -2,6 +2,8 @@ import { onRequest } from "firebase-functions/https";
import { logger } from "firebase-functions"; import { logger } from "firebase-functions";
import { runDailyArchive } from "../scheduled/dailyArchive"; import { runDailyArchive } from "../scheduled/dailyArchive";
import { forceSyncDay } from "../services/gameSyncService"; import { forceSyncDay } from "../services/gameSyncService";
import { runChatToolsSheet, renderSheetMarkdown } from "../services/chatToolsSheetService";
import { runChatProbe, runChatProbeBatch, renderProbeMarkdown } from "../services/chatProbeService";
import { sendError } from "../middleware/errors"; import { sendError } from "../middleware/errors";
import { daysAgoKst, parseDateString } from "../types/dateString"; import { daysAgoKst, parseDateString } from "../types/dateString";
@ -12,8 +14,17 @@ import { daysAgoKst, parseDateString } from "../types/dateString";
* - GET/POST `/debug/forceSync?date=YYYY-MM-DD` sync. * - GET/POST `/debug/forceSync?date=YYYY-MM-DD` sync.
* - GET/POST `/debug/dailyArchive` (KST) dailyArchive . * - GET/POST `/debug/dailyArchive` (KST) dailyArchive .
* - GET/POST `/debug/dailyArchive?date=YYYY-MM-DD` archive . * - GET/POST `/debug/dailyArchive?date=YYYY-MM-DD` archive .
* - GET `/debug/chatToolsSheet?uid=UID`
* (JSON) . `&format=md` Markdown, `&player=`·`&date=` .
* - GET `/debug/chatProbe?uid=UID&q=문장` 1 +
* , ·(toolCalls) . `&model=` .
* - GET `/debug/chatProbeSheet?uid=UID`
* (·· ). `&format=md` Markdown, `&model=<모델ID>` A/B
* ( lite. 24).
* + uid의 (·) Vertex
* .
*/ */
export const debug = onRequest(async (req, res) => { export const debug = onRequest({ timeoutSeconds: 300 }, async (req, res) => {
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
const tail = segs.slice(-1)[0]; const tail = segs.slice(-1)[0];
@ -23,9 +34,9 @@ export const debug = onRequest(async (req, res) => {
(typeof req.query.date === "string" && req.query.date) || (typeof req.query.date === "string" && req.query.date) ||
(typeof req.body?.date === "string" && req.body.date) || (typeof req.body?.date === "string" && req.body.date) ||
undefined; undefined;
const dateString = dateParam const dateString = dateParam ?
? parseDateString(dateParam) parseDateString(dateParam) :
: daysAgoKst(1); daysAgoKst(1);
const ymd = dateString.replace(/-/g, ""); const ymd = dateString.replace(/-/g, "");
logger.info(`debug.forceSync triggered manually (ymd=${ymd})`); logger.info(`debug.forceSync triggered manually (ymd=${ymd})`);
@ -49,6 +60,71 @@ export const debug = onRequest(async (req, res) => {
return; return;
} }
if (tail === "chatToolsSheet" || tail === "chat-tools-sheet") {
const uid =
(typeof req.query.uid === "string" && req.query.uid) ||
(typeof req.body?.uid === "string" && req.body.uid) ||
"";
if (!uid) {
res.status(400).json({ error: "uid is required (e.g. ?uid=<UID>)" });
return;
}
const player = typeof req.query.player === "string" ? req.query.player : undefined;
const date = typeof req.query.date === "string" ? req.query.date : undefined;
const format = typeof req.query.format === "string" ? req.query.format : "json";
logger.info(`debug.chatToolsSheet triggered (uid=${uid})`);
const sheet = await runChatToolsSheet(uid, { player, date });
if (format === "md" || format === "markdown") {
res.status(200).type("text/markdown; charset=utf-8").send(renderSheetMarkdown(sheet));
return;
}
res.status(200).json(sheet);
return;
}
if (tail === "chatProbe") {
const uid =
(typeof req.query.uid === "string" && req.query.uid) ||
(typeof req.body?.uid === "string" && req.body.uid) ||
"";
const q =
(typeof req.query.q === "string" && req.query.q) ||
(typeof req.body?.q === "string" && req.body.q) ||
"";
if (!uid || !q) {
res.status(400).json({ error: "uid and q are required (e.g. ?uid=<UID>&q=<문장>)" });
return;
}
const model = typeof req.query.model === "string" ? req.query.model : undefined;
logger.info(`debug.chatProbe triggered (uid=${uid}, model=${model ?? "default"})`);
const result = await runChatProbe(uid, q, model);
res.status(200).json(result);
return;
}
if (tail === "chatProbeSheet") {
const uid =
(typeof req.query.uid === "string" && req.query.uid) ||
(typeof req.body?.uid === "string" && req.body.uid) ||
"";
if (!uid) {
res.status(400).json({ error: "uid is required (e.g. ?uid=<UID>)" });
return;
}
const format = typeof req.query.format === "string" ? req.query.format : "json";
const model = typeof req.query.model === "string" ? req.query.model : undefined;
logger.info(`debug.chatProbeSheet triggered (uid=${uid}, model=${model ?? "default"})`);
const sheet = await runChatProbeBatch(uid, undefined, model);
if (format === "md" || format === "markdown") {
res.status(200).type("text/markdown; charset=utf-8").send(renderProbeMarkdown(sheet));
return;
}
res.status(200).json(sheet);
return;
}
res.status(404).json({ error: `Unknown: ${req.method} ${req.path}` }); res.status(404).json({ error: `Unknown: ${req.method} ${req.path}` });
} catch (err) { } catch (err) {
sendError(res, err); sendError(res, err);

View File

@ -0,0 +1,292 @@
import { getChatConfig, invalidateChatConfigCache } from "./chatConfigService";
import { getUser } from "../repositories/userRepository";
import {
assemblePrompt,
gatherUserContext,
resolveTeamCode,
} from "./chatContextService";
import { buildChatTools, type ChatToolContext } from "./chatToolService";
import {
callProviderWithBudget,
getChatProvider,
type ChatProvider,
type ChatTool,
} from "./chatProviderService";
import { HttpError } from "../middleware/errors";
import { TEAM_DISPLAY_NAMES } from "../constants/chatPrompts";
import { todayKst } from "../types/dateString";
import type { ChatConfig } from "../types/chat";
/**
* (§) "
* " + .
*
* (chatToolsSheetService) args를 .
* NL ,
* (toolCalls) . ··
* ( Vertex ).
*/
export interface ProbeToolCall {
tool: string;
args: Record<string, unknown>;
output: string;
elapsedMs: number;
}
export interface ProbeResult {
message: string;
expect?: string;
reply: string;
finishReason: string;
toolCalls: ProbeToolCall[];
elapsedMs: number;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
error?: string;
}
export interface ChatProbeMeta {
generatedAt: string;
uid: string;
displayName: string;
favoriteTeamCode: string | null;
favoriteTeamName: string;
date: string;
provider: string;
model: string;
}
export interface ChatProbeSheet {
meta: ChatProbeMeta;
results: ProbeResult[];
totals: {
inputTokens: number;
outputTokens: number;
cachedTokens: number;
elapsedMs: number;
avgMs: number;
};
}
/** 각 문장은 어떤 도구를 부르길 "기대"하는지 — 결과 대조용(모델엔 미노출). */
export const DEFAULT_PROBE_PROMPTS: Array<{ q: string; expect: string }> = [
{ q: "우리 팀 지금 몇 위야?", expect: "get_team_rank_snapshot" },
{ q: "KBO 전체 순위 보여줘", expect: "get_team_rank_snapshot(all)" },
{ q: "우리 팀에서 제일 잘 치는 타자 누구야?", expect: "get_roster" },
{ q: "올해 평균자책점 1위 투수 누구야?", expect: "get_roster(league,pitcher)" },
{ q: "오늘 우리 선발 누구야?", expect: "get_lineup(today)" },
{ q: "지난주에 우리 경기에서 누가 제일 잘했어?", expect: "get_game_standouts(과거일)" },
{ q: "이번 달에 나 출석 며칠 했어?", expect: "get_my_attendance_this_month" },
{ q: "어제 내 예측 맞았어?", expect: "get_prediction_breakdown_date" },
{ q: "양의지 올 시즌 가장 활약한 경기가 언제야?", expect: "get_game_standouts(추론→검증)" },
];
interface ProbeEnv {
system: string;
toolCtx: ChatToolContext;
provider: ChatProvider;
config: ChatConfig;
meta: ChatProbeMeta;
}
/** uid 1명 기준으로 시스템 프롬프트·도구 컨텍스트·provider를 한 번만 준비한다. */
async function buildProbeEnv(uid: string, modelOverride?: string): Promise<ProbeEnv> {
// 프로브는 검수 도구이므로 항상 최신 config를 읽는다 — 5분 캐시 우회.
invalidateChatConfigCache();
const base = await getChatConfig();
// A/B용 모델 오버라이드 — 모델만 바꾸고 나머지 provider 설정(공평 타임아웃 등)은 공유.
// 미지정 시 라이브 기본(lite).
const config: ChatConfig = modelOverride ?
{ ...base, provider: { ...base.provider, model: modelOverride } } :
base;
const user = await getUser(uid);
if (!user) {
throw new HttpError(404, `user not found: ${uid}`, "USER_NOT_FOUND");
}
const teamCode = resolveTeamCode(user.favoriteTeamCode);
const date = todayKst();
const ctx = await gatherUserContext(uid, user, config);
const { system } = assemblePrompt(config, ctx);
const provider = getChatProvider(config.provider);
const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)";
return {
system,
toolCtx: { uid, teamCode, date },
provider,
config,
meta: {
generatedAt: new Date().toISOString(),
uid,
displayName: user.displayName,
favoriteTeamCode: teamCode ?? null,
favoriteTeamName: teamName,
date,
provider: config.provider.name,
model: config.provider.model,
},
};
}
/** 각 도구의 run을 감싸 호출(tool·args·output·소요)을 trace에 기록한다. */
function instrument(tools: ChatTool[], trace: ProbeToolCall[]): ChatTool[] {
return tools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
run: async (args: Record<string, unknown>) => {
const started = Date.now();
const output = await t.run(args);
trace.push({ tool: t.name, args, output, elapsedMs: Date.now() - started });
return output;
},
}));
}
async function runOne(env: ProbeEnv, q: string, expect?: string): Promise<ProbeResult> {
const started = Date.now();
const trace: ProbeToolCall[] = [];
const tools = instrument(buildChatTools(env.toolCtx), trace);
try {
const result = await callProviderWithBudget(env.provider, {
system: env.system,
messages: [{ role: "user", content: q }],
config: env.config.provider,
tools,
});
return {
message: q,
expect,
reply: result.reply,
finishReason: result.finishReason,
toolCalls: trace,
elapsedMs: Date.now() - started,
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
cachedTokens: result.usage.cachedTokens,
};
} catch (err) {
const error = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
return {
message: q,
expect,
reply: "",
finishReason: "error",
toolCalls: trace,
elapsedMs: Date.now() - started,
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
error,
};
}
}
/** 단일 자연어 문장 프로브. modelOverride로 테스트 모델 지정(미지정 시 기본 lite). */
export async function runChatProbe(
uid: string,
message: string,
modelOverride?: string,
): Promise<ProbeResult> {
const env = await buildProbeEnv(uid, modelOverride);
return runOne(env, message);
}
/** 자연어 문장 배치 프로브(순차). prompts 미지정 시 기본 세트. modelOverride로 테스트 모델 지정. */
export async function runChatProbeBatch(
uid: string,
prompts?: Array<{ q: string; expect?: string }>,
modelOverride?: string,
): Promise<ChatProbeSheet> {
const env = await buildProbeEnv(uid, modelOverride);
const set = prompts ?? DEFAULT_PROBE_PROMPTS;
// 순차 실행 — 동시 호출 부하로 인한 예산 초과 아티팩트를 피하고 실제 채팅(1건씩)에
// 가깝게 둔다. 각 프로브가 25초 예산을 온전히 쓰도록 보장.
const results: ProbeResult[] = [];
for (const p of set) {
results.push(await runOne(env, p.q, p.expect));
}
const sums = results.reduce(
(acc, r) => ({
inputTokens: acc.inputTokens + r.inputTokens,
outputTokens: acc.outputTokens + r.outputTokens,
cachedTokens: acc.cachedTokens + r.cachedTokens,
elapsedMs: acc.elapsedMs + r.elapsedMs,
}),
{ inputTokens: 0, outputTokens: 0, cachedTokens: 0, elapsedMs: 0 },
);
const totals = {
...sums,
avgMs: results.length > 0 ? Math.round(sums.elapsedMs / results.length) : 0,
};
return { meta: env.meta, results, totals };
}
/** 프로브 시트를 사람이 읽는 Markdown으로 렌더링한다. */
export function renderProbeMarkdown(sheet: ChatProbeSheet): string {
const { meta, results } = sheet;
const lines: string[] = [];
lines.push("# 짹 자연어 프로브 결과 (NL → 도구선택 → 데이터)");
lines.push("");
lines.push(`- 생성: ${meta.generatedAt}`);
lines.push(`- uid: \`${meta.uid}\` / 유저: ${meta.displayName}`);
lines.push(`- 응원팀: ${meta.favoriteTeamName} (${meta.favoriteTeamCode ?? "—"})`);
lines.push(`- 모델: ${meta.provider}/${meta.model} / 기준 날짜(KST): ${meta.date}`);
lines.push(
`- 토큰 합계: 입력 ${sheet.totals.inputTokens.toLocaleString()} / ` +
`출력 ${sheet.totals.outputTokens.toLocaleString()} / ` +
`캐시히트 ${sheet.totals.cachedTokens.toLocaleString()}`,
);
lines.push(
`- 속도: 총 ${(sheet.totals.elapsedMs / 1000).toFixed(1)}s / ` +
`평균 ${(sheet.totals.avgMs / 1000).toFixed(2)}s per 문장`,
);
lines.push("");
lines.push("## 요약");
lines.push("");
lines.push("| # | 문장 | 기대 도구 | 실제 호출 | grounded | in/out 토큰 | ms | 응답 첫 줄 |");
lines.push("|---|------|-----------|-----------|----------|-------------|----|-----------|");
results.forEach((r, i) => {
const called = r.toolCalls.map((c) => c.tool).join(", ") || "(없음)";
const grounded = r.toolCalls.length > 0 ? "✓" : "✗";
const head = (r.error ? `ERR ${r.error}` : r.reply).split("\n")[0].slice(0, 50).replace(/\|/g, "\\|");
const msg = r.message.replace(/\|/g, "\\|");
const tok = `${r.inputTokens}/${r.outputTokens}`;
const cells = [i + 1, msg, r.expect ?? "", called, grounded, tok, r.elapsedMs, head];
lines.push(`| ${cells.join(" | ")} |`);
});
lines.push(
`| | **합계** | | | | **${sheet.totals.inputTokens}/${sheet.totals.outputTokens}** | ` +
`**${sheet.totals.elapsedMs}** | 평균 ${(sheet.totals.avgMs / 1000).toFixed(2)}s |`,
);
lines.push("");
lines.push("## 상세");
lines.push("");
results.forEach((r, i) => {
lines.push(`### ${i + 1}. "${r.message}"`);
lines.push("");
lines.push(`- 기대 도구: ${r.expect ?? "—"} / 소요: ${r.elapsedMs}ms / finishReason: ${r.finishReason}`);
if (r.toolCalls.length === 0) {
lines.push("- ⚠️ 호출된 도구 없음(모델이 도구를 안 부르고 자체 응답).");
} else {
r.toolCalls.forEach((c) => {
lines.push(`- 🔧 \`${c.tool}\`(${JSON.stringify(c.args)}) — ${c.elapsedMs}ms`);
lines.push(" ```");
c.output.split("\n").forEach((ln) => lines.push(` ${ln}`));
lines.push(" ```");
});
}
lines.push("");
lines.push("**최종 응답:**");
lines.push("```");
lines.push(r.error ? `ERROR: ${r.error}` : (r.reply || "(빈 응답)"));
lines.push("```");
lines.push("");
});
return lines.join("\n");
}

View File

@ -0,0 +1,188 @@
import { buildChatTools, type ChatToolContext } from "./chatToolService";
import { resolveTeamCode } from "./chatContextService";
import { getUser } from "../repositories/userRepository";
import { HttpError } from "../middleware/errors";
import { TEAM_DISPLAY_NAMES } from "../constants/chatPrompts";
import { todayKst } from "../types/dateString";
/**
* () (§).
*
* uid chatToolService의
* . /LLM "
* " . debug standalone .
*
* read-only(· ), KBO .
*/
export interface SheetInvocation {
tool: string;
label: string;
args: Record<string, unknown>;
}
export interface SheetResult extends SheetInvocation {
ok: boolean;
elapsedMs: number;
output: string;
error?: string;
}
export interface ChatToolsSheetMeta {
generatedAt: string;
uid: string;
displayName: string;
favoriteTeamCode: string | null;
favoriteTeamName: string;
knowledgeLevel: string;
date: string;
}
export interface ChatToolsSheet {
meta: ChatToolsSheetMeta;
results: SheetResult[];
}
export interface SheetOptions {
/** get_game_standouts에 선수 검증 케이스를 추가한다. */
player?: string;
/** 과거 특정 날짜(YYYY-MM-DD) 케이스(라인업·활약·예측 복기)를 추가한다. */
date?: string;
}
/** 도구별 대표 호출 케이스 매트릭스. opts로 선수/날짜 타깃 케이스를 덧붙인다. */
export function buildSheetMatrix(opts: SheetOptions = {}): SheetInvocation[] {
const player = opts.player?.trim();
const date = opts.date?.trim();
const matrix: SheetInvocation[] = [
{ tool: "get_team_rank_snapshot", label: "내 팀 순위 상세", args: {} },
{ tool: "get_team_rank_snapshot", label: "전체 순위표", args: { all: true } },
{ tool: "get_roster", label: "내 팀 주요 타자", args: {} },
{ tool: "get_roster", label: "내 팀 주요 투수", args: { type: "pitcher" } },
{ tool: "get_roster", label: "리그 전체 타자 상위", args: { league: true } },
{ tool: "get_roster", label: "리그 전체 투수 상위", args: { league: true, type: "pitcher" } },
{ tool: "get_lineup", label: "오늘 라인업", args: {} },
{ tool: "get_lineup", label: "어제 라인업", args: { dayOffset: -1 } },
{ tool: "get_game_standouts", label: "어제 활약 선수", args: { dayOffset: -1 } },
{ tool: "get_my_attendance_this_month", label: "이번 달 출석/포인트", args: {} },
{ tool: "get_my_attendance_this_month", label: "지난달 출석", args: { monthOffset: -1 } },
{ tool: "get_prediction_breakdown_date", label: "어제 예측 복기", args: { dayOffset: -1 } },
{ tool: "get_prediction_breakdown_date", label: "그저께 예측 복기", args: { dayOffset: -2 } },
];
if (date) {
matrix.push(
{ tool: "get_lineup", label: `라인업(${date})`, args: { date } },
{ tool: "get_game_standouts", label: `활약 선수(${date})`, args: { date } },
{ tool: "get_prediction_breakdown_date", label: `예측 복기(${date})`, args: { date } },
);
}
if (player) {
matrix.push({
tool: "get_game_standouts",
label: `${player} 활약 검증(${date ?? "어제"})`,
args: date ? { date, player } : { dayOffset: -1, player },
});
}
return matrix;
}
/**
* uid의 .
* @throws {HttpError} 404 .
*/
export async function runChatToolsSheet(uid: string, opts: SheetOptions = {}): Promise<ChatToolsSheet> {
const user = await getUser(uid);
if (!user) {
throw new HttpError(404, `user not found: ${uid}`, "USER_NOT_FOUND");
}
const teamCode = resolveTeamCode(user.favoriteTeamCode);
const date = todayKst();
const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)";
const ctx: ChatToolContext = { uid, teamCode, date };
const tools = buildChatTools(ctx);
const byName = new Map(tools.map((t) => [t.name, t]));
const results: SheetResult[] = [];
for (const inv of buildSheetMatrix(opts)) {
const tool = byName.get(inv.tool);
const started = Date.now();
if (!tool) {
results.push({ ...inv, ok: false, elapsedMs: 0, output: "", error: `unknown tool: ${inv.tool}` });
continue;
}
try {
const output = await tool.run(inv.args);
results.push({ ...inv, ok: true, elapsedMs: Date.now() - started, output });
} catch (err) {
const error = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
results.push({ ...inv, ok: false, elapsedMs: Date.now() - started, output: "", error });
}
}
return {
meta: {
generatedAt: new Date().toISOString(),
uid,
displayName: user.displayName,
favoriteTeamCode: teamCode ?? null,
favoriteTeamName: teamName,
knowledgeLevel: user.knowledgeLevel,
date,
},
results,
};
}
/** 시트를 사람이 읽는 Markdown으로 렌더링한다(요약 표 + 케이스별 상세). */
export function renderSheetMarkdown(sheet: ChatToolsSheet): string {
const { meta, results } = sheet;
const lines: string[] = [];
lines.push("# 짹 도구 실호출 결과 시트");
lines.push("");
lines.push(`- 생성: ${meta.generatedAt}`);
lines.push(`- uid: \`${meta.uid}\``);
lines.push(
`- 유저: ${meta.displayName} / 응원팀: ${meta.favoriteTeamName} ` +
`(${meta.favoriteTeamCode ?? "—"}) / 지식수준: ${meta.knowledgeLevel}`,
);
lines.push(`- 기준 날짜(KST): ${meta.date}`);
lines.push("");
lines.push("## 요약");
lines.push("");
lines.push("| # | 도구 | 케이스 | args | ms | 결과 |");
lines.push("|---|------|--------|------|----|------|");
results.forEach((r, i) => {
const head = (r.ok ? r.output : (r.error ?? "")).split("\n")[0].slice(0, 60).replace(/\|/g, "\\|");
lines.push(
`| ${i + 1} | \`${r.tool}\` | ${r.label} | \`${JSON.stringify(r.args)}\` | ${r.elapsedMs} | ` +
`${r.ok ? "✓" : "✗"} ${head} |`,
);
});
lines.push("");
lines.push("## 상세");
lines.push("");
results.forEach((r, i) => {
lines.push(`### ${i + 1}. ${r.tool}${r.label}`);
lines.push("");
lines.push(`- args: \`${JSON.stringify(r.args)}\``);
lines.push(`- 소요: ${r.elapsedMs}ms / 상태: ${r.ok ? "성공" : "실패"}`);
lines.push("");
lines.push("```");
lines.push(r.ok ? (r.output || "(빈 응답)") : `ERROR: ${r.error}`);
lines.push("```");
lines.push("");
});
return lines.join("\n");
}

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildSheetMatrix, runChatToolsSheet } from "../../src/services/chatToolsSheetService";
describe("chatToolsSheetService", () => {
describe("buildSheetMatrix — 케이스 매트릭스", () => {
it("기본 13개 케이스를 만든다", () => {
const m = buildSheetMatrix();
expect(m).toHaveLength(13);
expect(m.every((c) => typeof c.tool === "string" && typeof c.label === "string")).toBe(true);
});
it("date 옵션은 라인업·활약·예측 복기 3개를 추가한다", () => {
expect(buildSheetMatrix({ date: "2026-05-01" })).toHaveLength(16);
});
it("player 옵션은 선수 활약 검증 케이스를 추가한다", () => {
const m = buildSheetMatrix({ player: "양의지" });
expect(m).toHaveLength(14);
expect(m.some((c) => c.tool === "get_game_standouts" && c.args.player === "양의지")).toBe(true);
});
});
describe("runChatToolsSheet — 유저 경계", () => {
it("유저 문서가 없으면 404를 던진다(도구 호출 이전 단계)", async () => {
await expect(runChatToolsSheet("nonexistent-uid-xyz")).rejects.toMatchObject({ status: 404 });
});
});
});