Commit fadb6fa18ab0c903f1fd32be5eef87460aa11f15

Authored by yibo
1 parent 1b6d4355

feat(validation): 提案 B/C/D 一起落地——证据可见性 + 复用检查工具 + 引擎证据闸门

35 号文档剩下的三个提案,彼此直接相关(C 产出 D 消费的证据,B 展示 D 算出的结果),
一次做完。

提案 D(goal-contract.js 引擎级证据闸门):
  `runBenchmark` 新增 evidence 检测——`evidence_hint` 命中"浏览器/视口/开发者工具/
  console/network/截图"类关键词的维度,必须在 evidence.<dim> 有真实字符串支撑,
  否则该维度判不过(哪怕分数达标)。证据来源两处都认:`--target` 文件自带的
  `evidence.<dim>`,或同目录姊妹判词文件 `attempt-<n>.json` 的 `dimensions[].evidence`
  ——后者是 benchmark-validator/agent.md 早就在教的既有写法,不用 agent 多写一份。
  新增 `dimensions[].evidence_required` 字段随判决落盘。6 条新单测。

提案 C(可复用视觉验收工具):
  新增 scripts/visual-acceptance-check.mjs,四个子命令(overflow/style/console/
  contrast),封装最常见的几类真实浏览器检查(Playwright),agent 通过 bash 直接
  调用,不用每次自己临时想办法(真实撞见过两个极端:一次真写了 Playwright 认真测,
  一次只写了正则解析文本)。配一份不进 vitest 快速循环的独立冒烟脚本(起真
  Chromium,几秒量级)。benchmark-validator/agent.md Step 2 加了引用说明。

提案 B(控制台证据可见性):
  GET /api/graph-runs/:id 新增 benchmarkVerdicts 字段(只读 goal-contract.js 已落盘
  的判决,不重新计算)。NodeDetailPanel 概览 tab 新增"验收证据"小节:逐维度显示
  分数 + 是否有证据支撑,evidence_required 且缺证据的标 ⚠️。纯展示,不改判定逻辑。
  2 条新单测。

