Commit fadb6fa18ab0c903f1fd32be5eef87460aa11f15
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>
Showing
10 changed files
with
578 additions
and
6 deletions
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('/:id/nodes/:nodeId/retry', h((req, res) => { |
| 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('/:id', h((req, res) => { |
| 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('执行器:benchmark', () => { |
| 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('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { |
| 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(() => |
| 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 || ''" | ... | ... |
-
mentioned in commit 4ea42d8d1e2e981ff60861c494b9d46bd982c80f