// eval-runner.mjs — 태스크마다 하네스 루프를 하나씩 돌리는 평가 트랙
//
// 사용법:
// node eval-runner.mjs 수정된(정규화) 검증기 사용
// node eval-runner.mjs --strict-verify 정규화 이전의 옛 검증기 사용, 거짓 실패를 관찰
const STRICT = process.argv.includes("--strict-verify");
const MODEL = "claude-opus-5"; // 스텁은 무시한다. 실제 client로 바꿀 때 model과 max_tokens는 필수 파라미터다
// ============ 1. 테스트 대상: 도구와 데이터 ============
const ORDERS = {
"SO-1001": { customer: "계명테크", status: "complete", month: "2026-08", amount: 780.0 },
"SO-1002": { customer: "계명테크", status: "complete", month: "2026-08", amount: 500.0 },
"SO-1003": { customer: "계명테크", status: "pending", month: "2026-08", amount: 320.0 },
"SO-1004": { customer: "원산물류", status: "pending", month: "2026-08", amount: 96.5 },
};
const TOOLS = [
{
name: "search_orders",
description: "고객명 또는 주문 상태로 주문을 검색하고 주문 ID 목록을 반환한다. customer와 status 중 최소 하나는 반드시 제공해야 한다.",
input_schema: {
type: "object",
properties: { customer: { type: "string" }, status: { type: "string" } },
},
},
{
name: "get_order",
description: "주문 ID로 단일 주문의 고객, 상태, 금액을 조회한다.",
input_schema: {
type: "object",
properties: { order_id: { type: "string" } },
required: ["order_id"],
},
},
];
const TOOL_IMPL = {
search_orders({ customer, status }) {
if (!customer && !status) {
throw new Error("잘못된 파라미터: customer 또는 status 중 최소 하나를 제공해야 합니다");
}
const ids = Object.keys(ORDERS).filter(
(id) =>
(!customer || ORDERS[id].customer === customer) &&
(!status || ORDERS[id].status === status)
);
return { order_ids: ids };
},
get_order({ order_id }) {
const o = ORDERS[order_id];
if (!o) throw new Error(`주문 ${order_id}이(가) 존재하지 않습니다`);
return { order_id, ...o };
},
};
// ============ 2. 스텁 client: 고정된 응답 큐 ============
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const text = (s) => ({ type: "text", text: s });
const toolUse = (id, name, input) => ({ type: "tool_use", id, name, input });
const useTools = (blocks, [i, o]) => ({
stop_reason: "tool_use",
content: blocks,
usage: { input_tokens: i, output_tokens: o },
latency_ms: 40,
});
const finish = (s, [i, o]) => ({
stop_reason: "end_turn",
content: [text(s)],
usage: { input_tokens: i, output_tokens: o },
latency_ms: 40,
});
function stubClient(script, label) {
if (!script) throw new Error(`[stub] ${label}에 대한 응답 큐가 없습니다`);
let cursor = 0;
return {
messages: {
async create() {
if (cursor >= script.length) {
throw new Error(`[stub] ${label} 응답 큐가 소진되었습니다 (요청 ${cursor}건 발행)`);
}
const res = script[cursor++];
await sleep(res.latency_ms);
return res;
},
},
};
}
// ============ 3. 두 개의 시스템 프롬프트와 각각의 응답 큐 ============
const SYSTEM_PROMPTS = {
v1: "당신은 주문 어시스턴트입니다. 도구로 주문을 조회한 뒤 사용자에게 답하십시오.",
v2:
"당신은 주문 어시스턴트입니다. 도구로 주문을 조회한 뒤 사용자에게 답하십시오.\n" +
"두 가지 하드 규칙:\n" +
"1. 사용자가 주문번호를 주지 않았을 때는 먼저 주문번호를 물어보고, 추측해서 조회하지 마십시오.\n" +
"2. 고객에게 보내는 환불 안내에는 환불 금액과 입금 시점을 반드시 밝히십시오.",
};
const SCRIPT_V1 = {
"t1-total": [
useTools([toolUse("tu_1", "search_orders", { customer: "계명테크", status: "complete" })], [420, 60]),
useTools(
[
toolUse("tu_2", "get_order", { order_id: "SO-1001" }),
toolUse("tu_3", "get_order", { order_id: "SO-1002" }),
],
[520, 88]
),
finish("고객 계명테크의 2026년 8월 완료 주문은 2건(SO-1001, SO-1002)이고 합계 ₩1,280.00입니다.", [660, 52]),
],
"t2-pending": [
useTools([toolUse("tu_1", "search_orders", { status: "pending" })], [415, 46]),
finish("현재 미출고 주문은 SO-1003과 SO-1004입니다.", [500, 34]),
],
"t3-no-orderid": [
useTools([toolUse("tu_1", "search_orders", {})], [408, 38]),
useTools([toolUse("tu_2", "get_order", { order_id: "SO-1001" })], [470, 44]),
finish("주문 SO-1001의 상태는 완료입니다.", [560, 30]),
],
"t4-refund-note": [
useTools([toolUse("tu_1", "get_order", { order_id: "SO-1003" })], [430, 42]),
finish(
"안녕하세요, 주문 SO-1003(금액 ₩320.00)의 취소 요청을 접수했습니다. 환불은 원 결제수단으로 돌려드립니다. 불편을 드려 죄송합니다.",
[540, 76]
),
],
"t5-missing-order": [
useTools([toolUse("tu_1", "get_order", { order_id: "SO-9999" })], [412, 40]),
finish("시스템에서 주문 SO-9999를 찾을 수 없습니다. 주문번호가 맞는지 확인해 주세요.", [478, 36]),
],
};
// v2는 두 태스크의 응답만 갈아 끼운다 — 버전 차이가 여기에 고정되어 있다
const SCRIPT_V2 = {
...SCRIPT_V1,
"t3-no-orderid": [
finish("어느 주문을 말씀하시는 걸까요? 주문번호(SO-1001 형식)를 알려 주세요.", [455, 32]),
],
"t4-refund-note": [
useTools([toolUse("tu_1", "get_order", { order_id: "SO-1003" })], [462, 42]),
finish(
"안녕하세요, 주문 SO-1003(금액 ₩320.00)의 취소 요청을 접수했습니다. 환불은 원 결제수단으로 돌려드리며, 보통 영업일 기준 3~5일 안에 입금됩니다. 불편을 드려 죄송합니다.",
[572, 94]
),
],
};
const SCRIPTS = { v1: SCRIPT_V1, v2: SCRIPT_V2 };
// ============ 4. 판정자: 이쪽도 스텁, 0.0-1.0과 통과/실패를 출력 ============
const JUDGE_PROMPT = `당신은 채점자입니다. 아래 루브릭으로 이 고객 응대 답변을 채점하되, 근거를 먼저 쓰고 점수를 나중에 내십시오.
루브릭 (각 항목 0 또는 1, 평균을 총점으로 함):
- 금액 정확: 환불 금액이 명시되어 있고 주문 금액과 일치한다
- 입금 시점: 환불 입금 시점이 명시되어 있다
- 톤 적절: 고객에게 그대로 보낼 수 있는 표현이다
JSON만 출력할 것: {"reasoning": "...", "score": 0.0-1.0, "grade": "pass" | "fail"}
score >= 0.8이면 pass로 친다.`;
const JUDGE_SCRIPTS = {
v1: {
"t4-refund-note": [
finish(
JSON.stringify({
reasoning: "금액은 주문과 일치하고 톤도 적절하다. 다만 환불 입금 시점이 빠져 있어 고객이 예상 시점을 알 수 없다. 3개 항목 중 1개 누락.",
score: 0.67,
grade: "fail",
}),
[286, 58]
),
],
},
v2: {
"t4-refund-note": [
finish(
JSON.stringify({
reasoning: "금액, 입금 시점, 톤 세 항목을 모두 충족한다. 고객에게 그대로 보낼 수 있다.",
score: 1.0,
grade: "pass",
}),
[302, 46]
),
],
},
};
async function judgeAnswer(task, answer, version, metrics) {
const client = stubClient(JUDGE_SCRIPTS[version][task.id], `judge/${version}/${task.id}`);
const res = await client.messages.create({
model: MODEL,
max_tokens: 1024,
system: JUDGE_PROMPT,
messages: [{ role: "user", content: `【과제】${task.prompt}\n【채점 대상 답변】${answer}` }],
});
metrics.tokens += res.usage.input_tokens + res.usage.output_tokens;
const verdict = JSON.parse(res.content.map((b) => b.text).join(""));
return { pass: verdict.grade === "pass", score: verdict.score, note: verdict.reasoning };
}
// ============ 5. 평가 세트: 일반 4개 + 엣지 케이스 1개 ============
function amountsIn(s) {
return [...s.replace(/[,,\s¥¥]/g, "").matchAll(/\d+(?:\.\d+)?/g)].map((m) => Number(m[0]));
}
function orderIdsIn(s) {
return [...new Set(s.match(/SO-\d+/g) ?? [])].sort();
}
const TASKS = [
{
id: "t1-total",
grader: "결정론적",
prompt: "고객 계명테크의 2026년 8월 완료 주문, 합계 금액이 얼마인가요?",
verify: (r) => ({
pass: amountsIn(r.answer).some((n) => Math.abs(n - 1280) < 0.005),
note: "답변에 1280이 들어 있어야 한다 (통화 기호, 천 단위 구분자, 단위 허용)",
}),
strictVerify: (r) => ({
pass: r.answer.includes("1280.00"),
note: "답변에 문자열 1280.00이 그대로 들어 있어야 한다",
}),
},
{
id: "t2-pending",
grader: "결정론적",
prompt: "아직 출고 안 된 주문이 어떤 것들인가요? 주문 ID를 알려 주세요.",
verify: (r) => ({
pass: JSON.stringify(orderIdsIn(r.answer)) === JSON.stringify(["SO-1003", "SO-1004"]),
note: "답변의 주문 ID 집합이 SO-1003 + SO-1004와 정확히 일치해야 한다",
}),
},
{
id: "t3-no-orderid",
grader: "결정론적",
prompt: "그 주문 상태 좀 확인해 주세요.",
verify: (r) => ({
pass: r.toolCalls === 0 && r.answer.includes("?") && r.answer.includes("주문번호"),
note: "파라미터가 불완전하면 도구를 한 번도 호출하지 말고 주문번호를 되물어야 한다",
}),
},
{
id: "t4-refund-note",
grader: "LLM 판정자",
judge: true,
prompt: "고객이 주문 SO-1003의 취소와 환불을 신청했습니다. 답변을 써 주세요.",
},
{
id: "t5-missing-order",
grader: "결정론적",
prompt: "주문 SO-9999의 상태를 확인해 주세요.",
verify: (r) => ({
pass: r.toolErrors === 1 && /찾을 수 없|존재하지 않/.test(r.answer) && !/[¥¥₩]|원/.test(r.answer),
note: "도구 에러 후에는 찾을 수 없다고 사실대로 말해야 하며 금액을 지어내면 안 된다",
}),
},
];
// ============ 6. 태스크 하나에 하네스 루프 하나 ============
async function runToolUses(content, metrics) {
const results = [];
for (const block of content) {
if (block.type !== "tool_use") continue;
metrics.toolCalls += 1;
try {
const out = TOOL_IMPL[block.name](block.input);
results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(out) });
} catch (err) {
metrics.toolErrors += 1;
results.push({ type: "tool_result", tool_use_id: block.id, content: err.message, is_error: true });
}
}
return results;
}
async function runTask(task, version) {
const metrics = { toolCalls: 0, toolErrors: 0, tokens: 0 };
const client = stubClient(SCRIPTS[version][task.id], `${version}/${task.id}`);
const system = SYSTEM_PROMPTS[version];
const messages = [{ role: "user", content: task.prompt }];
const startedAt = Date.now();
let response = await client.messages.create({ model: MODEL, max_tokens: 4096, system, tools: TOOLS, messages });
metrics.tokens += response.usage.input_tokens + response.usage.output_tokens;
while (response.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: response.content });
const toolResults = await runToolUses(response.content, metrics);
messages.push({ role: "user", content: toolResults });
response = await client.messages.create({ model: MODEL, max_tokens: 4096, system, tools: TOOLS, messages });
metrics.tokens += response.usage.input_tokens + response.usage.output_tokens;
}
const answer = response.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n");
let verdict;
if (task.judge) {
verdict = await judgeAnswer(task, answer, version, metrics);
} else {
const fn = STRICT && task.strictVerify ? task.strictVerify : task.verify;
const out = fn({ answer, ...metrics });
verdict = { pass: out.pass, score: out.pass ? 1 : 0, note: out.note };
}
return {
id: task.id,
grader: task.grader,
pass: verdict.pass,
score: verdict.score,
note: verdict.note,
answer,
durationMs: Date.now() - startedAt,
...metrics,
};
}
async function runSuite(version) {
const rows = [];
for (const task of TASKS) rows.push(await runTask(task, version));
return {
version,
verifier: STRICT ? "strict (옛 버전, 정규화 없음)" : "normalized (수정 후)",
rows,
passed: rows.filter((r) => r.pass).length,
total: rows.length,
toolCalls: rows.reduce((n, r) => n + r.toolCalls, 0),
toolErrors: rows.reduce((n, r) => n + r.toolErrors, 0),
tokens: rows.reduce((n, r) => n + r.tokens, 0),
durationMs: rows.reduce((n, r) => n + r.durationMs, 0),
};
}
// ============ 7. 리포트 ============
const CJK = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/;
const width = (s) => [...String(s)].reduce((n, ch) => n + (CJK.test(ch) ? 2 : 1), 0);
const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - width(s)));
const padL = (s, n) => " ".repeat(Math.max(0, n - width(s))) + String(s);
function printReport(report) {
console.log(`\n=== 리포트 · 프롬프트 ${report.version} · 검증기 ${report.verifier} ===`);
console.log(
pad("태스크", 18) + pad("채점 방식", 14) + pad("결과", 8) + padL("점수", 6) +
padL("호출", 6) + padL("에러", 6) + padL("토큰", 10) + padL("소요 시간", 12)
);
console.log("-".repeat(80));
for (const r of report.rows) {
console.log(
pad(r.id, 18) + pad(r.grader, 14) + pad(r.pass ? "pass" : "FAIL", 8) +
padL(r.score.toFixed(2), 6) + padL(r.toolCalls, 6) + padL(r.toolErrors, 6) +
padL(r.tokens.toLocaleString("en-US"), 10) + padL(`${r.durationMs}ms`, 12)
);
}
console.log("-".repeat(80));
const rate = ((report.passed / report.total) * 100).toFixed(0);
console.log(
`통과율 ${report.passed}/${report.total} (${rate}%) · 도구 호출 ${report.toolCalls} · ` +
`도구 에러 ${report.toolErrors} · 토큰 ${report.tokens.toLocaleString("en-US")} · 합계 ${report.durationMs}ms`
);
const failed = report.rows.filter((r) => !r.pass);
if (failed.length) {
console.log("\n미통과 케이스:");
for (const r of failed) {
console.log(` [${r.id}] 판정 기준: ${r.note}`);
console.log(` 에이전트 답변: ${r.answer}`);
}
}
}
function printDiff(a, b) {
console.log(`\n=== 점수 변화 ${a.version} -> ${b.version} ===`);
console.log(pad("태스크", 18) + padL(a.version, 7) + padL(b.version, 7) + " 변화");
console.log("-".repeat(50));
for (let i = 0; i < a.rows.length; i++) {
const x = a.rows[i], y = b.rows[i];
let mark = "변화 없음";
if (!x.pass && y.pass) mark = "fail => pass";
else if (x.pass && !y.pass) mark = "pass => FAIL";
else if (y.score !== x.score) mark = `점수 ${(y.score - x.score).toFixed(2)}`;
console.log(pad(x.id, 18) + padL(x.score.toFixed(2), 7) + padL(y.score.toFixed(2), 7) + " " + mark);
}
console.log("-".repeat(50));
console.log(`통과율 ${a.passed}/${a.total} -> ${b.passed}/${b.total}`);
}
// ============ 8. 진입점 ============
const reportV1 = await runSuite("v1");
printReport(reportV1);
const reportV2 = await runSuite("v2");
printReport(reportV2);
printDiff(reportV1, reportV2);