全量单测 1012/1012 通过;控制台功能用 Chrome 实测过(截图确认"验收证据"小节正确
渲染)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scripts/visual-acceptance-check.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * visual-acceptance-check.mjs — 可复用的真实浏览器验收检查(35 号文档提案 C)
  4 + *
  5 + * 起因:真实回归任务里,goal-benchmark.json 的 evidence_hint 常常点名"打开浏览器 /
  6 + * 依次设置视口宽度 / 开发者工具 Console+Network 面板"这类检查,但系统里没有一份
  7 + * 标准化的工具——每次都靠 agent 自己临时想(这次撞见的两种极端:一次真的写了一份
  8 + * Playwright 脚本认真测,一次只写了正则解析文本)。这份脚本把最常见的几类检查
  9 + * 封装成一个通用工具,agent 通过 bash 直接调用,不用每次重新发明。
  10 + *
  11 + * 用法:
  12 + * node scripts/visual-acceptance-check.mjs overflow --file index.html --widths 375,768,1440
  13 + * node scripts/visual-acceptance-check.mjs style --file index.html --selector .hero-title --prop font-size
  14 + * node scripts/visual-acceptance-check.mjs contrast --fg "#1d1d1f" --bg "#ffffff"
  15 + * node scripts/visual-acceptance-check.mjs console --file index.html
  16 + *
  17 + * 每个子命令把结果打到 stdout(一行 JSON),可以直接摘录进 attempt-N.scores.json 的
  18 + * evidence.<dimension> 字段(比如 `node ... | node -e "console.log(JSON.parse(require('fs').readFileSync(0)).ratio)"`,
  19 + * 或者更常见的做法:把这行 JSON 原样贴进 evidence 文本)。
  20 + *
  21 + * 退出码:0 = 检查本身跑完了(不代表"通过",通不通过看输出里的字段);
  22 + * 1 = 参数错 / 文件不存在 / 浏览器跑不起来,检查没能真正执行。
  23 + * 这个区分很重要:agent 应该只在退出码 0 时把输出当"真实证据"贴进 evidence,
  24 + * 退出码 1 时说明检查本身没跑成,不能拿它的输出(如果有)冒充证据。
  25 + */
  26 +
  27 +import fs from 'fs';
  28 +import path from 'path';
  29 +import { pathToFileURL } from 'url';
  30 +
  31 +function parseArgs(argv) {
  32 + const out = { _: [] };
  33 + for (let i = 0; i < argv.length; i++) {
  34 + const a = argv[i];
  35 + if (a.startsWith('--')) {
  36 + const key = a.slice(2);
  37 + const next = argv[i + 1];
  38 + if (next === undefined || next.startsWith('--')) { out[key] = true; }
  39 + else { out[key] = next; i += 1; }
  40 + } else out._.push(a);
  41 + }
  42 + return out;
  43 +}
  44 +
  45 +function fail(msg) {
  46 + process.stderr.write(`ERROR ${msg}\n`);
  47 + process.exit(1);
  48 +}
  49 +
  50 +function requireFile(p) {
  51 + const abs = path.resolve(p);
  52 + if (!fs.existsSync(abs)) fail(`文件不存在: ${abs}`);
  53 + return abs;
  54 +}
  55 +
  56 +function parseWidths(s) {
  57 + const widths = String(s || '').split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n) && n > 0);
  58 + if (!widths.length) fail('--widths 需要至少一个正整数,用逗号分隔,如 375,768,1440');
  59 + return widths;
  60 +}
  61 +
  62 +async function loadChromium() {
  63 + try {
  64 + const { chromium } = await import('playwright');
  65 + return chromium;
  66 + } catch (e) {
  67 + fail(`加载 playwright 失败(${e.message})——确认在仓库根目录下跑这份脚本,node_modules/playwright 才找得到`);
  68 + }
  69 +}
  70 +
  71 +// ── overflow:多档视口宽度下有没有横向溢出 ──────────────────────────
  72 +async function cmdOverflow(args) {
  73 + const file = requireFile(args.file || fail('--file 必填'));
  74 + const widths = parseWidths(args.widths);
  75 + const height = Number(args.height) || 900;
  76 + const chromium = await loadChromium();
  77 + const browser = await chromium.launch();
  78 + try {
  79 + const page = await browser.newPage();
  80 + const results = [];
  81 + for (const width of widths) {
  82 + await page.setViewportSize({ width, height });
  83 + await page.goto(pathToFileURL(file).href, { waitUntil: 'load' });
  84 + const m = await page.evaluate(() => ({
  85 + scrollWidth: document.documentElement.scrollWidth,
  86 + clientWidth: document.documentElement.clientWidth,
  87 + }));
  88 + results.push({ width, ...m, overflow: m.scrollWidth > m.clientWidth });
  89 + }
  90 + print({ file, widths: results, overflow_any: results.some((r) => r.overflow) });
  91 + } finally {
  92 + await browser.close();
  93 + }
  94 +}
  95 +
  96 +// ── style:某个选择器在指定视口下的某个 computedStyle 属性 ──────────
  97 +async function cmdStyle(args) {
  98 + const file = requireFile(args.file || fail('--file 必填'));
  99 + const selector = args.selector || fail('--selector 必填');
  100 + const prop = args.prop || fail('--prop 必填');
  101 + const width = Number(args.width) || 1440;
  102 + const height = Number(args.height) || 900;
  103 + const chromium = await loadChromium();
  104 + const browser = await chromium.launch();
  105 + try {
  106 + const page = await browser.newPage();
  107 + await page.setViewportSize({ width, height });
  108 + await page.goto(pathToFileURL(file).href, { waitUntil: 'load' });
  109 + const value = await page.evaluate(({ selector, prop }) => {
  110 + const el = document.querySelector(selector);
  111 + if (!el) return null;
  112 + return getComputedStyle(el)[prop] ?? null;
  113 + }, { selector, prop });
  114 + print({ file, selector, prop, width, height, value, found: value !== null });
  115 + } finally {
  116 + await browser.close();
  117 + }
  118 +}
  119 +
  120 +// ── console:加载页面时的 console 错误 + 失败的网络请求 ──────────────
  121 +async function cmdConsole(args) {
  122 + const file = requireFile(args.file || fail('--file 必填'));
  123 + const width = Number(args.width) || 1440;
  124 + const height = Number(args.height) || 900;
  125 + const chromium = await loadChromium();
  126 + const browser = await chromium.launch();
  127 + try {
  128 + const page = await browser.newPage();
  129 + await page.setViewportSize({ width, height });
  130 + const consoleErrors = [];
  131 + const failedRequests = [];
  132 + page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
  133 + page.on('requestfailed', (req) => failedRequests.push({ url: req.url(), failure: req.failure()?.errorText || null }));
  134 + await page.goto(pathToFileURL(file).href, { waitUntil: 'load' });
  135 + print({
  136 + file, console_errors: consoleErrors, failed_requests: failedRequests,
  137 + clean: consoleErrors.length === 0 && failedRequests.length === 0,
  138 + });
  139 + } finally {
  140 + await browser.close();
  141 + }
  142 +}
  143 +
  144 +// ── contrast:WCAG 相对亮度对比度,纯计算,不需要浏览器 ─────────────
  145 +function srgbToLinear(c) {
  146 + const v = c / 255;
  147 + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
  148 +}
  149 +function relativeLuminance(hex) {
  150 + const h = hex.replace('#', '');
  151 + const r = parseInt(h.slice(0, 2), 16);
  152 + const g = parseInt(h.slice(2, 4), 16);
  153 + const b = parseInt(h.slice(4, 6), 16);
  154 + if ([r, g, b].some((n) => Number.isNaN(n))) return null;
  155 + return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
  156 +}
  157 +function cmdContrast(args) {
  158 + const fg = args.fg || fail('--fg 必填(如 "#1d1d1f")');
  159 + const bg = args.bg || fail('--bg 必填(如 "#ffffff")');
  160 + const lFg = relativeLuminance(fg);
  161 + const lBg = relativeLuminance(bg);
  162 + if (lFg === null || lBg === null) fail(`颜色解析失败,需要 #rrggbb 形式:fg=${fg} bg=${bg}`);
  163 + const [lighter, darker] = lFg > lBg ? [lFg, lBg] : [lBg, lFg];
  164 + const ratio = (lighter + 0.05) / (darker + 0.05);
  165 + print({
  166 + fg, bg, ratio: Number(ratio.toFixed(2)),
  167 + wcag_aa_normal_text: ratio >= 4.5, // WCAG 2.1 AA:正文 ≥4.5:1
  168 + wcag_aa_large_text: ratio >= 3, // WCAG 2.1 AA:大字号(≥18pt 或 14pt 加粗)≥3:1
  169 + });
  170 +}
  171 +
  172 +function print(obj) {
  173 + process.stdout.write(`${JSON.stringify(obj)}\n`);
  174 +}
  175 +
  176 +async function main() {
  177 + const [cmd, ...rest] = process.argv.slice(2);
  178 + const args = parseArgs(rest);
  179 + switch (cmd) {
  180 + case 'overflow': return cmdOverflow(args);
  181 + case 'style': return cmdStyle(args);
  182 + case 'console': return cmdConsole(args);
  183 + case 'contrast': return cmdContrast(args);
  184 + default:
  185 + process.stderr.write([
  186 + '用法: node scripts/visual-acceptance-check.mjs <overflow|style|console|contrast> [选项]',
  187 + ' overflow --file <path> --widths <w1,w2,...> [--height <h>]',
  188 + ' style --file <path> --selector <sel> --prop <cssProp> [--width <w>] [--height <h>]',
  189 + ' console --file <path> [--width <w>] [--height <h>]',
  190 + ' contrast --fg <hex> --bg <hex>',
  191 + '',
  192 + ].join('\n'));
  193 + process.exit(cmd ? 1 : 0);
  194 + }
  195 +}
  196 +
  197 +main().catch((e) => fail(e.stack || e.message));
