// eval-runner.mjs — タスクごとに harness ループを1本回す評価トラック
//
// 使い方:
// node eval-runner.mjs 修正後(正規化あり)の検証器を使う
// node eval-runner.mjs --strict-verify 旧版の正規化なし検証器を使い、偽陰性を見る
const STRICT = process.argv.includes("--strict-verify");
const MODEL = "claude-opus-5"; // スタブは無視する。実クライアントに差し替えるときは 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 のうち少なくとも1つを指定すること。",
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 のうち少なくとも1つを指定してください");
}
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. 2つのシステムプロンプトとその応答キュー ============
const SYSTEM_PROMPTS = {
v1: "あなたは注文アシスタントです。ツールで注文を照会し、ユーザーに答えてください。",
v2:
"あなたは注文アシスタントです。ツールで注文を照会し、ユーザーに答えてください。\n" +
"ハードルールが2つあります:\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 は2つのタスクの応答だけを差し替える。バージョン差分はここに固定されている
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: "金額・入金時期・トーンの3項目をすべて満たしており、そのまま顧客に送れる。",
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: "まだ発送されていない注文はどれですか。注文番号を挙げてください。",
verify: (r) => ({
pass: JSON.stringify(orderIdsIn(r.answer)) === JSON.stringify(["SO-1003", "SO-1004"]),
note: "回答内の注文番号の集合が 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. タスク1件につき harness ループ1本 ============
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("判定方式", 16) + pad("結果", 8) + padL("スコア", 8) +
padL("呼出", 6) + padL("エラー", 8) + padL("トークン", 12) + padL("所要時間", 12)
);
console.log("-".repeat(88));
for (const r of report.rows) {
console.log(
pad(r.id, 18) + pad(r.grader, 16) + pad(r.pass ? "pass" : "FAIL", 8) +
padL(r.score.toFixed(2), 8) + padL(r.toolCalls, 6) + padL(r.toolErrors, 8) +
padL(r.tokens.toLocaleString("en-US"), 12) + padL(`${r.durationMs}ms`, 12)
);
}
console.log("-".repeat(88));
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(` Agent の回答: ${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);