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:
parent
d3c6fddb58
commit
40dcb41d99
@ -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
23
scripts/_bootstrap.ts
Normal 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;
|
||||||
64
scripts/chat-tools-sheet.ts
Normal file
64
scripts/chat-tools-sheet.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
292
src/services/chatProbeService.ts
Normal file
292
src/services/chatProbeService.ts
Normal 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");
|
||||||
|
}
|
||||||
188
src/services/chatToolsSheetService.ts
Normal file
188
src/services/chatToolsSheetService.ts
Normal 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");
|
||||||
|
}
|
||||||
28
tests/services/chatToolsSheetService.test.ts
Normal file
28
tests/services/chatToolsSheetService.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user