diff --git a/package.json b/package.json index feaa405..184c694 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", "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": { "node": "24" diff --git a/scripts/_bootstrap.ts b/scripts/_bootstrap.ts new file mode 100644 index 0000000..5da8fed --- /dev/null +++ b/scripts/_bootstrap.ts @@ -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; diff --git a/scripts/chat-tools-sheet.ts b/scripts/chat-tools-sheet.ts new file mode 100644 index 0000000..20eed8f --- /dev/null +++ b/scripts/chat-tools-sheet.ts @@ -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= + * npx tsx scripts/chat-tools-sheet.ts + * + * 옵션(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 { + const uid = (process.argv[2] ?? process.env.CHAT_TOOLS_UID ?? "").trim(); + if (!uid) { + console.error("사용법: npx tsx scripts/chat-tools-sheet.ts (또는 CHAT_TOOLS_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); +}); diff --git a/src/handlers/debugHandlers.ts b/src/handlers/debugHandlers.ts index b44e107..08afc13 100644 --- a/src/handlers/debugHandlers.ts +++ b/src/handlers/debugHandlers.ts @@ -2,6 +2,8 @@ import { onRequest } from "firebase-functions/https"; import { logger } from "firebase-functions"; import { runDailyArchive } from "../scheduled/dailyArchive"; import { forceSyncDay } from "../services/gameSyncService"; +import { runChatToolsSheet, renderSheetMarkdown } from "../services/chatToolsSheetService"; +import { runChatProbe, runChatProbeBatch, renderProbeMarkdown } from "../services/chatProbeService"; import { sendError } from "../middleware/errors"; 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/dailyArchive` — 어제(KST) 기준으로 dailyArchive 본체 실행. * - 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 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.body?.date === "string" && req.body.date) || undefined; - const dateString = dateParam - ? parseDateString(dateParam) - : daysAgoKst(1); + const dateString = dateParam ? + parseDateString(dateParam) : + daysAgoKst(1); const ymd = dateString.replace(/-/g, ""); logger.info(`debug.forceSync triggered manually (ymd=${ymd})`); @@ -49,6 +60,71 @@ export const debug = onRequest(async (req, res) => { 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=)" }); + 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=&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=)" }); + 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}` }); } catch (err) { sendError(res, err); diff --git a/src/services/chatProbeService.ts b/src/services/chatProbeService.ts new file mode 100644 index 0000000..070932f --- /dev/null +++ b/src/services/chatProbeService.ts @@ -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; + 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 { + // 프로브는 검수 도구이므로 항상 최신 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) => { + 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 { + 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 { + 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 { + 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"); +} diff --git a/src/services/chatToolsSheetService.ts b/src/services/chatToolsSheetService.ts new file mode 100644 index 0000000..64f947e --- /dev/null +++ b/src/services/chatToolsSheetService.ts @@ -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; +} + +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 { + 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"); +} diff --git a/tests/services/chatToolsSheetService.test.ts b/tests/services/chatToolsSheetService.test.ts new file mode 100644 index 0000000..99f315d --- /dev/null +++ b/tests/services/chatToolsSheetService.test.ts @@ -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 }); + }); + }); +});