... ...
scripts/visual-acceptance-check.smoke.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 冒烟检查(不进 vitest 套件):visual-acceptance-check.mjs 四个子命令各跑一次,
  4 + * 用真实 Chromium + 一份现造的最小 HTML fixture,断言输出形状与已知答案。
  5 + * 起真浏览器要几秒,故意不挂进 vitest 的快速单测循环——手动跑:
  6 + * node scripts/visual-acceptance-check.smoke.mjs
  7 + */
  8 +import assert from 'assert';
  9 +import { execFileSync } from 'child_process';
  10 +import fs from 'fs';
  11 +import os from 'os';
  12 +import path from 'path';
  13 +import { fileURLToPath } from 'url';
  14 +
  15 +const CLI = fileURLToPath(new URL('./visual-acceptance-check.mjs', import.meta.url));
  16 +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-check-smoke-'));
  17 +const htmlPath = path.join(tmpDir, 'index.html');
  18 +
  19 +// 故意做成「小宽度会溢出、大宽度不会」,且带一个必定失败的图片请求,
  20 +// 已知答案才好断言,不是随手糊一份页面
  21 +fs.writeFileSync(htmlPath, `<!DOCTYPE html>
  22 +<html><head><style>
  23 + body { margin: 0; }
  24 + .wide { width: 500px; height: 10px; background: #000; }
  25 + .title { color: #1d1d1f; background: #ffffff; font-size: 40px; }
  26 +</style></head>
  27 +<body>
  28 + <div class="title">Hi</div>
  29 + <div class="wide"></div>
  30 + <img src="./does-not-exist.png">
  31 + <script>console.error('boom');</script>
  32 +</body></html>`);
  33 +
  34 +function run(args) {
  35 + const out = execFileSync(process.execPath, [CLI, ...args], { encoding: 'utf8' });
  36 + return JSON.parse(out.trim().split('\n').pop());
  37 +}
  38 +
  39 +// ① overflow:375 应该溢出(.wide 是 500px),1440 不该溢出
  40 +{
  41 + const r = run(['overflow', '--file', htmlPath, '--widths', '375,1440']);
  42 + const at375 = r.widths.find((w) => w.width === 375);
  43 + const at1440 = r.widths.find((w) => w.width === 1440);
  44 + assert.strictEqual(at375.overflow, true, '375px 视口下 500px 宽的 div 应该判定为溢出');
  45 + assert.strictEqual(at1440.overflow, false, '1440px 视口下不该溢出');
  46 + assert.strictEqual(r.overflow_any, true);
  47 + console.log('✓ overflow');
  48 +}
  49 +
  50 +// ② style:真读到 .title 的 font-size 计算值
  51 +{
  52 + const r = run(['style', '--file', htmlPath, '--selector', '.title', '--prop', 'font-size']);
  53 + assert.strictEqual(r.value, '40px', `期望 40px,实得 ${r.value}`);
  54 + assert.strictEqual(r.found, true);
  55 + console.log('✓ style');
  56 +}
  57 +
  58 +// ③ console:真捕获到 console.error 与失败的图片请求
  59 +{
  60 + const r = run(['console', '--file', htmlPath]);
  61 + assert.ok(r.console_errors.includes('boom'), 'console_errors 应包含页面里那条 console.error');
  62 + assert.ok(r.failed_requests.length >= 1, '应该捕获到 does-not-exist.png 的失败请求');
  63 + assert.strictEqual(r.clean, false);
  64 + console.log('✓ console');
  65 +}
  66 +
  67 +// ④ contrast:#1d1d1f / #ffffff 是真实网站常用的高对比度组合,已知比值约 16.8:1
  68 +{
  69 + const r = run(['contrast', '--fg', '#1d1d1f', '--bg', '#ffffff']);
  70 + assert.ok(r.ratio > 16 && r.ratio < 17.5, `期望约 16.8:1,实得 ${r.ratio}`);
  71 + assert.strictEqual(r.wcag_aa_normal_text, true);
  72 + console.log('✓ contrast');
  73 +}
  74 +
  75 +fs.rmSync(tmpDir, { recursive: true, force: true });
  76 +console.log('全部通过');
