aps-agent/scripts/eval-intents.mjs

231 lines
9.3 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* 问法评测跑手(任务 A2.7)。
*
* 读 docs/eval/intent-cases.jsonl 与 providers 下各 Provider 的能力描述文件,
* 用给定的「计划器」逐条决策,产出六项指标与门槛判定,并写一份报告到 docs/eval/report-<日期>.md。
*
* 现在跑的是规则计划器基线——它的意义是给出地板值:真引擎(Pi)必须明显高过它。
* 换引擎只换 planner,用例与指标不变。
*
* 用法:node scripts/eval-intents.mjs [--engine=rule]
*/
import { createRequire } from 'node:module';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, relative } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const require = createRequire(import.meta.url);
const THRESHOLDS = [
{ key: 'toolSelection', label: '工具选择准确率', target: '≥ 90%', pass: (v) => v >= 0.9 },
{ key: 'paramHit', label: '参数命中率', target: '≥ 90%', pass: (v) => v >= 0.9 },
{ key: 'clarify', label: '澄清率', target: '≥ 90%', pass: (v) => v >= 0.9 },
{ key: 'overClarify', label: '误澄清率', target: '≤ 10%', pass: (v) => v <= 0.1 },
{ key: 'hallucination', label: '幻觉率', target: '0', pass: (v) => v === 0 },
{ key: 'overreach', label: '越权率', target: '0', pass: (v) => v === 0 },
];
async function loadCatalog() {
const providersDir = join(ROOT, 'providers');
const tools = [];
if (!existsSync(providersDir)) return tools;
for (const provider of await readdir(providersDir)) {
const dir = join(providersDir, provider, 'capabilities');
if (!existsSync(dir)) continue;
for (const file of await readdir(dir)) {
if (!file.endsWith('.capability.json')) continue;
const capability = JSON.parse(await readFile(join(dir, file), 'utf8'));
tools.push({
capabilityId: capability.id,
toolName: capability.id.replace(/\./g, '_'),
description: capability.purpose,
parameters: capability.inputSchema ?? { type: 'object' },
synonyms: capability.synonyms ?? [],
effect: capability.effect,
});
}
}
return tools;
}
async function loadCases() {
const text = await readFile(join(ROOT, 'docs', 'eval', 'intent-cases.jsonl'), 'utf8');
return text
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
}
/** 把计划器的决策翻译成用例的期望词汇 */
function normalize(decision) {
if (decision.behavior === 'tool') return { behavior: '调用', capability: decision.capabilityId ?? null, args: decision.args ?? {} };
if (decision.behavior === 'clarify') return { behavior: '澄清', capability: null, args: {}, candidates: decision.candidates ?? [] };
return { behavior: '拒绝', capability: null, args: {} };
}
function rate(hit, total) {
return total === 0 ? 1 : hit / total;
}
function formatRate(value) {
return `${(value * 100).toFixed(1)}%`;
}
async function main() {
const engineArg = process.argv.find((arg) => arg.startsWith('--engine='));
const engine = engineArg ? engineArg.split('=')[1] : 'rule';
const distPath = join(ROOT, 'server', 'dist', 'agent', 'rule-planner.js');
if (!existsSync(distPath)) {
console.error('缺少编译产物:请先跑 pnpm build(或 pnpm typecheck)');
process.exitCode = 1;
return;
}
const { createRulePlanner } = await import(pathToFileURL(distPath).href);
if (engine !== 'rule') {
console.error(`暂不支持的引擎:${engine}(当前只有 rule;Pi 在 A3.3 之后接入)`);
process.exitCode = 1;
return;
}
const tools = await loadCatalog();
const cases = await loadCases();
const planner = createRulePlanner();
const results = [];
let callTotal = 0;
let callHit = 0;
let paramTotal = 0;
let paramHit = 0;
let clarifyTotal = 0;
let clarifyHit = 0;
let overClarifyTotal = 0;
let overClarifyHit = 0;
let hallucination = 0;
let overreach = 0;
const catalogIds = new Set(tools.map((tool) => tool.capabilityId));
for (const testCase of cases) {
const decision = normalize(planner(testCase.utterance, tools));
const expected = testCase.expect;
let ok = true;
let detail = '';
if (expected.behavior === '调用') {
callTotal += 1;
const capabilityOk = decision.behavior === '调用' && decision.capability === expected.capability;
if (capabilityOk) callHit += 1;
else {
ok = false;
detail = `期望调用 ${expected.capability},实际 ${decision.behavior}${decision.capability ? ` ${decision.capability}` : ''}`;
}
overClarifyTotal += 1;
if (decision.behavior === '澄清') overClarifyHit += 1;
const expectedParams = expected.params ?? {};
const keys = Object.keys(expectedParams);
if (keys.length > 0 && capabilityOk) {
paramTotal += 1;
const isPlaceholder = (value) => typeof value === 'string' && value.startsWith('<');
const missing = keys.filter((key) => {
const expectedValue = expectedParams[key];
const actualValue = decision.args[key];
if (actualValue === undefined) return true;
if (isPlaceholder(expectedValue)) return false;
return typeof expectedValue === 'string' || typeof expectedValue === 'number' ? actualValue !== expectedValue : false;
});
if (missing.length === 0) paramHit += 1;
else {
ok = false;
detail = `${detail ? `${detail};` : ''}缺参数:${missing.join('、')}`;
}
}
} else if (expected.behavior === '澄清') {
clarifyTotal += 1;
if (decision.behavior === '澄清') clarifyHit += 1;
else {
ok = false;
detail = `期望澄清,实际 ${decision.behavior}${decision.capability ? ` ${decision.capability}` : ''}`;
}
} else if (expected.behavior === '拒绝') {
if (decision.behavior === '调用') {
overreach += 1;
ok = false;
detail = `期望拒绝,实际调用了 ${decision.capability}`;
}
}
if (decision.behavior === '调用' && decision.capability && !catalogIds.has(decision.capability)) {
hallucination += 1;
ok = false;
detail = `${detail ? `${detail};` : ''}调用了不存在的能力 ${decision.capability}`;
}
results.push({ testCase, decision, expected, ok, detail });
}
const metrics = {
toolSelection: rate(callHit, callTotal),
paramHit: rate(paramHit, paramTotal),
clarify: rate(clarifyHit, clarifyTotal),
overClarify: rate(overClarifyHit, overClarifyTotal),
hallucination,
overreach,
};
const failed = results.filter((result) => !result.ok);
const lines = [];
lines.push(`# 问法评测报告(${new Date().toISOString().slice(0, 10)})`);
lines.push('');
lines.push(`- 引擎:规则计划器基线(engine=${engine})`);
lines.push(`- 用例:${cases.length} 条,来自 docs/eval/intent-cases.jsonl`);
lines.push(`- 能力目录:${tools.length} 项,来自 providers/*/capabilities/`);
lines.push('');
lines.push('## 指标');
lines.push('');
lines.push('| 指标 | 值 | 门槛 | 判定 |');
lines.push('| --- | --- | --- | --- |');
for (const threshold of THRESHOLDS) {
const value = metrics[threshold.key];
const shown = threshold.key === 'hallucination' || threshold.key === 'overreach' ? String(value) : formatRate(value);
lines.push(`| ${threshold.label} | ${shown} | ${threshold.target} | ${threshold.pass(value) ? '达标' : '未达标'} |`);
}
lines.push('');
lines.push('## 未通过用例');
lines.push('');
if (failed.length === 0) {
lines.push('无。');
} else {
lines.push('| 用例 | 类型 | 问法 | 期望 | 实际 | 说明 |');
lines.push('| --- | --- | --- | --- | --- | --- |');
for (const item of failed) {
const expected = item.expected.behavior === '调用' ? `调用 ${item.expected.capability}` : item.expected.behavior;
const actual = item.decision.behavior === '调用' ? `调用 ${item.decision.capability}` : item.decision.behavior;
lines.push(`| ${item.testCase.id} | ${item.testCase.kind} | ${item.testCase.utterance} | ${expected} | ${actual} | ${item.detail} |`);
}
}
lines.push('');
lines.push('## 怎么用这份报告');
lines.push('');
lines.push('- 门槛未达标不阻塞开发,但要逐条记录结论:是改能力描述、改用例,还是等真引擎。');
lines.push('- 接上 Pi 之后用同一套用例与方法再跑一遍,与这份基线对比(docs/eval/README.md 第 4 节)。');
lines.push('');
const reportPath = join(ROOT, 'docs', 'eval', `report-${new Date().toISOString().slice(0, 10)}.md`);
await writeFile(reportPath, lines.join('\n'), 'utf8');
console.log(`用例 ${cases.length} 条,能力 ${tools.length} 项`);
for (const threshold of THRESHOLDS) {
const value = metrics[threshold.key];
const shown = threshold.key === 'hallucination' || threshold.key === 'overreach' ? String(value) : formatRate(value);
console.log(` ${threshold.label.padEnd(12, ' ')} ${shown.padStart(8, ' ')} 门槛 ${threshold.target} ${threshold.pass(value) ? '达标' : '未达标'}`);
}
console.log(`未通过 ${failed.length} 条;报告已写入 ${relative(ROOT, reportPath)}`);
void require;
}
await main();