... ...
server/routes/graph-run.js
... ... @@ -340,6 +340,53 @@ router.post(&#39;/:id/nodes/:nodeId/retry&#39;, h((req, res) =&gt; {
340 340 * loopRecords = 回退账本,画布上 `N/3` 徽标的数据来源(§四.B.2)
341 341 * budget = root run 的 budget_json 解析结果,页头预算条用(§四.B.4);非 root run 也回这个值
342 342 */
  343 +/**
  344 + * 35 号文档提案 B:控制台"这份验收有没有证据支撑"要读的数据。
  345 + *
  346 + * 只读 `goal-contract.js` 已经落盘的判决索引 + 判决全文(`verdicts/index.ndjson` +
  347 + * `verdicts/{id}.json`),不重新计算——判决在 `runAcceptance`/`goal-contract.js run`
  348 + * 那一刻就已经算好了,这里只是把 `sections.benchmark.dimensions[]`(含提案 D 新加的
  349 + * `evidence_required`)摘出来给前端渲染。读不到 / 解析不出就跳过那一条,不拖垮整个
  350 + * 响应——这是纯展示数据,读盘失败不该让 GET /:id 报错。
  351 + */
  352 +function collectBenchmarkVerdicts(rootRun) {
  353 + if (!rootRun) return [];
  354 + let gc;
  355 + try {
  356 + gc = require('../services/goal-contract');
  357 + } catch {
  358 + return [];
  359 + }
  360 + const runDir = gc.resolveRunDir(rootRun.company_id, rootRun.root_run_id || rootRun.id);
  361 + let index;
  362 + try {
  363 + index = gc.listVerdicts(runDir, {});
  364 + } catch {
  365 + return [];
  366 + }
  367 + const out = [];
  368 + for (const entry of index) {
  369 + let full;
  370 + try {
  371 + full = gc.readVerdict(runDir, entry.verdict_id);
  372 + } catch {
  373 + full = null;
  374 + }
  375 + const dims = full && full.sections && full.sections.benchmark && full.sections.benchmark.dimensions;
  376 + if (!Array.isArray(dims)) continue;
  377 + out.push({
  378 + verdictId: entry.verdict_id,
  379 + nodeRunId: entry.node_run_id || null,
  380 + ok: entry.ok,
  381 + dimensions: dims.map((d) => ({
  382 + name: d.name, ok: d.ok, score: d.score,
  383 + evidenceRequired: Boolean(d.evidence_required),
  384 + })),
  385 + });
  386 + }
  387 + return out;
  388 +}
  389 +
343 390 router.get('/:id', h((req, res) => {
344 391 const state = getRunState(req.params.id);
345 392 if (!state) return res.status(404).json({ error: `graph run 不存在: ${req.params.id}` });
... ... @@ -373,6 +420,9 @@ router.get(&#39;/:id&#39;, h((req, res) =&gt; {
373 420 // (见 graph-engine.js「W2 · 每个 root run 一个项目根」那段),两者不是同一份数据。
374 421 // readProjectRootRecord 按 root_run_id 查,任一层 run 传进去结果相同(同一棵树共享一个项目根)。
375 422 projectRoot: engine.readProjectRootRecord(state.run),
  423 + // 35 号文档提案 B:每个 benchmark 判决按维度标了有没有证据支撑,前端拿去在
  424 + // 节点详情上画一个小标签——不改变任何判定逻辑,纯展示。
  425 + benchmarkVerdicts: collectBenchmarkVerdicts(rootRun),
376 426 loopRecords: listLoopRecords(state.run.id),
377 427 childRuns: listChildRuns(state.run.id),
378 428 budget,
... ...
server/services/goal-contract.js
... ... @@ -570,10 +570,50 @@ const SHORT_CIRCUIT_HINTS = {
570 570 };
571 571  
572 572 /**
  573 + * 35 号文档提案 D:evidence_hint 里点名"打开浏览器/视口/开发者工具/截图"这类维度,
  574 + * 光有一个分数不够——34 号文档真实撞见过 validator 给这类维度打满分、却没有任何
  575 + * 迹象证明它真的做过对应检查。这里只做**关键词检测 + 证据字段存在性检查**,
  576 + * 不校验证据内容真假(那需要重新起一次浏览器去验证,超出「引擎判分时能做什么」
  577 + * 的范围,留给下次;见 35 号文档 §二 提案 D 的范围说明)。
  578 + */
  579 +const EVIDENCE_REQUIRED_RE = /浏览器|viewport|视口|开发者工具|devtools|console|network|截图|screenshot/i;
  580 +function requiresEvidence(dimension) {
  581 + return EVIDENCE_REQUIRED_RE.test(String((dimension && dimension.evidence_hint) || ''));
  582 +}
  583 +
  584 +/**
  585 + * `--target` 通常是 `attempt-<n>.scores.json`(只有分数,见 benchmark-validator/agent.md
  586 + * Step 3),证据写在**同目录的姊妹文件** `attempt-<n>.json`(判词文件,Step 5)的
  587 + * `dimensions[].evidence` 里——这是已有的、agent.md 早就在教的约定,不是本次新加的格式。
  588 + * 这里只是把两份文件的证据接起来给 runBenchmark 用,不要求 agent 多写一份。
  589 + */
  590 +function readSiblingEvidence(targetAbs) {
  591 + if (!targetAbs || !targetAbs.endsWith('.scores.json')) return {};
  592 + const siblingPath = `${targetAbs.slice(0, -'.scores.json'.length)}.json`;
  593 + try {
  594 + const doc = JSON.parse(fs.readFileSync(siblingPath, 'utf8'));
  595 + const dims = Array.isArray(doc.dimensions) ? doc.dimensions : [];
  596 + const out = {};
  597 + for (const d of dims) {
  598 + if (d && typeof d.name === 'string' && typeof d.evidence === 'string' && d.evidence.trim()) {
  599 + out[d.name] = d.evidence;
  600 + }
  601 + }
  602 + return out;
  603 + } catch {
  604 + return {}; // 判词文件还没写 / 不是合法 JSON:不该拖垮打分本身,靠下面的证据缺失走正常失败分支
  605 + }
  606 +}
  607 +
  608 +/**
573 609 * benchmark:被测产物提供各维度分数,执行器只做加权与阈值比较(纯声明式,不起 LLM)。
574   - * 被测产物形态:{"scores": {"<dimension>": 87, ...}} 或直接 {"<dimension>": 87, ...}
  610 + * 被测产物形态:{"scores": {"<dimension>": 87, ...}} 或直接 {"<dimension>": 87, ...}。
  611 + * `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑——证据来源两处都认:
  612 + * `--target` 文件里自带的 `evidence.<dim>`(谁都可以这么写),或者同目录姊妹判词文件
  613 + * `attempt-<n>.json` 里 `dimensions[].evidence`(`benchmark-validator` 已有的既定写法,
  614 + * 见上面 `readSiblingEvidence`);`--target` 里的显式声明优先。
575 615 */
576   -function runBenchmark(sec, target) {
  616 +function runBenchmark(sec, target, targetAbs) {
577 617 // 短路分支也必须进 failures:verdict.failures 是下游(validator agent / validator-triager)
578 618 // 唯一的扁平归因入口,只留 reason 不留 failures 的话「读回」这一环就空了(§十 四环缺陷)。
579 619 if (!target.ok) {
... ... @@ -584,6 +624,8 @@ function runBenchmark(sec, target) {
584 624 };
585 625 }
586 626 const scores = (target.json && typeof target.json.scores === 'object' && target.json.scores) || target.json || {};
  627 + const explicitEvidence = (target.json && typeof target.json.evidence === 'object' && target.json.evidence) || {};
  628 + const evidence = { ...readSiblingEvidence(targetAbs), ...explicitEvidence };
587 629 const dims = [];
588 630 let weighted = 0;
589 631 let ok = true;
... ... @@ -592,7 +634,10 @@ function runBenchmark(sec, target) {
592 634 const score = scores[d.name];
593 635 const has = typeof score === 'number';
594 636 const threshold = typeof d.pass_threshold === 'number' ? d.pass_threshold : null;
595   - const passed = has && (threshold === null || score >= threshold);
  637 + const needsEvidence = requiresEvidence(d);
  638 + const hasEvidence = !needsEvidence
  639 + || (typeof evidence[d.name] === 'string' && evidence[d.name].trim() !== '');
  640 + const passed = has && hasEvidence && (threshold === null || score >= threshold);
596 641 if (!passed) ok = false;
597 642 if (has) weighted += score * d.weight;
598 643 dims.push({
... ... @@ -601,7 +646,8 @@ function runBenchmark(sec, target) {
601 646 score: has ? score : null,
602 647 pass_threshold: threshold,
603 648 ok: passed,
604   - reason: has ? undefined : 'missing score in target',
  649 + evidence_required: needsEvidence || undefined,
  650 + reason: !has ? 'missing score in target' : (!hasEvidence ? 'evidence_required' : undefined),
605 651 });
606 652 }
607 653  
... ... @@ -619,7 +665,9 @@ function runBenchmark(sec, target) {
619 665 case: d.name,
620 666 expected: d.pass_threshold,
621 667 got: d.score,
622   - hint: d.reason || 'dimension score below pass_threshold',
  668 + hint: d.reason === 'evidence_required'
  669 + ? `evidence_hint 要求真实检查(浏览器/视口/开发者工具类),但被测产物没有在 evidence.${d.name} 留下证据——只给了一个分数`
  670 + : (d.reason || 'dimension score below pass_threshold'),
623 671 })),
624 672 };
625 673 }
... ... @@ -818,7 +866,7 @@ async function executeContract(contract, targetPath, opts = {}) {
818 866 : { ok: true, json: null };
819 867  
820 868 const sections = { benchmark: null, test_data: null, test_script: null };
821   - if (contract.benchmark) sections.benchmark = runBenchmark(contract.benchmark, target);
  869 + if (contract.benchmark) sections.benchmark = runBenchmark(contract.benchmark, target, targetAbs);
822 870 if (contract.test_data) sections.test_data = runTestData(contract.test_data, target);
823 871 if (contract.test_script) {
824 872 // 脚本拿到的 ACCEPTANCE_TARGET 必须是绝对路径:子进程的 cwd 是 runDir/artifacts 下的某处,
... ...
server/tests/unit/goal-contract.test.js
... ... @@ -253,6 +253,98 @@ describe(&#39;执行器:benchmark&#39;, () =&gt; {
253 253 });
254 254 });
255 255  
  256 +// 35 号文档提案 D:evidence_hint 点名"浏览器/视口/开发者工具"类检查的维度,
  257 +// 没有 evidence.<dim> 就不能凭一个分数通过——真实撞见过 validator 给这类维度
  258 +// 打满分却没做过对应检查(见 34 号文档)。
  259 +describe('执行器:benchmark 的证据闸门(35 号文档提案 D)', () => {
  260 + const visualBenchmark = {
  261 + dimensions: [
  262 + {
  263 + name: 'hero_first_impression', weight: 0.5, pass_threshold: 80,
  264 + evidence_hint: '打开浏览器截图首屏,量标题与标语的计算字号比例',
  265 + },
  266 + { name: 'code_quality', weight: 0.5, pass_threshold: 70 }, // 不要求证据的普通维度
  267 + ],
  268 + overall_pass_threshold: 75,
  269 + };
  270 + const contract = { version: '1', goal_id: 'GOAL-visual', benchmark: visualBenchmark };
  271 +
  272 + it('要求证据的维度只给分数、没给 evidence:即使分数达标也判不过', async () => {
  273 + const runDir = newRunDir();
  274 + const target = writeTarget(runDir, 'rn_a', 1, {
  275 + scores: { hero_first_impression: 100, code_quality: 90 },
  276 + });
  277 + const v = await gc.executeContract(contract, target, { runDir });
  278 + expect(v.ok).toBe(false);
  279 + const f = v.failures.find((x) => x.case === 'hero_first_impression');
  280 + expect(f).toBeTruthy();
  281 + expect(f.hint).toMatch(/evidence_hint 要求真实检查/);
  282 + });
  283 +
  284 + it('给了 evidence.<dim> 之后,达标分数正常通过', async () => {
  285 + const runDir = newRunDir();
  286 + const target = writeTarget(runDir, 'rn_a', 1, {
  287 + scores: { hero_first_impression: 100, code_quality: 90 },
  288 + evidence: { hero_first_impression: 'Playwright 实测:标题48px/标语16px,比例3:1' },
  289 + });
  290 + const v = await gc.executeContract(contract, target, { runDir });
  291 + expect(v.ok).toBe(true);
  292 + expect(v.failures).toEqual([]);
  293 + });
  294 +
  295 + it('不含"浏览器/视口/开发者工具"类关键词的普通维度,不受这道闸门约束(不用给 evidence)', async () => {
  296 + const runDir = newRunDir();
  297 + // 只给 code_quality 一个维度的分数,hero_first_impression 缺分(走既有的"缺分"分支,
  298 + // 不是这道新闸门),确认新闸门没有误伤跟证据无关的维度
  299 + const target = writeTarget(runDir, 'rn_a', 1, { scores: { code_quality: 90 } });
  300 + const v = await gc.executeContract(contract, target, { runDir });
  301 + const codeQuality = v.sections.benchmark.dimensions.find((d) => d.name === 'code_quality');
  302 + expect(codeQuality.ok).toBe(true);
  303 + expect(codeQuality.evidence_required).toBeUndefined();
  304 + });
  305 +
  306 + it('evidence 是空字符串 / 非字符串时按"没给"处理,不当真', async () => {
  307 + const runDir = newRunDir();
  308 + const target = writeTarget(runDir, 'rn_a', 1, {
  309 + scores: { hero_first_impression: 100, code_quality: 90 },
  310 + evidence: { hero_first_impression: ' ' },
  311 + });
  312 + const v = await gc.executeContract(contract, target, { runDir });
  313 + expect(v.ok).toBe(false);
  314 + expect(v.failures.some((f) => f.case === 'hero_first_impression')).toBe(true);
  315 + });
  316 +
  317 + it('证据也认姊妹判词文件 attempt-<n>.json 的 dimensions[].evidence——benchmark-validator/agent.md 早就在教这个写法', async () => {
  318 + const runDir = newRunDir();
  319 + // target 是 attempt-1.scores.json(只有分数,Step 3 的写法)
  320 + const target = writeTarget(runDir, 'rn_a', '1.scores', {
  321 + scores: { hero_first_impression: 100, code_quality: 90 },
  322 + });
  323 + // 同目录的姊妹文件 attempt-1.json(判词文件,Step 5 的写法,带逐维度 evidence)
  324 + writeTarget(runDir, 'rn_a', 1, {
  325 + schema: 'work-validator/v1', validator: 'benchmark-validator', verdict: 'passed',
  326 + dimensions: [
  327 + { name: 'hero_first_impression', score: 100, ok: true, evidence: 'Playwright 实测标题48px/标语16px,比例3:1' },
  328 + { name: 'code_quality', score: 90, ok: true, evidence: 'eslint 零告警' },
  329 + ],
  330 + });
  331 + const v = await gc.executeContract(contract, target, { runDir });
  332 + expect(v.ok).toBe(true);
  333 + expect(v.failures).toEqual([]);
  334 + });
  335 +
  336 + it('姊妹判词文件不存在 / 不是合法 JSON 时不拖垮打分,按"没有证据"正常走失败分支', async () => {
  337 + const runDir = newRunDir();
  338 + const target = writeTarget(runDir, 'rn_a', '1.scores', {
  339 + scores: { hero_first_impression: 100, code_quality: 90 },
  340 + });
  341 + // 故意不写 attempt-1.json(姊妹判词文件缺失)
  342 + const v = await gc.executeContract(contract, target, { runDir });
  343 + expect(v.ok).toBe(false);
  344 + expect(v.failures.some((f) => f.case === 'hero_first_impression')).toBe(true);
  345 + });
  346 +});
  347 +
256 348 // ────────────────────────────────────────────────────────────────
257 349 describe('执行器:test_data', () => {
258 350 it('通过(subset 模式忽略额外键)', async () => {
... ...
server/tests/unit/graph-run-route-io.test.js
... ... @@ -168,4 +168,45 @@ describe(&#39;GET /api/graph-runs/:id — R-19 输入输出三字段&#39;, () =&gt; {
168 168 // 幂等:再查一次不会重新分配一个不同的项目根
169 169 expect(engine.ensureProjectRoot({ run, companyId, nodeRunId: 'rn-fake-2' }).root_path).toBe(rec.root_path);
170 170 });
  171 +
  172 + // 35 号文档提案 B:控制台「这份验收有没有证据支撑」要读的数据——只读
  173 + // goal-contract.js 已经落盘的判决,不重新计算。
  174 + it('benchmarkVerdicts:没有任何判决落盘时是空数组(不是整块降级)', async () => {
  175 + const r = await fetch(`${base}/api/graph-runs/${runId}`);
  176 + const d = await r.json();
  177 + expect(d.benchmarkVerdicts).toEqual([]);
  178 + });
  179 +
  180 + it('benchmarkVerdicts:落过判决之后,逐维度的 evidence_required/ok 原样透传给前端', async () => {
  181 + const gc = require('../../services/goal-contract');
  182 + const run = wdb.getRun(runId);
  183 + const runDir = gc.resolveRunDir(companyId, run.root_run_id || run.id);
  184 +
  185 + const verdict = {
  186 + verdict_id: 'vd-test-1', ok: false, fail_reason: 'acceptance_not_met',
  187 + goal_id: 'G-1', run_id: run.id, node_run_id: 'rn-benchmark-1', attempt: 0,
  188 + contract_hash: 'x', target: 'x', created_at: new Date().toISOString(),
  189 + sections: {
  190 + benchmark: {
  191 + ok: false,
  192 + dimensions: [
  193 + { name: 'hero_first_impression', score: 100, ok: false, evidence_required: true },
  194 + { name: 'code_quality', score: 90, ok: true },
  195 + ],
  196 + },
  197 + },
  198 + };
  199 + gc.persistVerdict(runDir, verdict);
  200 +
  201 + const r = await fetch(`${base}/api/graph-runs/${runId}`);
  202 + const d = await r.json();
  203 + expect(d.benchmarkVerdicts).toHaveLength(1);
  204 + const v = d.benchmarkVerdicts[0];
  205 + expect(v.nodeRunId).toBe('rn-benchmark-1');
  206 + expect(v.ok).toBe(false);
  207 + expect(v.dimensions).toEqual([
  208 + { name: 'hero_first_impression', ok: false, score: 100, evidenceRequired: true },
  209 + { name: 'code_quality', ok: true, score: 90, evidenceRequired: false },
  210 + ]);
  211 + });
171 212 });
... ...
system/agents/benchmark-validator/agent.md
... ... @@ -76,9 +76,29 @@ cat $R/artifacts/goal-benchmark.json # 蝏游漲蝥踴ubric
76 76 撖寞銝芰輕摨佗
77 77  
78 78 1. `evidence_hint` 鋡急漣**雿**嚗辣 + 雿蔭 +
  79 + **`evidence_hint` "瘚 / 閫 / 撘極 / Console / Network /
  80 + "餈掩摮嚗撌曹葩**嚗迤圾銝
  81 + 隞隞賜璉撌亙嚗
  82 +
  83 + ```bash
  84 + node scripts/visual-acceptance-check.mjs overflow --file <index.html> --widths 375,768,1440
  85 + node scripts/visual-acceptance-check.mjs style --file <index.html> --selector <css> --prop font-size
  86 + node scripts/visual-acceptance-check.mjs console --file <index.html>
  87 + node scripts/visual-acceptance-check.mjs contrast --fg "#111111" --bg "#ffffff"
  88 + ```
  89 +
  90 + 瘥隞方 headless Chromium嚗overflow`/`style`/`console`嚗 WCAG
  91 + 霈∠contrast`嚗銵 JSON stdout 0 誨銵冽
  92 + JSON嚗葉摮 Step 5 霂辣
  93 + `dimensions[].evidence`嚗 0 銝颲嚗
  94 + 撌亙霂衣秩辣憭湧瘜券
79 95 2. 撖寧 `rubric` ﹝蝏**rubric 瓷﹝雿停餈嚗頂**
80 96 3. 銝霂 **蝏 0 **嚗霂
81 97 > **銝霂銝恕** 銋嚗捂蛹"絲"撠梁
  98 + > **`evidence_hint` 銝凋蝐餃霂輕摨佗銵隡撩閬遢霂**
  99 + > 嚗goal-contract.js` evidence 嚗恕餈遢霂辣 `dimensions[].evidence`嚗
  100 + > `attempt-<n>.scores.json` `evidence.<dim>`嚗舅憭銵銝嚗
  101 + > 瘝⊥嚗蝏渡銝頨怨噢
82 102 4. 蝏游漲****嚗銝芸銝銵笆銝停
83 103 `missing score in target`嚗砲蝏游漲銝銝餈葵秤
84 104  
... ...
web/src/components/graph-console/GraphRunPane.vue
... ... @@ -105,6 +105,8 @@ const companyRoot = ref(null);
105 105 // 不要合并:graph_files 只索引节点私有目录,projectRoot 才是代码真正写的地方。
106 106 // 没有记录(节点还没跑到 ensureProjectRoot 那一步)时保持 null。
107 107 const projectRoot = ref(null);
  108 +// 35 号文档提案 B:这个节点最近一次 benchmark 判决按维度标了有没有证据支撑,纯展示。
  109 +const benchmarkVerdicts = ref([]);
108 110 const events = ref([]); // SSE 事件缓冲,供详情面板的「运行日志」tab 消费
109 111 const selectedDefId = ref(null);
110 112 const loading = ref(false);
... ... @@ -213,6 +215,7 @@ async function load() {
213 215 taskFiles.value = Array.isArray(j.taskFiles) ? j.taskFiles : null;
214 216 companyRoot.value = j.companyRoot || null;
215 217 projectRoot.value = j.projectRoot || null;
  218 + benchmarkVerdicts.value = Array.isArray(j.benchmarkVerdicts) ? j.benchmarkVerdicts : [];
216 219 mode.value = j.run?.execution_mode || 'manual';
217 220 push();
218 221 } catch (e) {
... ... @@ -232,6 +235,7 @@ function push() {
232 235 // R-19:右栏「输入输出」tab 的实例侧数据,与 nodes/edges 走同一条 load
233 236 taskFiles: taskFiles.value, companyRoot: companyRoot.value,
234 237 projectRoot: projectRoot.value,
  238 + benchmarkVerdicts: benchmarkVerdicts.value,
235 239 });
236 240 }
237 241  
... ...
web/src/components/graph-console/NodeDetailPanel.vue
... ... @@ -102,6 +102,27 @@
102 102 </dd>
103 103 </dl>
104 104  
  105 + <!-- 35 号文档提案 B:这份验收有没有证据支撑,纯展示,不改变任何判定逻辑。
  106 + 只在这个节点真的有 benchmark 判决时出现——大多数节点(非 validator)
  107 + 不会有,不留一个空标题(同 §输入输出 tab「不猜、不留空标题」的口径)。 -->
  108 + <template v-if="myVerdict">
  109 + <h4>验收证据</h4>
  110 + <p class="muted small">
  111 + {{ myVerdict.ok ? '判决:通过' : '判决:未通过' }}——标 ⚠️ 的维度是
  112 + <code>evidence_hint</code> 要求真实检查(浏览器/视口/开发者工具类)但没有证据支撑,
  113 + 光有分数不算数(35 号文档提案 D)。
  114 + </p>
  115 + <ul class="verdict-dims">
  116 + <li v-for="d in myVerdict.dimensions" :key="d.name" :class="{ warn: d.evidenceRequired && !d.ok }">
  117 + <span class="dim-name">{{ d.name }}</span>
  118 + <span class="dim-score">{{ d.score ?? '—' }}</span>
  119 + <span v-if="d.evidenceRequired" class="dim-flag" :class="d.ok ? 'ok' : 'missing'">
  120 + {{ d.ok ? '✓ 有证据支撑' : '⚠️ 缺证据' }}
  121 + </span>
  122 + </li>
  123 + </ul>
  124 + </template>
  125 +
105 126 <h4>回退记录({{ myLoops.length }})</h4>
106 127 <ul v-if="myLoops.length" class="loops">
107 128 <li v-for="r in myLoops" :key="r.id" :class="r.verdict">
... ... @@ -339,6 +360,8 @@ const props = defineProps({
339 360 companyId: { type: String, default: '' },
340 361 // 「LLM 原文」tab 要它拼端点:/api/graph-runs/:runId/nodes/:nodeRunId/transcript
341 362 runId: { type: String, default: '' },
  363 + // 35 号文档提案 B:GET /api/graph-runs/:id 的 benchmarkVerdicts 原样转发
  364 + benchmarkVerdicts: { type: Array, default: () => [] },
342 365 });
343 366 defineEmits(['dive']);
344 367  
... ... @@ -411,6 +434,14 @@ const myLoops = computed(() =&gt;
411 434 ? props.loopRecords.filter((r) => r.from_node_run_id === props.node.id || r.to_node_run_id === props.node.id)
412 435 : []);
413 436  
  437 +// 35 号文档提案 B:这个节点最近一次的 benchmark 判决(同一节点可能跑过多轮 attempt,
  438 +// 取最新那条——index.ndjson 是写入序,最后一条就是最近一次)
  439 +const myVerdict = computed(() => {
  440 + if (!props.node) return null;
  441 + const mine = props.benchmarkVerdicts.filter((v) => v.nodeRunId === props.node.id);
  442 + return mine.length ? mine[mine.length - 1] : null;
  443 +});
  444 +
414 445 /**
415 446 * 本节点这一轮实际拿到的「应该如何修复」——它就是下一轮输入与原始产出的差量。
416 447 *
... ... @@ -683,6 +714,18 @@ button.path.as-link:focus-visible { outline:2px solid #1d4ed8; outline-offset:1p
683 714 .c-pill.elsewhere { background:#f4f4f6; color:#666; border-color:#dcdce2; cursor:help; }
684 715 .c-pill.opt { opacity:.85; }
685 716  
  717 +.verdict-dims { list-style:none; padding:0; margin:0 0 4px; }
  718 +.verdict-dims > li {
  719 + display:flex; align-items:center; gap:8px; font-size:11px;
  720 + border:1px solid #e4e4e8; background:#fafafa; border-radius:5px; padding:5px 9px; margin-bottom:5px;
  721 +}
  722 +.verdict-dims > li.warn { border-color:#f0dcc0; background:#fffaf3; }
  723 +.dim-name { font-family: ui-monospace, monospace; color:#333; }
  724 +.dim-score { color:#888; margin-left:auto; }
  725 +.dim-flag { font-size:10px; border-radius:8px; padding:1px 7px; white-space:nowrap; }
  726 +.dim-flag.ok { background:#e8f6ec; color:#1a6b38; }
  727 +.dim-flag.missing { background:#fff4e5; color:#8a4b00; }
  728 +
686 729 .kv dd.wrap { white-space:pre-wrap; color:#444; }
687 730 .list { padding-left:16px; margin:0; }
688 731 .muted { color:#999; font-size:12px; }
... ...
web/src/views/GraphConsole.vue
... ... @@ -162,6 +162,7 @@
162 162 :nodes="selectedState.nodes || []"
163 163 :edges="selectedState.edges || []"
164 164 :files="selectedState.taskFiles || null"
  165 + :benchmark-verdicts="selectedState.benchmarkVerdicts || []"
165 166 :company-root="selectedState.companyRoot || null"
166 167 :company-id="companyId"
167 168 :run-id="selectedRunId || ''"
... ...