mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
1150 lines
92 KiB
JavaScript
1150 lines
92 KiB
JavaScript
#!/usr/bin/env node
|
||
// 评测页(docs/design/next-round/01-效果度量与回归评测.md 第 18 项;评测-D8)的界面检查。
|
||
//
|
||
// 守的是这些「看着没坏、其实不能用」的地方:
|
||
// - 导航「评测」排在设置前面、⌥0 进得去;开了登录的使用者看不到它,手输地址也被送回应用首页;
|
||
// - 题集列表照服务端给的写最近一次评测、同一变体的趋势、定时(没有定时给「设定时」,带着题集去新建定时);新建、导入题集;
|
||
// - 加题四种方式:写 SQL 定期望(选数据源、写 SQL、预览、点选格、试算、保存)、遮罩列拒收的原话落在 SQL 下面、选口径(不能复用
|
||
// 的照列、写原因、选不了)、从问数据运行认定(选运行、选查询、选取哪一列)、期望反问(不带期望答案);交上去的请求体逐项核对,
|
||
// 数值期望不许人填(请求体里不出现 value 这类键);
|
||
// - 分析期望(贡献、判定):预览之后选做哪种分析、模式、按哪几列,试算写出服务端算的判定和判定语,请求体只有分析方法和参数
|
||
// (不带来源、方向、判定);编辑配了几个期望的题,另外的期望照旧保留;结果页逐题写期望和答案的判定、点开写判定语和引用了
|
||
// 哪一份结论或正文那一句,总览按期望的种类分开写;
|
||
// - 口径升版待处置:顶部提示、处置三选一(叫法由服务端给,没有缺省);启用开关、导出;
|
||
// - 发起评测:估算和发起是同一份请求体,评一次 / A/B 对照 / 目录贡献对照(预设)各自的请求体;估算的项数、花费、今天已花照服务端写;
|
||
// 上一次的调用金额估不出时估算写「估不出花费」、估出一部分的写「至少约」,总览的每题平均不写成 $0;
|
||
// - 评测详情:进度、每题正在做什么;停止、只重试没做完的、重评、设为基线——摆不摆按钮照服务端的 actions;总览卡片和各项分布;
|
||
// 逐题表(结论、期望和答案里的数、SQL 差在哪、点开去运行详情)、?case= 定位那道题;和上一次比(指纹差异、转移、归因、换基准);
|
||
// A/B 配对表(都对、只 A 对、只 B 对、p 值、看不出差别);
|
||
// - 「口径命中与重复度」:参考线、这一窗口落在哪一档、趋势、口径账本的明细(直接调 /api/ledger/stats)、反复问的问题能加入题集
|
||
// (带着问法去加题)、能提取模板(从最近一次的运行提取,去画布);
|
||
// - 目录快照:拍当前目录、影子起草先估算再开始、能停;
|
||
// - 记录页:评测运行写来源「评测」,点了回到评测里那道题;运行详情页头有键盘到得了的链接;
|
||
// - 设置:评测一组的值和今天已花,存的时候只交 eval 一组。
|
||
//
|
||
// 页面是真页面(/evals 走真的应用外壳),评测、口径账本统计、设置里评测那一组、记录页要的几样都在浏览器层答掉(虚构的门店、
|
||
// 题集、数字);写请求照约定改这里的假状态并记下请求体,别的 GET 放行到服务端(外壳要它),别的写请求一律 503、最后核对一条
|
||
// 都没漏出去。
|
||
//
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-evals.mjs
|
||
// CHECK_ONLY=加题,发起 只跑段名里含这些字的段。
|
||
import { mkdirSync } from 'node:fs'
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
/** EVALS_SHOTS=<目录> 时几处关键画面各截一张,供人眼复核 */
|
||
const SHOTS = process.env.EVALS_SHOTS ?? ''
|
||
if (SHOTS) mkdirSync(SHOTS, { recursive: true })
|
||
const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false }).catch(() => {}) }
|
||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 220)
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
||
const opened = new Set()
|
||
async function section(name, fn) {
|
||
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
|
||
console.log(`\n=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
} finally {
|
||
for (const c of opened) await c.close().catch(() => {})
|
||
opened.clear()
|
||
}
|
||
}
|
||
const until = async (fn, ms = 6000, step = 100) => {
|
||
const end = Date.now() + ms
|
||
const once = () => Promise.resolve().then(fn).catch(() => null)
|
||
let v = await once()
|
||
while (!v && Date.now() < end) {
|
||
await new Promise((r) => setTimeout(r, step))
|
||
v = await once()
|
||
}
|
||
return v
|
||
}
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||
const iso = (t) => new Date(t).toISOString()
|
||
const NOW = Date.now()
|
||
const H = 3600e3
|
||
const clone = (v) => structuredClone(v)
|
||
/** 漏到服务端的写请求(假接口没认的):检查结束时必须是空的 */
|
||
const leaked = []
|
||
|
||
// ---------------------------------------------------------------- 夹具(通用示例名、编的数)
|
||
|
||
const DS = { id: 'ds-shop', name: '门店库', kind: 'sqlite', enabled: true, database: 'shop.db', options: {}, schema_cache: {},
|
||
created_at: iso(NOW - 90 * 24 * H), updated_at: iso(NOW - 24 * H) }
|
||
const BUDGET = { max_parallel: 2, per_case_timeout_s: 900, per_run_max_usd: 2, daily_max_usd: 5 }
|
||
const RATE = (passed, n) => ({ passed, n, rate: n ? Math.round((passed / n) * 10000) / 10000 : null, ci: n ? [0.3, 0.95] : null })
|
||
const SUITE = {
|
||
id: 'suite-a', name: '门店问数据回归', description: '虚构门店库上的几道问法', timezone: 'Asia/Shanghai', default_variant: {},
|
||
budget: {}, effective_budget: BUDGET, archived: false, version: 3, created_by: '检查员甲', created_at: iso(NOW - 10 * 24 * H),
|
||
updated_at: iso(NOW - H), counts: { cases: 3, enabled: 3 },
|
||
latest: { id: 'ev-1', status: 'succeeded', running: false, arm: null, pair_id: null, trigger: 'manual', created_at: iso(NOW - 2 * H),
|
||
finished_at: iso(NOW - H), counts: { total: 3, done: 3 }, accuracy: RATE(2, 3), traced_accuracy: RATE(1, 3) },
|
||
trend: [{ id: 'ev-old', created_at: iso(NOW - 48 * H), arm: null, ...RATE(1, 3) }, { id: 'ev-1', created_at: iso(NOW - 2 * H), arm: null, ...RATE(2, 3) }],
|
||
schedules: [{ id: 'sch-1', name: '每周回归', enabled: true, description_text: '每周一 09:00(Asia/Shanghai)', next_run_at: iso(NOW + 24 * H) }],
|
||
}
|
||
const SUITE_B = { ...SUITE, id: 'suite-b', name: '库存问法', description: '', counts: { cases: 0, enabled: 0 }, latest: null, trend: [],
|
||
schedules: [], version: 1 }
|
||
const CASE_SQL = {
|
||
id: 'case-1', suite_id: 'suite-a', seq: 1, kind: 'ask', question: '9 月第一周按支付日的销售额是多少?', datasource_ids: ['ds-shop'],
|
||
workflow_id: null, workflow_version: null, input_template: {}, history: null,
|
||
expectations: [{ id: 'e1', kind: 'scalar', column: 'gmv', row: 0, required: true }],
|
||
gold: { kind: 'sql', datasource_id: 'ds-shop', sql: 'SELECT SUM(amount) AS gmv FROM orders' }, tags: ['口径陷阱'], enabled: true,
|
||
gold_source: { id: 'ds-shop', name: '门店库' }, datasources: [{ id: 'ds-shop', name: '门店库' }], archived: false, superseded_by: null,
|
||
version: 2, case_hash: 'h1', trial: { status: 'ok', code: null, reason: null, as_of: iso(NOW - 3 * H), run_ids: ['run-gold-1'], at: iso(NOW - 3 * H), by: '检查员甲' },
|
||
upgrade: null, created_by: '检查员甲', created_at: iso(NOW - 9 * 24 * H), updated_at: iso(NOW - 3 * H),
|
||
}
|
||
const CASE_CAL = {
|
||
...CASE_SQL, id: 'case-2', seq: 2, question: '上周的周销售额?', expectations: [{ id: 'e1', kind: 'scalar', metric: 'gmv', required: true }],
|
||
gold: { kind: 'caliber', workflow_id: 'wf-week', version: 2, node_id: 'card', inputs: { week: '{{ schedule.last_week_start }}' } },
|
||
gold_source: null, tags: [], version: 4,
|
||
upgrade: { pinned: 2, latest: 3, retired: false, pending: true, message: '口径已升版到 v3,待处置', policy: null, versions: [2] },
|
||
}
|
||
const CASE_ASK = { ...CASE_SQL, id: 'case-3', seq: 3, question: '最近怎么样?', expectations: [{ id: 'e1', kind: 'ask', required: true }],
|
||
gold: null, gold_source: null, tags: ['问法含糊'], version: 1 }
|
||
const CALIBERS = [
|
||
{ workflow_id: 'wf-week', workflow_name: '周销售', version: 3, level: 'governed', node_id: 'card', card: '周销售口径', caliber: '周销售',
|
||
metric_id: 'gmv', name: '销售额', unit: '元', reusable: true, reason: null },
|
||
{ workflow_id: 'wf-mix', workflow_name: '混合看板', version: 1, level: 'published', node_id: 'card', card: '混合口径', caliber: '混合',
|
||
metric_id: 'ratio', name: '占比', unit: '', reusable: false, reason: '口径卡读的不是查库的格' },
|
||
]
|
||
const VERSION_WEEK = { workflow_id: 'wf-week', version: 3, graph: { nodes: [{ id: 'in', type: 'input', position: { x: 0, y: 0 },
|
||
data: { label: '入口', config: { fields: [{ name: 'week', required: true, description: '周一的日期' }] } } }], edges: [] } }
|
||
const PREVIEW_OK = { status: 'ok', reason: null, code: null, columns: ['地区', 'gmv'], rows: [['华东', 330], ['华南', 410]], row_count: 2,
|
||
truncated: false, shown: 2, run_id: 'run-preview-1', rendered: { sql: 'SELECT region AS 地区, SUM(amount) AS gmv FROM orders GROUP BY region' } }
|
||
const MASKED_REASON = '期望 SQL 的结果里「手机号」取自数据源设了遮罩的列:期望答案不能用遮罩列(评测页会显示期望值)。请去掉这几列'
|
||
const PREVIEW_MASKED = { status: 'refused', reason: MASKED_REASON, code: 'eval_gold_masked', columns: [], rows: [], row_count: null,
|
||
truncated: false, run_id: 'run-preview-2', rendered: {} }
|
||
const TRIAL_OK = { status: 'ok', as_of: iso(NOW), reason: null, code: null, run_ids: ['run-trial-1'], rendered: {},
|
||
sets: [{ run_id: 'run-trial-1', snapshot: 'snap-1', version: null, values: { e1: { kind: 'scalar', value: 410, column: 'gmv', row: 1, dims: { 地区: '华南' } } } }] }
|
||
const CHAT_RUN = { id: 'run-chat-1', workflow_id: null, workflow_name: '问数据', status: 'succeeded', input: { question: '上周华东的订单数?' },
|
||
output: {}, error: null, usage: {}, run_class: 'exploratory', version: null, origin_kind: 'chat', origin: {}, schedule_id: null,
|
||
created_at: iso(NOW - 5 * H), started_at: iso(NOW - 5 * H), finished_at: iso(NOW - 5 * H + 3000) }
|
||
const QUERIES = [
|
||
{ artifact: 'q-1', node_id: 'ag', source: '门店库', source_id: 'ds-shop', sql: 'SELECT COUNT(*) AS orders FROM orders', columns: ['orders'],
|
||
row_count: 1, truncated: false, masked: false },
|
||
{ artifact: 'q-2', node_id: 'ag', source: '门店库', source_id: 'ds-shop', sql: 'SELECT phone FROM customers', columns: ['phone'],
|
||
row_count: 5, truncated: false, masked: true },
|
||
]
|
||
const VARIANT = { assistant: { provider: null, model: null }, writer: { provider: null, model: null }, judge: false,
|
||
catalog: { mode: 'live', snapshot: null }, analysis: { tools: 'on' }, repeats: 1, label: null }
|
||
const SUMMARY = {
|
||
scorer_version: 1, items: 3, done: 3, cases: 3, verdicts: { pass: 2, fail: 1, ungradable: 0, skipped: 0 }, not_run: 0,
|
||
accuracy: RATE(2, 3), traced_accuracy: RATE(1, 3), outcomes: { traced: 1, untraced: 1, wrong: 1 }, text: {},
|
||
paths: { ran: 2, asked: 1 }, tiers: { formal: 0, degraded: 0, withheld: 0, explore: 2 },
|
||
review: { verdicts: { ok: 2 }, severity: {}, retries: 0, slow: 0 }, judge: { on: false, claims: null },
|
||
looks: { model: 2, system: 1, per_case: 1, limit_hit: 0 }, calls: { assistant: 5, per_case: 1.67 },
|
||
timings: { total_ms: { p50: 41000, p90: 60000, n: 3 } }, usage: { assistant: { cost_usd: 0.03, calls: 5, input_tokens: 9000, output_tokens: 700 },
|
||
writer: { cost_usd: 0.012, calls: 2 }, judge: { cost_usd: 0, calls: 0 }, gold: { cost_usd: 0, calls: 0 } },
|
||
cost_usd: 0.042, unpriced_calls: 0, per_case_cost_usd: 0.014,
|
||
citations: { numbers: 4, numbers_cited: 3, uncited_numbers: 1, unresolved: 0, unknown_entities: 0, rate: 0.75 },
|
||
e1: { ...RATE(1, 2), aspects: { time: 1 } }, pass_rates: {},
|
||
baseline: { id: 'ev-old', pinned: false, created_at: iso(NOW - 48 * H), fingerprint_diff: [{ key: 'writer_model', label: '写作模型', before: 'model-a', after: 'model-b' }],
|
||
counts: { regressed: 1, improved: 1, same: 1, incomparable: 0 }, causes: { data: 0, method: 1, params: 1, path: 0, unknown: 0 } },
|
||
}
|
||
const EVAL_DONE = {
|
||
id: 'ev-1', suite_id: 'suite-a', status: 'succeeded', trigger: 'manual', schedule_id: null, variant: VARIANT,
|
||
resolved_models: { assistant: 'model-a', writer: null, judge: false }, variant_text: '助手 model-a · 写作 各节点自己的 · 当前目录 · 分析工具开 · 结论裁判关 · 每题 1 次',
|
||
catalog_snapshot: 'snap-live', as_of: iso(NOW - 2 * H), repeats: 1, fingerprint: {}, pair_id: null, arm: null, baseline_id: 'ev-old',
|
||
pinned: false, budget: BUDGET, counts: { total: 3, done: 3, passed: 2, failed: 1 }, usage: {}, summary: SUMMARY, stop_reason: null,
|
||
error: null, created_by: '检查员甲', created_at: iso(NOW - 2 * H), started_at: iso(NOW - 2 * H), finished_at: iso(NOW - H),
|
||
cleaned_at: null, running: false, current: [],
|
||
actions: { stop: false, retry: false, rescore: true, pin: true, unpin: false, open: 0, cleaned: false },
|
||
}
|
||
const RESULTS = [
|
||
{ id: 'r-1', eval_run_id: 'ev-1', seq: 1, case_id: 'case-1', case_seq: 1, case_hash: 'h1', repeat: 0, arm: null, status: 'ok',
|
||
run_id: 'run-answer-1', gold_run_ids: ['run-gold-1'], gold: { status: 'ok' }, path: 'ran', verdict: 'pass',
|
||
scores: [{ id: 'e1', kind: 'scalar', required: true, outcome: 'traced', expected: 330, got: 330 }], traced: true, review: null, tier: null,
|
||
judge: null, looks: { model: 1, system: 1 }, calls: 2, timings: { total_ms: 41000 }, usage: {}, sql_match: { status: 'proven', aspects: [] },
|
||
details_artifact: 'd-1', current: null, error: null, started_at: iso(NOW - 2 * H), finished_at: iso(NOW - H), question: CASE_SQL.question,
|
||
question_template: CASE_SQL.question, cost: { cost_usd: 0.014, unpriced_calls: 0 } },
|
||
{ id: 'r-2', eval_run_id: 'ev-1', seq: 2, case_id: 'case-2', case_seq: 2, case_hash: 'h2', repeat: 0, arm: null, status: 'ok',
|
||
run_id: 'run-answer-2', gold_run_ids: ['run-gold-2'], gold: { status: 'ok' }, path: 'ran', verdict: 'fail',
|
||
scores: [{ id: 'e1', kind: 'scalar', required: true, outcome: 'wrong', expected: 330, got: 310, reason: '答案里的数和期望对不上' }], traced: false,
|
||
review: null, tier: null, judge: null, looks: { model: 1, system: 0 }, calls: 2, timings: { total_ms: 60000 }, usage: {},
|
||
sql_match: { status: 'related', aspects: ['time'] }, details_artifact: 'd-2', current: null, error: '答案里的数和期望对不上',
|
||
started_at: iso(NOW - 2 * H), finished_at: iso(NOW - H), cost: { cost_usd: 0.02, unpriced_calls: 0 },
|
||
// 带变量的题:question 是实际问的(服务端按这次评测的截至时间渲染好),模板原文另放
|
||
question: '10 月 5 日到 10 月 11 日的周销售额?', question_template: '{{ schedule.last_week_start }} 到 {{ schedule.last_week_end }} 的周销售额?' },
|
||
{ id: 'r-3', eval_run_id: 'ev-1', seq: 3, case_id: 'case-3', case_seq: 3, case_hash: 'h3', repeat: 0, arm: null, status: 'ok',
|
||
run_id: null, gold_run_ids: [], gold: null, path: 'asked', verdict: 'pass', scores: [{ id: 'e1', kind: 'ask', required: true, outcome: 'traced' }],
|
||
traced: true, review: null, tier: null, judge: null, looks: { model: 0, system: 0 }, calls: 1, timings: { total_ms: 9000 }, usage: {},
|
||
sql_match: null, details_artifact: 'd-3', current: null, error: null, started_at: iso(NOW - 2 * H), finished_at: iso(NOW - H),
|
||
question: CASE_ASK.question, cost: { cost_usd: 0.008, unpriced_calls: 0 } },
|
||
]
|
||
const EVAL_OLD = { ...EVAL_DONE, id: 'ev-old', created_at: iso(NOW - 48 * H), summary: { ...SUMMARY, accuracy: RATE(1, 3), baseline: undefined } }
|
||
// 分析期望:贡献(变化拆解)、判定(波动基线……)。判定语、判定类都是服务端算好交来的,界面照原样写
|
||
const PHRASE_EAST = '销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)'
|
||
const PREVIEW_WEEKS = { status: 'ok', reason: null, code: null, columns: ['地区', '周', 'gmv'],
|
||
rows: [['华东', '2026-10-05', 820], ['华东', '2026-10-12', 700], ['华南', '2026-10-05', 700], ['华南', '2026-10-12', 626]], row_count: 4,
|
||
truncated: false, shown: 4, run_id: 'run-preview-3', rendered: { sql: 'SELECT region AS 地区, week AS 周, SUM(amount) AS gmv FROM orders GROUP BY 地区, 周' } }
|
||
const TRIAL_ANALYSIS = { status: 'ok', as_of: iso(NOW), reason: null, code: null, run_ids: ['run-trial-2'], rendered: {},
|
||
sets: [{ run_id: 'run-trial-2', snapshot: 'snap-2', version: null, values: { e1: { kind: 'contribution', status: 'ok', op: 'decompose',
|
||
mode: 'additive', method: '变化拆解(可加)', judgement: 'source', subjects: ['华东'], direction: 'down', phrase: PHRASE_EAST } } }] }
|
||
const CASE_AN = { ...CASE_SQL, id: 'case-an', suite_id: 'suite-b', seq: 1, question: '上周销售额为什么下降?', version: 1,
|
||
expectations: [{ id: 'source', kind: 'contribution', required: true, op: 'decompose', mode: 'additive',
|
||
params: { by: ['地区'], period: '周', measure: 'gmv' }, top_n: 1 },
|
||
{ id: 'gmv', kind: 'scalar', column: 'gmv', row: 0, required: false, label: '华东上上周的销售额' }] }
|
||
const WANT_EAST = { judgement: 'source', subjects: ['华东'], direction: 'down', phrase: PHRASE_EAST, method: '变化拆解(可加)', status: 'ok' }
|
||
const RESULTS_AN = [
|
||
{ ...RESULTS[0], id: 'r-a1', eval_run_id: 'ev-an', case_id: 'case-an', case_seq: 1, question: '上周销售额为什么下降?', question_template: null, verdict: 'pass',
|
||
traced: true, scores: [{ id: 'source', kind: 'contribution', required: true, outcome: 'traced', want: WANT_EAST,
|
||
found: { judgement: 'source', subjects: ['华东', '华北'], direction: 'down', alias: 'A1', status: 'ok',
|
||
phrase: '销售额下降 194(-8.7%),集中在华东、华北(合计占 82.5%)', method: '变化拆解(可加)' } }] },
|
||
{ ...RESULTS[0], id: 'r-a2', seq: 2, eval_run_id: 'ev-an', case_id: 'case-a2', case_seq: 2, question: '上周订单数下降算不算异常?', question_template: null,
|
||
verdict: 'pass', traced: false, scores: [{ id: 'band', kind: 'verdict', required: true, outcome: 'untraced',
|
||
want: { judgement: 'normal', phrase: '订单数本期变化 -0.7%,在过去 23 周的常规波动范围内(-8.7% 至 +9.3%)', method: '波动基线', status: 'ok' },
|
||
found: { judgement: 'normal', text: '订单数的下降在常规波动范围内' } }] },
|
||
{ ...RESULTS[0], id: 'r-a3', seq: 3, eval_run_id: 'ev-an', case_id: 'case-a3', case_seq: 3, question: '上周取消的订单是不是突然变多了?', question_template: null,
|
||
verdict: 'fail', traced: false, error: '正文写的「取消单数没有异常」和期望对不上', scores: [{ id: 'spike', kind: 'verdict', required: true,
|
||
outcome: 'wrong', reason: '正文写的「取消单数没有异常」和期望对不上:判定是「取消单数本期变化 +250.0%,明显超出过去 23 周的常规波动范围」',
|
||
want: { judgement: 'anomaly', direction: 'up', phrase: '取消单数本期变化 +250.0%,明显超出过去 23 周的常规波动范围(-21.4% 至 +30.1%)',
|
||
method: '波动基线', status: 'ok' }, found: { judgement: 'normal', text: '取消单数没有异常' } }] },
|
||
{ ...RESULTS[0], id: 'r-a4', seq: 4, eval_run_id: 'ev-an', case_id: 'case-a4', case_seq: 4, question: '这周和上周比卖得怎么样?', question_template: null,
|
||
verdict: 'ungradable', traced: false, error: '期望算不了:期次列「周」里没有本期「2026-10-19」', scores: [{ id: 'comparable', kind: 'verdict',
|
||
required: true, outcome: 'ungradable', reason: '期望算不了:期次列「周」里没有本期「2026-10-19」',
|
||
want: { status: 'refused', reason: '期次列「周」里没有本期「2026-10-19」', method: '可比性检查' } }] },
|
||
]
|
||
const EVAL_AN = { ...EVAL_DONE, id: 'ev-an', counts: { total: 4, done: 4, passed: 2, failed: 1, ungradable: 1 },
|
||
summary: { ...SUMMARY, items: 4, done: 4, cases: 4, verdicts: { pass: 2, fail: 1, ungradable: 1, skipped: 0 }, accuracy: RATE(2, 3),
|
||
traced_accuracy: RATE(1, 3), outcomes: { traced: 1, untraced: 1, wrong: 1, ungradable: 1 },
|
||
outcomes_by_kind: { contribution: { traced: 1 }, verdict: { untraced: 1, wrong: 1, ungradable: 1 } } } }
|
||
const COMPARE = {
|
||
base: EVAL_OLD, cleaned: false,
|
||
fingerprint_diff: [{ key: 'writer_model', label: '写作模型', before: 'model-a', after: 'model-b' },
|
||
{ key: 'analysis_tools', label: '分析工具', before: '开', after: '关' }, { key: 'schemas', label: '表结构' }],
|
||
transitions: { counts: { regressed: 1, improved: 1, same: 1, incomparable: 0 }, causes: { data: 0, method: 1, params: 1, path: 0, unknown: 0 },
|
||
items: [{ case_id: 'case-2', case_seq: 2, before: 'pass', after: 'fail', transition: 'regressed', cause: 'method' },
|
||
{ case_id: 'case-1', case_seq: 1, before: 'fail', after: 'pass', transition: 'improved', cause: 'params' },
|
||
{ case_id: 'case-3', case_seq: 3, before: 'pass', after: 'pass', transition: 'same' }] },
|
||
}
|
||
const PAIR_A = { ...EVAL_DONE, id: 'ev-pa', pair_id: 'ev-pa', arm: '当前目录', trigger: 'pair' }
|
||
const PAIR_B = { ...EVAL_DONE, id: 'ev-pb', pair_id: 'ev-pa', arm: '关目录', trigger: 'pair' }
|
||
// 分析工具贡献对照的两臂:名字、变体那一句都是服务端给的(variants.analysis_tools_preset、describe)
|
||
const TOOLS_TEXT = (on) => `助手 model-a · 写作 各节点自己的 · 当前目录 · 分析工具${on ? '开' : '关'} · 结论裁判关 · 每题 3 次`
|
||
const PAIR_TA = { ...EVAL_DONE, id: 'ev-ta', pair_id: 'ev-ta', arm: '分析工具开', trigger: 'pair', variant_text: TOOLS_TEXT(true),
|
||
variant: { ...VARIANT, repeats: 3, label: '分析工具开' } }
|
||
const PAIR_TB = { ...EVAL_DONE, id: 'ev-tb', pair_id: 'ev-ta', arm: '分析工具关', trigger: 'pair', variant_text: TOOLS_TEXT(false),
|
||
variant: { ...VARIANT, analysis: { tools: 'off' }, repeats: 3, label: '分析工具关' } }
|
||
const PAIR_TOOLS = { pair_id: 'ev-ta', evaluations: [PAIR_TA, PAIR_TB], cleaned: false,
|
||
table: { arms: [{ arm: '分析工具开', accuracy: RATE(3, 4) }, { arm: '分析工具关', accuracy: RATE(2, 4) }],
|
||
pairs: [{ a: '分析工具开', b: '分析工具关', both: 2, neither: 1, only_a: 1, only_b: 0, excluded: 0, p_value: 1, note: '这批题上看不出差别' }] } }
|
||
const PAIR = { pair_id: 'ev-pa', evaluations: [PAIR_A, PAIR_B], cleaned: false,
|
||
table: { arms: [{ arm: '当前目录', accuracy: RATE(3, 4) }, { arm: '关目录', accuracy: RATE(1, 4) }],
|
||
pairs: [{ a: '当前目录', b: '关目录', both: 1, neither: 1, only_a: 2, only_b: 0, excluded: 0, p_value: 0.5, note: '这批题上看不出差别' }] } }
|
||
const ESTIMATE = (arms) => ({
|
||
arms: arms.map((a, i) => ({ label: arms.length > 1 ? a : null, variant: VARIANT, models: { assistant: 'model-a', writer: null, judge: false },
|
||
text: `助手 model-a · 写作 各节点自己的 · ${a.startsWith('分析工具') ? `当前目录 · ${a}` : `${a} · 分析工具开`} · 结论裁判关 · 每题 ${arms.length > 1 ? 3 : 1} 次` })),
|
||
cases: 3, repeats: arms.length > 1 ? 3 : 1, estimate: { items: 3 * (arms.length > 1 ? 3 : 1) * arms.length, per_item_usd: 0.014,
|
||
total_usd: 0.042 * (arms.length > 1 ? 3 : 1) * arms.length, basis: 'ev-1', note: '按这个题集上一次评测的每题平均花费估算' },
|
||
budget: BUDGET, parallel: 2, spend: { usd: 0.37, calls: 12, unpriced_calls: 0, daily_max_usd: 5 },
|
||
})
|
||
const SIGNALS = {
|
||
window: { since: iso(NOW - 30 * 24 * H), until: iso(NOW), days: 30 },
|
||
criteria: { e1: [{ band: 'route', at_least: 0.3, text: '等价命中率 ≥ 30%:值得按已治理的口径走' }, { band: 'normalize', at_least: 0.1, text: '10%–30%:先加强规范化' },
|
||
{ band: 'reconcile', at_least: 0, text: '< 10%:先只做口径对账' }], e3: [{ band: 'worth', at_least: 0.2, text: '问题重复度 ≥ 20%:沉淀有价值' },
|
||
{ band: 'not_yet', at_least: 0, text: '< 20%:反复问的问题还不多' }], note: '参考线出自口径账本的设计文档,只作参考,不卡门' },
|
||
current: { since: iso(NOW - 30 * 24 * H), until: iso(NOW), e1: { rate: 0.35, n: 20, queries: 31, band: 'route' }, e3: { share: 0.12, n: 50, lower_bound: 0.04, band: 'not_yet' } },
|
||
trend: [0, 1, 2].map((i) => ({ since: iso(NOW - (3 - i) * 30 * 24 * H), until: iso(NOW - (2 - i) * 30 * 24 * H),
|
||
e1: { rate: [0.1, 0.2, 0.35][i], n: 20, queries: 30, band: ['normalize', 'normalize', 'route'][i] }, e3: { share: 0.12, n: 50, lower_bound: 0.04, band: 'not_yet' } })),
|
||
clusters: [{ count: 4, representative: '上周华东的销售额是多少?', first_at: iso(NOW - 9 * 24 * H), last_at: iso(NOW - 2 * H),
|
||
members: [{ text: '上周华东的销售额是多少?', at: iso(NOW - 2 * H), run_id: 'run-chat-1', turn_id: 't1' }], run_id: 'run-chat-1' },
|
||
{ count: 3, representative: '本月退款率?', first_at: iso(NOW - 5 * 24 * H), last_at: iso(NOW - 3 * H), members: [], run_id: null }],
|
||
questions: 50, threshold: 3, embedder: 'local-hash',
|
||
}
|
||
const LEDGER = { window: { since: SIGNALS.window.since, until: null, origins: null },
|
||
e1: { runs: 12, queries: 31, status: { proven: 7, composed: 1, related: 9, none: 11, skipped: 3 }, with_candidates: 20, rates: { proven: 0.35 },
|
||
proven_via: {}, unproven_reasons: { '参数配上了,但和口径不等价': 5 }, diff_aspects: { time: 4, filter: 2, aggregate: 0, join: 0, other: 1, parse: 0 },
|
||
labels: {}, whole_equivalent: 3 },
|
||
e3: { chat_runs: 40, runs_with_queries: 35, shapes: 22, repeated_shapes: 2, runs_in_repeated_shapes: 6, repeated_share: 0.04,
|
||
threshold: { runs: 3, conversations: 2 }, questions: 50, clusters: 30, questions_in_repeated_clusters: 6, cluster_share: 0.12, embedder: 'local-hash' } }
|
||
const SNAPSHOTS = [
|
||
{ id: 'snap-live', origin: 'live', status: 'succeeded', artifact_id: 'art-1', sources: [{ id: 'ds-shop', name: '门店库' }], tables: null,
|
||
draft: null, estimate: null, usage: {}, progress: {}, error: null, created_by: '检查员甲', created_at: iso(NOW - 2 * H), started_at: null,
|
||
finished_at: iso(NOW - 2 * H), view: { mode: 'snapshot', statuses: 'all', snapshot: 'art-1' } },
|
||
{ id: 'snap-shadow', origin: 'shadow', status: 'running', artifact_id: null, sources: [{ id: 'ds-shop', name: '门店库' }], tables: { 'ds-shop': ['orders'] },
|
||
draft: { model_id: 'model-draft', strip_llm: true }, estimate: null, usage: { calls: 1, cost_usd: 0.01 },
|
||
progress: { tables_total: 2, tables_done: 1, failures: {} }, error: null, created_by: '检查员甲', created_at: iso(NOW - 10 * 60e3),
|
||
started_at: iso(NOW - 10 * 60e3), finished_at: null, view: null },
|
||
]
|
||
const SHADOW_PLAN = { sources: [{ id: 'ds-shop', name: '门店库', tables: ['orders', 'refunds'], unknown: ['nope'] }],
|
||
estimate: { tables: 2, columns: 12, calls: 1, input_tokens: 3200, output_tokens: 900, model: 'model-draft', cost_usd: 0.02, unpriced: false,
|
||
note: '按经验估:不含思考' }, draft: {} }
|
||
const EVAL_RUN = { id: 'run-eval-ans', workflow_id: null, workflow_name: '评测:门店问数据回归·第 2 题', status: 'succeeded',
|
||
input: { question: CASE_CAL.question }, output: { answer: '上周销售额 310 元' }, error: null, usage: {}, run_class: 'exploratory', version: null,
|
||
origin_kind: 'eval', origin: { suite_id: 'suite-a', eval_run_id: 'ev-1', case_id: 'case-2', role: 'answer', repeat: 0 }, schedule_id: null,
|
||
created_at: iso(NOW - 90 * 60e3), started_at: iso(NOW - 90 * 60e3), finished_at: iso(NOW - 89 * 60e3) }
|
||
const RUN_GRAPH = { nodes: [{ id: 'out', type: 'output', position: { x: 0, y: 0 }, data: { label: '成果', config: {} } }], edges: [] }
|
||
/** 已发布的工作流(工作流题要固定一个已发布版本) */
|
||
const WF_WEEK = { id: 'wf-week', name: '周销售', description: '', graph: VERSION_WEEK.graph, tags: [], version: 4, is_template: false,
|
||
status: 'published', published_version: 3, published_level: 'governed', created_at: iso(NOW - 30 * 24 * H), updated_at: iso(NOW - 24 * H) }
|
||
const BUILDER = { mode: 'local', username: 'jiang', display_name: '检查员甲', role: 'builder', groups: [], verified: true }
|
||
const USER = { mode: 'local', username: 'li', display_name: '使用者乙', role: 'user', groups: [], verified: true }
|
||
|
||
function freshState(patch = {}) {
|
||
return {
|
||
suites: [clone(SUITE), clone(SUITE_B)], cases: { 'suite-a': [clone(CASE_SQL), clone(CASE_CAL), clone(CASE_ASK)], 'suite-b': [] },
|
||
evaluations: { 'ev-1': clone(EVAL_DONE), 'ev-old': clone(EVAL_OLD), 'ev-pa': clone(PAIR_A), 'ev-pb': clone(PAIR_B),
|
||
'ev-ta': clone(PAIR_TA), 'ev-tb': clone(PAIR_TB) },
|
||
writes: [], me: null, eval: { max_parallel: 2, per_case_timeout_s: 900, per_run_max_usd: 2, daily_max_usd: 5, keep_runs: 20, judge: false },
|
||
...patch,
|
||
}
|
||
}
|
||
|
||
/** 一个请求怎么答:返回 {status, json};null 表示放行(GET)或拦下(写请求) */
|
||
function reply(state, method, path, url, body) {
|
||
const write = (key) => { state.writes.push({ key: `${method} ${key ?? path}`, body }) }
|
||
if (state.override) {
|
||
const out = state.override(method, path, body, url)
|
||
if (out) return out
|
||
}
|
||
if (path === '/auth/me' && state.me) return { json: state.me }
|
||
// 「提取模板」去画布之后,画布页给这张图起一段画布对话(POST /conversations):早晚不定,以前偶尔赶在检查结束前发出去、
|
||
// 漏到服务端。照 check-ledger 的写法在这里接住,不进写请求的清单(不是评测页发的)
|
||
if (method === 'POST' && path === '/conversations') return { json: { id: 'cv-eval', kind: body?.kind ?? 'canvas', title: '', turns: [] } }
|
||
if (method === 'GET' && path === '/datasources') return { json: [DS] }
|
||
if (method === 'GET' && path === '/evals/suites') return { json: { suites: state.suites } }
|
||
if (method === 'POST' && path === '/evals/suites') {
|
||
write()
|
||
const made = { ...clone(SUITE_B), id: 'suite-new', name: body.name, version: 1 }
|
||
state.suites.push(made)
|
||
state.cases['suite-new'] = []
|
||
return { status: 201, json: made }
|
||
}
|
||
if (method === 'POST' && path === '/evals/suites/import/preview') {
|
||
write()
|
||
return { json: { suite: { name: '导入的题集' }, cases: [{ seq: 1, question: '问法一', ok: true, reason: null, code: null },
|
||
{ seq: 2, question: '问法二', ok: false, reason: '数据源「另一个库」在这里没有同名的', code: 'eval_import_invalid' }],
|
||
sources: { 门店库: 'ds-shop', 另一个库: null }, workflows: {}, importable: 1, skipped: 1 } }
|
||
}
|
||
if (method === 'POST' && path === '/evals/suites/import') {
|
||
write()
|
||
const made = { ...clone(SUITE_B), id: 'suite-imp', name: body.name ?? '导入的题集' }
|
||
state.suites.push(made)
|
||
state.cases['suite-imp'] = []
|
||
return { status: 201, json: { suite: { ...made, cases: [], pending_upgrades: 0 }, imported: 1, skipped: [{ seq: 2, ok: false, reason: '没对上' }] } }
|
||
}
|
||
let m = path.match(/^\/evals\/suites\/([^/]+)(\/.*)?$/)
|
||
if (m) {
|
||
const id = m[1]
|
||
const rest = m[2] ?? ''
|
||
const suite = state.suites.find((s) => s.id === id)
|
||
if (!suite) return { status: 404, json: { detail: '题集不存在,可能已被删除', code: 'eval_suite_not_found' } }
|
||
const cases = state.cases[id] ?? []
|
||
const detail = () => ({ ...suite, cases, pending_upgrades: cases.filter((c) => c.upgrade?.pending).length })
|
||
if (method === 'GET' && !rest) return { json: detail() }
|
||
if (method === 'PATCH' && !rest) {
|
||
write()
|
||
Object.assign(suite, body, { version: suite.version + 1 })
|
||
return { json: detail() }
|
||
}
|
||
if (method === 'GET' && rest === '/evaluations') {
|
||
return { json: { evaluations: Object.values(state.evaluations).filter((e) => e.suite_id === id) } }
|
||
}
|
||
if (method === 'GET' && rest === '/export') return { raw: JSON.stringify({ format: 'agentlab.eval_suite', cases: [] }), headers: {
|
||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent('门店问数据回归.json')}` } }
|
||
if (method === 'POST' && rest === '/cases/preview') {
|
||
write()
|
||
return { json: String(body.sql).includes('phone') ? PREVIEW_MASKED : PREVIEW_OK }
|
||
}
|
||
if (method === 'POST' && rest === '/cases/trial') { write(); return { json: { trial: TRIAL_OK } } }
|
||
if (method === 'POST' && (rest === '/cases' || rest === '/cases/from-run')) {
|
||
write()
|
||
const made = { ...clone(CASE_SQL), id: `case-new-${cases.length + 1}`, seq: cases.length + 1, question: body.question ?? '', expectations: body.expectations ?? [],
|
||
gold: body.gold ?? null, upgrade: null }
|
||
cases.push(made)
|
||
return { status: 201, json: { case: made, trial: TRIAL_OK } }
|
||
}
|
||
const c = rest.match(/^\/cases\/([^/]+)(\/.*)?$/)
|
||
if (c) {
|
||
const row = cases.find((x) => x.id === c[1])
|
||
if (!row) return { status: 404, json: { detail: '题目不存在', code: 'eval_case_not_found' } }
|
||
if (method === 'PATCH' && !c[2]) {
|
||
write()
|
||
if (body.version !== row.version) return { status: 409, json: { detail: '版本对不上' } }
|
||
Object.assign(row, body, { version: row.version + 1 })
|
||
return { json: { case: row, trial: null } }
|
||
}
|
||
if (method === 'POST' && c[2] === '/trial') { write(); return { json: { trial: TRIAL_OK } } }
|
||
if (method === 'GET' && c[2] === '/upgrade') {
|
||
return { json: { upgrade: row.upgrade, choices: { recompute: '改为固定新版本(历史在这里断开)', dual: '新旧两版各算一份期望',
|
||
incomparable: '旧题归档,按新版本另建新题' } } }
|
||
}
|
||
if (method === 'POST' && c[2] === '/upgrade') {
|
||
write()
|
||
row.upgrade = { ...row.upgrade, pending: false, message: null, policy: body.policy }
|
||
row.version += 1
|
||
return { json: { case: row, new_case: null, trial: TRIAL_OK } }
|
||
}
|
||
}
|
||
if (method === 'POST' && rest === '/evaluations/estimate') {
|
||
write()
|
||
const arms = body.preset ? ({ catalog_reviewed: ['当前目录', '只用已核实和已确认的项', '关目录'], analysis_tools: ['分析工具开', '分析工具关'] }[body.preset]
|
||
?? ['当前目录', '关目录']) : body.arms ? body.arms.map((a, i) => a.label || 'ABC'[i]) : ['当前目录']
|
||
return { json: ESTIMATE(arms) }
|
||
}
|
||
if (method === 'POST' && rest === '/evaluations') {
|
||
write()
|
||
const run = { ...clone(EVAL_DONE), id: 'ev-new', status: 'running', running: true, summary: {}, counts: { total: 3, done: 1 },
|
||
current: [{ case_seq: 2, repeat: 0, arm: null, doing: '正在建图' }],
|
||
actions: { stop: true, retry: false, rescore: false, pin: false, unpin: false, open: 2, cleaned: false } }
|
||
state.evaluations['ev-new'] = run
|
||
return { status: 202, json: { evaluations: [run], pair_id: null } }
|
||
}
|
||
}
|
||
m = path.match(/^\/evals\/evaluations\/([^/]+)(\/.*)?$/)
|
||
if (m) {
|
||
const ev = state.evaluations[m[1]]
|
||
const rest = m[2] ?? ''
|
||
if (!ev) return { status: 404, json: { detail: '评测不存在', code: 'eval_evaluation_not_found' } }
|
||
if (method === 'GET' && !rest) return { json: ev }
|
||
if (method === 'GET' && rest === '/results') {
|
||
return { json: { evaluation_id: ev.id, cleaned: false, results: ev.id === 'ev-new' ? RESULTS.slice(0, 1) : ev.id === 'ev-an' ? RESULTS_AN : RESULTS } }
|
||
}
|
||
if (method === 'GET' && rest === '/compare') {
|
||
state.compareBases = [...(state.compareBases ?? []), url.searchParams.get('base')]
|
||
return { json: COMPARE }
|
||
}
|
||
if (method === 'POST' && rest === '/stop') {
|
||
write()
|
||
// 服务端(runner._counts、_close):停下的原因记 stop_reason、不当成出错;没跑的题单独计 not_run,不进 errors,done 不等于 total
|
||
Object.assign(ev, { status: 'cancelled', running: false, current: [], stop_reason: '评测已停止', error: null,
|
||
counts: { total: 3, done: 1, not_run: 2, running: 0, pending: 0, passed: 1, failed: 0, ungradable: 0, skipped: 0, errors: 0 },
|
||
summary: { ...clone(SUMMARY), items: 3, done: 3, verdicts: { pass: 1, fail: 0, ungradable: 0, skipped: 0 }, not_run: 2, errors: 0,
|
||
accuracy: RATE(1, 1), traced_accuracy: RATE(1, 1) },
|
||
actions: { stop: false, retry: true, rescore: true, pin: false, unpin: false, open: 2, cleaned: false } })
|
||
return { json: ev }
|
||
}
|
||
if (method === 'POST' && rest === '/retry') {
|
||
write()
|
||
Object.assign(ev, { status: 'running', running: true, actions: { ...ev.actions, stop: true, retry: false } })
|
||
return { status: 202, json: { evaluations: [ev] } }
|
||
}
|
||
if (method === 'POST' && rest === '/rescore') { write(); return { json: { ...ev, changed: 1 } } }
|
||
if (method === 'POST' && rest === '/pin') {
|
||
write()
|
||
Object.assign(ev, { pinned: body.pinned, actions: { ...ev.actions, pin: !body.pinned, unpin: body.pinned } })
|
||
return { json: ev }
|
||
}
|
||
}
|
||
m = path.match(/^\/evals\/evaluations\/([^/]+)\/results\/([^/]+)$/)
|
||
if (method === 'GET' && m) {
|
||
const r = [...RESULTS, ...RESULTS_AN].find((x) => x.id === m[2])
|
||
return r ? { json: { ...r, details: { turn: { attempts: 1, reason: null, reply: null }, answer: { text: `答案原文:上周销售额 ${r.scores[0]?.got ?? ''} 元` } } } }
|
||
: { status: 404, json: { detail: '这一题的结果不存在' } }
|
||
}
|
||
if (method === 'POST' && path === '/evals/suites/suite-a/cleanup') { write(); return { json: { runs: 2, results: 6, deleted_runs: 8, files: 3 } } }
|
||
if (method === 'GET' && path === '/workflows') return { json: [WF_WEEK] }
|
||
if (method === 'GET' && path === '/evals/pairs/ev-pa') return { json: PAIR }
|
||
if (method === 'GET' && path === '/evals/pairs/ev-ta') return { json: PAIR_TOOLS }
|
||
if (method === 'GET' && path === '/evals/calibers') return { json: { calibers: CALIBERS } }
|
||
if (method === 'GET' && path === '/workflows/wf-week/versions/3') return { json: VERSION_WEEK }
|
||
if (method === 'GET' && path === '/evals/runs/run-chat-1/queries') return { json: { queries: QUERIES } }
|
||
if (method === 'GET' && path === '/evals/signals') {
|
||
state.signalDays = [...(state.signalDays ?? []), url.searchParams.get('days')]
|
||
return { json: SIGNALS }
|
||
}
|
||
if (method === 'GET' && path === '/ledger/stats') {
|
||
state.ledgerSince = url.searchParams.get('since')
|
||
return { json: LEDGER }
|
||
}
|
||
// 「提取模板」之后进画布:画布打开时要的几样(只读的校验、变量分析,不写库)
|
||
if (method === 'GET' && path === '/workflows/wf-drafted') {
|
||
return { json: { id: 'wf-drafted', name: '上周华东的销售额(草稿)', description: '', graph: { nodes: [], edges: [] }, status: 'draft',
|
||
published_version: null, created_at: iso(NOW), updated_at: iso(NOW) } }
|
||
}
|
||
if (method === 'POST' && path === '/workflows/validate') return { json: { ok: true, issues: [] } }
|
||
if (method === 'POST' && path === '/workflows/variables') return { json: { variables: [], issues: [] } }
|
||
if (method === 'POST' && path === '/copilot/from-run') {
|
||
write()
|
||
return { json: { workflow_id: 'wf-drafted', name: '上周华东的销售额(草稿)', nodes: 3, dropped_nodes: 0 } }
|
||
}
|
||
if (method === 'GET' && path === '/evals/catalog-snapshots') return { json: { snapshots: SNAPSHOTS } }
|
||
if (method === 'POST' && path === '/evals/catalog-snapshots') { write(); return { status: 201, json: SNAPSHOTS[0] } }
|
||
if (method === 'POST' && path === '/evals/catalog-snapshots/shadow/estimate') { write(); return { json: SHADOW_PLAN } }
|
||
if (method === 'POST' && path === '/evals/catalog-snapshots/shadow') { write(); return { status: 202, json: SNAPSHOTS[1] } }
|
||
if (method === 'POST' && path === '/evals/catalog-snapshots/snap-shadow/stop') { write(); return { json: { ...SNAPSHOTS[1], status: 'cancelled' } } }
|
||
if (method === 'GET' && path === '/settings/eval/spend') return { json: { date: '2026-10-16', usd: 0.37, calls: 12, unpriced_calls: 0, daily_max_usd: 5 } }
|
||
if (method === 'GET' && path === '/settings' && state.settings) return { json: { ...state.settings, eval: state.eval } }
|
||
if (method === 'PUT' && path === '/settings') {
|
||
write()
|
||
if (body?.values?.eval) state.eval = { ...state.eval, ...body.values.eval }
|
||
return { json: { ...(state.settings ?? {}), eval: state.eval } }
|
||
}
|
||
if (method === 'GET' && path === '/runs' && url.searchParams.get('origin_kind') === 'chat') return { json: [CHAT_RUN] }
|
||
if (state.runs && method === 'GET' && path === '/runs') return { json: state.runs }
|
||
if (state.runs && method === 'GET' && path.startsWith('/runs/')) {
|
||
const run = state.runs.find((r) => path === `/runs/${r.id}`)
|
||
if (run) return { json: run }
|
||
const rm = path.match(/^\/runs\/([^/]+)\/(events|graph|artifacts)$/)
|
||
const of = rm && state.runs.find((r) => r.id === rm[1])
|
||
if (of && rm[2] === 'events') {
|
||
const t0 = Date.parse(of.started_at) / 1000
|
||
return { json: [{ seq: 1, type: 'run.started', node_id: null, ts: t0, data: { nodes: 1 } },
|
||
{ seq: 2, type: 'run.finished', node_id: null, ts: t0 + 3, data: { output: of.output, usage: {} } }] }
|
||
}
|
||
if (of && rm[2] === 'graph') return { json: { graph: RUN_GRAPH, workflow_id: null, version: null } }
|
||
if (of) return { json: [] }
|
||
}
|
||
if (state.runs && method === 'GET' && path === '/approvals') return { json: [] }
|
||
return null
|
||
}
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
let realSettings = null
|
||
|
||
async function open(path, { state = freshState(), width = 1280, height = 900 } = {}) {
|
||
const ctx = await browser.newContext({ viewport: { width, height }, acceptDownloads: true })
|
||
opened.add(ctx)
|
||
ctx.setDefaultTimeout(6000)
|
||
ctx.setDefaultNavigationTimeout(30000)
|
||
await ctx.addInitScript(() => {
|
||
try { localStorage.setItem('agentlab_actor', '检查员甲') } catch { /* noop */ }
|
||
})
|
||
const page = await ctx.newPage()
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
|
||
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
|
||
const r = route.request()
|
||
const url = new URL(r.url())
|
||
const method = r.method()
|
||
const p = decodeURIComponent(url.pathname.replace(/^\/api/, ''))
|
||
let body = null
|
||
try { body = r.postDataJSON() } catch { body = null }
|
||
if (state.settings === undefined && p === '/settings' && method === 'GET') {
|
||
realSettings ??= await route.fetch().then((x) => x.json()).catch(() => ({}))
|
||
state.settings = realSettings
|
||
}
|
||
const out = reply(state, method, p, url, body)
|
||
if (out) {
|
||
if (out.raw !== undefined) return route.fulfill({ status: out.status ?? 200, contentType: 'application/json', body: out.raw, headers: out.headers })
|
||
return route.fulfill({ status: out.status ?? 200, contentType: 'application/json', body: out.json === undefined ? '' : JSON.stringify(out.json) })
|
||
}
|
||
if (method === 'GET') return route.continue()
|
||
leaked.push(`${method} ${p}`)
|
||
return route.fulfill({ status: 503, body: '' })
|
||
})
|
||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||
return { ctx, page, errors, state }
|
||
}
|
||
const writes = (state, re) => state.writes.filter((w) => re.test(w.key))
|
||
const lastWrite = (state, re) => writes(state, re).at(-1)
|
||
const text = (page, sel) => page.locator(sel).first().innerText()
|
||
const hasHumanValue = (exps) => (exps ?? []).some((e) => ['value', 'values', 'expected', 'answer', 'rows', 'number', 'segment',
|
||
'subjects', 'direction', 'verdict', 'judgement'].some((k) => k in e))
|
||
|
||
// ---------------------------------------------------------------- 导航与权限
|
||
|
||
await section('导航:评测在设置前面、⌥0;使用者看不到', async () => {
|
||
const builder = await open('/chat', { state: freshState({ me: BUILDER }) })
|
||
const nav = builder.page.locator('nav[aria-label="主导航"]')
|
||
await nav.waitFor()
|
||
const labels = await nav.locator('li > a[aria-keyshortcuts] > span:not([aria-hidden])').allInnerTexts()
|
||
check('搭建者:导航里有「评测」,紧挨着排在「设置」前面', labels.at(-1) === '设置' && labels.at(-2) === '评测', labels.join(','))
|
||
const key = await nav.locator('a[href="/evals"]').getAttribute('aria-keyshortcuts')
|
||
check('「评测」的快捷键是 ⌥0', key === 'Alt+0', key)
|
||
await builder.page.locator('main').click({ position: { x: 600, y: 300 } }).catch(() => {})
|
||
await builder.page.keyboard.press('Alt+Digit0')
|
||
check('⌥0 切到评测页', await builder.page.waitForURL(/\/evals(\?|$)/, { timeout: 5000 }).then(() => true, () => false), builder.page.url())
|
||
|
||
const user = await open('/apps', { state: freshState({ me: USER }) })
|
||
const uNav = user.page.locator('nav[aria-label="主导航"]')
|
||
await uNav.waitFor()
|
||
check('使用者:导航里没有「评测」', await uNav.locator('a[href="/evals"]').count() === 0,
|
||
(await uNav.locator('li > a[aria-keyshortcuts] > span:not([aria-hidden])').allInnerTexts()).join(','))
|
||
await user.page.goto(`${WEB}/evals/suites/suite-a`, { waitUntil: 'networkidle' })
|
||
check('使用者手输评测地址:送回应用首页', await user.page.waitForURL(/\/apps(\?|$)/, { timeout: 5000 }).then(() => true, () => false), user.page.url())
|
||
await user.page.keyboard.press('Alt+Digit0')
|
||
await sleep(300)
|
||
check('使用者按 ⌥0 不会进评测页', !new URL(user.page.url()).pathname.startsWith('/evals'), user.page.url())
|
||
check('没有页面报错', !builder.errors.length && !user.errors.length, [...builder.errors, ...user.errors].join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 题集列表
|
||
|
||
await section('题集列表:最近一次、趋势、定时;新建、导入', async () => {
|
||
const { page, state, errors } = await open('/evals')
|
||
const row = page.locator('[data-eval-suite="suite-a"]')
|
||
await row.waitFor()
|
||
const t = await row.innerText()
|
||
check('写题数、最近一次的准确率(服务端给的 66.7%)', t.includes('3 道题') && t.includes('66.7%'), t)
|
||
check('趋势画了服务端给的两次', await row.locator('[data-eval-trend="2"]').count() === 1)
|
||
check('定时:列出「每周回归」,链到那条定时任务', await row.locator('[data-eval-suite-schedule="sch-1"]').getAttribute('href') === '/schedules/sch-1')
|
||
const add = page.locator('[data-eval-suite="suite-b"] [data-eval-suite-add-schedule]')
|
||
check('没有定时的题集给「设定时」,带着题集去新建定时', (await add.getAttribute('href')) === '/schedules/new?target_kind=eval_suite&target_id=suite-b')
|
||
check('最近一次链到评测详情', await row.locator('[data-eval-suite-latest] a').getAttribute('href') === '/evals/runs/ev-1')
|
||
await shot(page, 'list')
|
||
|
||
await page.locator('[data-evals-create]').click()
|
||
const dialog = page.locator('[role="dialog"]')
|
||
await dialog.locator('input').fill('新的问法回归')
|
||
await dialog.getByRole('button', { name: '新建题集' }).click()
|
||
check('新建题集:交上去的只有名称,建好进题集页', await until(() => new URL(page.url()).pathname === '/evals/suites/suite-new')
|
||
&& JSON.stringify(lastWrite(state, /^POST \/evals\/suites$/)?.body) === JSON.stringify({ name: '新的问法回归' }), JSON.stringify(lastWrite(state, /^POST \/evals\/suites$/)?.body))
|
||
|
||
await page.goto(`${WEB}/evals`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-evals-import]').click()
|
||
await page.locator('[data-eval-import-file]').setInputFiles({ name: '题集.json', mimeType: 'application/json',
|
||
buffer: Buffer.from(JSON.stringify({ format: 'agentlab.eval_suite', version: 1, suite: { name: '导入的题集' }, cases: [] })) })
|
||
const plan = page.locator('[data-eval-import-plan]')
|
||
await plan.waitFor()
|
||
const pt = await plan.innerText()
|
||
check('导入预览:能导入几道、跳过几道和原因、数据源对照照服务端写', pt.includes('能导入 1 道,跳过 1 道') && pt.includes('没有同名的')
|
||
&& pt.includes('这里没有同名的数据源'), pt)
|
||
await page.locator('[data-eval-import-confirm]').click()
|
||
check('导入:交上去的是文件内容和名称,导完进新题集', await until(() => new URL(page.url()).pathname === '/evals/suites/suite-imp')
|
||
&& lastWrite(state, /import$/)?.body?.content?.format === 'agentlab.eval_suite' && lastWrite(state, /import$/)?.body?.name === '导入的题集',
|
||
JSON.stringify(lastWrite(state, /import$/)?.body))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 题集详情
|
||
|
||
await section('题集详情:题目表、口径升版处置、启用开关、导出', async () => {
|
||
const { page, state, errors } = await open('/evals/suites/suite-a')
|
||
await page.locator('[data-eval-cases]').waitFor()
|
||
check('三道题都在,写期望答案的来源', (await text(page, '[data-eval-case="case-1"]')).includes('SQL · 门店库')
|
||
&& (await text(page, '[data-eval-case="case-2"]')).includes('口径 · wf-week v2'))
|
||
const banner = page.locator('[data-eval-upgrade-banner]')
|
||
check('顶部提示有 1 道口径题待处置', (await banner.getAttribute('data-eval-upgrade-banner')) === '1' && (await banner.innerText()).includes('待处置'))
|
||
check('那道题上写服务端的原话「口径已升版到 v3,待处置」', (await text(page, '[data-eval-case="case-2"] [data-eval-case-upgrade]')).includes('口径已升版到 v3,待处置'))
|
||
await shot(page, 'suite')
|
||
await page.locator('[data-eval-case="case-2"] [data-eval-case-dispose]').click()
|
||
const confirm = page.locator('[data-eval-upgrade-confirm]')
|
||
await page.locator('[data-eval-upgrade-policy]').first().waitFor()
|
||
check('处置:三种都列出(叫法由服务端给),没有缺省、不选不能确认', await page.locator('[data-eval-upgrade-policy]').count() === 3
|
||
&& await confirm.isDisabled())
|
||
await page.locator('[data-eval-upgrade-policy="dual"]').check()
|
||
await confirm.click()
|
||
await until(() => lastWrite(state, /upgrade$/))
|
||
check('处置交上去的是选的那一种和读到的版本', JSON.stringify(lastWrite(state, /upgrade$/)?.body) === JSON.stringify({ policy: 'dual', version: 4 }),
|
||
JSON.stringify(lastWrite(state, /upgrade$/)?.body))
|
||
check('处置完提示消失', await until(async () => await page.locator('[data-eval-upgrade-banner]').count() === 0))
|
||
|
||
await page.locator('[data-eval-case="case-3"] [data-eval-case-toggle]').click()
|
||
await until(() => lastWrite(state, /^PATCH \/evals\/suites\/suite-a\/cases\/case-3$/))
|
||
check('启用开关:只交启用和读到的版本', JSON.stringify(lastWrite(state, /cases\/case-3$/)?.body) === JSON.stringify({ enabled: false, version: 1 }),
|
||
JSON.stringify(lastWrite(state, /cases\/case-3$/)?.body))
|
||
|
||
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 5000 }).catch(() => null), page.locator('[data-eval-suite-export]').click()])
|
||
check('导出:下载服务端给的文件名', download?.suggestedFilename() === '门店问数据回归.json', download?.suggestedFilename())
|
||
await page.locator('[data-eval-runs]').count()
|
||
await page.getByRole('tab', { name: '评测记录' }).click()
|
||
check('评测记录:历次评测链到详情', await page.locator('[data-eval-run-row="ev-1"]').getAttribute('href') === '/evals/runs/ev-1')
|
||
await page.locator('[data-eval-suite-cleanup]').click()
|
||
await page.locator('[role="dialog"]').getByRole('button', { name: '清理更早的明细' }).click()
|
||
check('清理更早的明细:先确认、再发一次,提示清了几次', await until(() => writes(state, /cleanup$/).length === 1)
|
||
&& await until(async () => (await page.locator('[data-toast]').allInnerTexts()).some((x) => x.includes('已清理 2 次评测的明细'))))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 加题
|
||
|
||
await section('加题:写 SQL 定期望、预览、点选格、试算、保存;遮罩列拒收', async () => {
|
||
const { page, state, errors } = await open('/evals/suites/suite-b')
|
||
await page.locator('[data-eval-add-case]').click()
|
||
const editor = page.locator('[data-eval-case-editor="sql"]')
|
||
await editor.waitFor()
|
||
await page.locator('[data-eval-case-question]').fill('上周各地区的销售额?')
|
||
await page.locator('[data-eval-case-source]').selectOption('ds-shop')
|
||
check('没点选期望时保存按钮不可用', await page.locator('[data-eval-case-save]').isDisabled())
|
||
await page.locator('[data-eval-case-sql]').fill('SELECT region AS 地区, phone FROM customers')
|
||
await page.locator('[data-eval-case-preview]').click()
|
||
const err = page.locator('#eval-case-sql-error')
|
||
await err.waitFor()
|
||
check('遮罩列:服务端的原话落在 SQL 那一格下面,结果一格都不显示', (await err.innerText()).includes('取自数据源设了遮罩的列')
|
||
&& await page.locator('[data-eval-preview]').count() === 0, await err.innerText())
|
||
check('预览交上去的是数据源和 SQL', JSON.stringify(lastWrite(state, /preview$/)?.body) === JSON.stringify({ datasource_id: 'ds-shop', sql: 'SELECT region AS 地区, phone FROM customers' }))
|
||
await page.locator('[data-eval-case-sql]').fill('SELECT region AS 地区, SUM(amount) AS gmv FROM orders GROUP BY region')
|
||
await page.locator('[data-eval-case-preview]').click()
|
||
await page.locator('[data-eval-preview]').waitFor()
|
||
check('预览结果画出来,写共几行', (await page.locator('[data-eval-preview-count]').innerText()).includes('共 2 行'))
|
||
await page.locator('[data-eval-preview-cell="1:gmv"]').click()
|
||
check('点一格定「一个数」:「gmv」第 2 行', (await text(page, '[data-eval-case-main-text]')).includes('「gmv」第 2 行'))
|
||
await page.locator('[data-eval-preview-where-col="地区"]').check()
|
||
check('按这一行的维度找这一行:(地区=华南)', (await text(page, '[data-eval-case-main-text]')).includes('地区=华南'))
|
||
await page.locator('[data-eval-case-trial]').click()
|
||
await page.locator('[data-eval-trial="ok"]').waitFor()
|
||
await shot(page, 'case-sql')
|
||
check('试算通过:写算出的期望(服务端算的 410,带维度)', (await text(page, '[data-eval-trial="ok"]')).includes('410') && (await text(page, '[data-eval-trial="ok"]')).includes('华南'))
|
||
const trialBody = lastWrite(state, /cases\/trial$/)?.body
|
||
check('试算交上去的期望只写取哪一格(不带人填的数)', trialBody?.expectations?.[0]?.kind === 'scalar' && trialBody.expectations[0].column === 'gmv'
|
||
&& trialBody.expectations[0].where?.地区 === '华南' && !hasHumanValue(trialBody.expectations), JSON.stringify(trialBody?.expectations))
|
||
await page.locator('[data-eval-case-save]').click()
|
||
await until(() => lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/))
|
||
const saved = lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/)?.body
|
||
check('保存:问法、期望 SQL、数据源、期望都在请求体里,期望不带人填的数', saved?.question === '上周各地区的销售额?' && saved?.gold?.kind === 'sql'
|
||
&& saved.gold.datasource_id === 'ds-shop' && saved.gold.sql.includes('GROUP BY region') && saved.kind === 'ask' && !hasHumanValue(saved.expectations),
|
||
JSON.stringify(saved))
|
||
check('保存后编辑框关掉、题目进了列表', await until(async () => await page.locator('[data-eval-case-editor]').count() === 0
|
||
&& await page.locator('[data-eval-case-seq="1"]').count() === 1))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
await section('加题:选口径、从运行认定、期望反问', async () => {
|
||
const { page, state, errors } = await open('/evals/suites/suite-b')
|
||
await page.locator('[data-eval-add-case]').click()
|
||
await page.locator('[data-eval-case-mode="caliber"]').click()
|
||
await page.locator('[data-eval-calibers]').waitFor()
|
||
const bad = page.locator('[data-eval-caliber="wf-mix@1:card:ratio"]')
|
||
check('不能复用的口径照列、写原因、选不了', await bad.isDisabled() && (await text(page, '[data-eval-calibers]')).includes('口径卡读的不是查库的格'))
|
||
await page.locator('[data-eval-case-question]').fill('上周的周销售额?')
|
||
await page.locator('[data-eval-caliber="wf-week@3:card:gmv"]').check()
|
||
await page.locator('[data-eval-caliber-input="week"]').fill('{{ schedule.last_week_start }}')
|
||
await shot(page, 'case-caliber')
|
||
await page.locator('[data-eval-case-save]').click()
|
||
await until(() => lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/))
|
||
const cal = lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/)?.body
|
||
check('口径题:固定 wf-week@3 的口径卡,期望写指标、输入照填的模板', cal?.gold?.kind === 'caliber' && cal.gold.workflow_id === 'wf-week'
|
||
&& cal.gold.version === 3 && cal.gold.node_id === 'card' && cal.gold.inputs?.week === '{{ schedule.last_week_start }}'
|
||
&& cal.expectations?.[0]?.metric === 'gmv' && !hasHumanValue(cal.expectations), JSON.stringify(cal))
|
||
|
||
await page.locator('[data-eval-add-case]').click()
|
||
await page.locator('[data-eval-case-mode="from_run"]').click()
|
||
await page.locator('[data-eval-from-run-recent]').selectOption('run-chat-1')
|
||
await page.locator('[data-eval-run-query="q-1"]').waitFor()
|
||
check('用到遮罩列的查询写明不能认定、没有「认定这一条」', (await text(page, '[data-eval-run-query="q-2"]')).includes('不能认定')
|
||
&& await page.locator('[data-eval-run-query="q-2"] [data-eval-run-query-pick]').count() === 0)
|
||
await page.locator('[data-eval-run-query="q-1"] [data-eval-run-query-pick]').click()
|
||
await page.locator('[data-eval-from-run-cell="orders"]').click()
|
||
await page.locator('[data-eval-case-save]').click()
|
||
await until(() => lastWrite(state, /from-run$/))
|
||
const fr = lastWrite(state, /from-run$/)?.body
|
||
check('从运行认定:交运行、那条查询和取哪一格(认定人由服务端记)', fr?.run_id === 'run-chat-1' && fr?.query === 'q-1'
|
||
&& fr?.expectations?.[0]?.column === 'orders' && !('confirmed_by' in (fr ?? {})), JSON.stringify(fr))
|
||
|
||
await page.locator('[data-eval-add-case]').click()
|
||
await page.locator('[data-eval-case-mode="special"]').click()
|
||
await page.locator('[data-eval-special-kind="ask"]').check()
|
||
await page.locator('[data-eval-case-question]').fill('最近怎么样?')
|
||
await page.locator('[data-eval-case-save]').click()
|
||
await until(() => writes(state, /^POST \/evals\/suites\/suite-b\/cases$/).length === 2)
|
||
const ask = lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/)?.body
|
||
check('期望反问:期望只有「反问」,不带期望答案的来源', JSON.stringify(ask?.expectations) === JSON.stringify([{ kind: 'ask' }]) && ask?.gold === null,
|
||
JSON.stringify(ask))
|
||
await page.locator('[data-eval-add-case]').click()
|
||
await page.locator('[data-eval-case-kind-pick="workflow"]').check()
|
||
await page.locator('[data-eval-case-workflow]').selectOption('wf-week')
|
||
await page.locator('[data-eval-case-input="week"]').fill('{{ schedule.last_week_start }}')
|
||
await page.locator('[data-eval-case-source]').selectOption('ds-shop')
|
||
await page.locator('[data-eval-case-sql]').fill('SELECT region AS 地区, SUM(amount) AS gmv FROM orders GROUP BY region')
|
||
await page.locator('[data-eval-case-preview]').click()
|
||
await page.locator('[data-eval-preview-cell="0:gmv"]').click()
|
||
await page.locator('[data-eval-case-save]').click()
|
||
await until(() => writes(state, /^POST \/evals\/suites\/suite-b\/cases$/).length === 3)
|
||
const wf = lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/)?.body
|
||
check('工作流题:固定已发布的 v3、按入口字段填输入模板,期望照样由 SQL 算', wf?.kind === 'workflow' && wf.workflow_id === 'wf-week'
|
||
&& wf.workflow_version === 3 && wf.input_template?.week === '{{ schedule.last_week_start }}' && wf.gold?.kind === 'sql',
|
||
JSON.stringify(wf))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
await section('分析期望:加题选做哪种分析、试算写判定,编辑保留另外的期望', async () => {
|
||
const state = freshState({ override: (method, path, body) => {
|
||
if (method === 'POST' && path === '/evals/suites/suite-b/cases/preview' && String(body?.sql).includes('周')) {
|
||
state.writes.push({ key: `${method} ${path}`, body })
|
||
return { json: PREVIEW_WEEKS }
|
||
}
|
||
if (method === 'POST' && path === '/evals/suites/suite-b/cases/trial' && body?.expectations?.[0]?.kind === 'contribution') {
|
||
state.writes.push({ key: `${method} ${path}`, body })
|
||
return { json: { trial: TRIAL_ANALYSIS } }
|
||
}
|
||
return null
|
||
} })
|
||
const { page, errors } = await open('/evals/suites/suite-b', { state })
|
||
await page.locator('[data-eval-add-case]').click()
|
||
await page.locator('[data-eval-case-editor="sql"]').waitFor()
|
||
await page.locator('[data-eval-case-question]').fill('上周销售额为什么下降?')
|
||
await page.locator('[data-eval-case-source]').selectOption('ds-shop')
|
||
await page.locator('[data-eval-case-sql]').fill('SELECT region AS 地区, week AS 周, SUM(amount) AS gmv FROM orders GROUP BY 地区, 周')
|
||
await page.locator('[data-eval-case-preview]').click()
|
||
await page.locator('[data-eval-analysis]').waitFor()
|
||
check('预览之后能定分析期望:贡献、判定两种', await page.locator('[data-eval-analysis-kind]').count() === 2)
|
||
await page.locator('[data-eval-analysis-kind="contribution"]').click()
|
||
check('贡献:模式三种(可加、比率、乘积,叫法同契约)', (await page.locator('[data-eval-analysis-mode] option').allInnerTexts()).join('、')
|
||
=== '变化拆解(可加)、变化拆解(比率)、变化拆解(乘积)', (await page.locator('[data-eval-analysis-mode] option').allInnerTexts()).join('、'))
|
||
await page.locator('[data-eval-analysis-param-col="地区"]').check()
|
||
await page.locator('select[data-eval-analysis-param="period"]').selectOption('周')
|
||
await page.locator('select[data-eval-analysis-param="measure"]').selectOption('gmv')
|
||
check('期望的种类写做哪种分析、按哪几列:贡献:变化拆解(可加) · 按地区 · gmv',
|
||
(await text(page, '[data-eval-case-main-text]')) === '贡献:变化拆解(可加) · 按地区 · gmv', await text(page, '[data-eval-case-main-text]'))
|
||
await page.locator('[data-eval-case-trial]').click()
|
||
await page.locator('[data-eval-trial="ok"]').waitFor()
|
||
const trialText = await text(page, '[data-eval-trial="ok"]')
|
||
check('试算写服务端算的判定和判定语:主要来源:华东(下降)', trialText.includes('主要来源:华东(下降)') && trialText.includes(PHRASE_EAST), trialText)
|
||
const trialBody = lastWrite(state, /cases\/trial$/)?.body
|
||
check('试算交上去的只有分析方法、模式、参数和前几段(不带来源、方向、判定)', JSON.stringify(trialBody?.expectations?.[0])
|
||
=== JSON.stringify({ kind: 'contribution', op: 'decompose', mode: 'additive', params: { by: ['地区'], period: '周', measure: 'gmv' },
|
||
top_n: 1, required: true }) && !hasHumanValue(trialBody?.expectations), JSON.stringify(trialBody?.expectations))
|
||
await shot(page, 'case-analysis')
|
||
await page.locator('[data-eval-analysis-kind="verdict"]').click()
|
||
await page.locator('[data-eval-analysis-op]').selectOption('differ')
|
||
check('判定选差异检验:要选模式(比率、均值、逐行明细)', await page.locator('[data-eval-analysis-mode] option').count() === 3)
|
||
await page.locator('[data-eval-analysis-op]').selectOption('baseline')
|
||
check('换回波动基线:没有模式', await page.locator('[data-eval-analysis-mode]').count() === 0)
|
||
await page.locator('select[data-eval-analysis-param="period"]').selectOption('周')
|
||
await page.locator('select[data-eval-analysis-param="measure"]').selectOption('gmv')
|
||
await page.locator('[data-eval-case-save]').click()
|
||
await until(() => lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/))
|
||
const saved = lastWrite(state, /^POST \/evals\/suites\/suite-b\/cases$/)?.body
|
||
check('保存判定期望:分析方法和参数,期望答案的来源是那条 SQL', JSON.stringify(saved?.expectations?.[0])
|
||
=== JSON.stringify({ kind: 'verdict', op: 'baseline', params: { period: '周', measure: 'gmv' }, required: true })
|
||
&& saved?.gold?.kind === 'sql' && !hasHumanValue(saved?.expectations), JSON.stringify(saved))
|
||
|
||
// 编辑一道配了两个期望的题(贡献 + 一个数):编辑框改的是贡献那一个,另外那个照旧带上
|
||
state.cases['suite-b'] = [clone(CASE_AN)]
|
||
await page.goto(`${WEB}/evals/suites/suite-b`, { waitUntil: 'networkidle' })
|
||
const row = page.locator('[data-eval-case="case-an"]')
|
||
await row.waitFor()
|
||
check('题目表写分析期望:贡献:变化拆解(可加) · 按地区 · gmv', (await row.innerText()).includes('贡献:变化拆解(可加) · 按地区 · gmv'),
|
||
await row.innerText())
|
||
await row.locator('[data-eval-case-edit]').click()
|
||
await page.locator('[data-eval-case-others="1"]').waitFor()
|
||
check('编辑:另外的期望列出来、写明照旧保留', (await text(page, '[data-eval-case-others]')).includes('华东上上周的销售额'))
|
||
await page.locator('[data-eval-case-question]').fill('上周销售额为什么下降了?')
|
||
await page.locator('[data-eval-case-save]').click()
|
||
await until(() => lastWrite(state, /cases\/case-an$/))
|
||
const patched = lastWrite(state, /cases\/case-an$/)?.body
|
||
check('保存:两个期望都在,贡献的参数没动', patched?.expectations?.length === 2 && patched.expectations[0].kind === 'contribution'
|
||
&& JSON.stringify(patched.expectations[0].params) === JSON.stringify(CASE_AN.expectations[0].params)
|
||
&& patched.expectations[1].id === 'gmv', JSON.stringify(patched?.expectations))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
await section('分析期望:逐题写期望和答案的判定,总览按期望的种类分开', async () => {
|
||
const state = freshState()
|
||
state.evaluations['ev-an'] = clone(EVAL_AN)
|
||
const { page, errors } = await open('/evals/runs/ev-an', { state })
|
||
await page.locator('[data-eval-overview]').waitFor()
|
||
check('总览:按期望的种类分开写结论', (await text(page, '[data-eval-outcomes-kind="contribution"]')) === '贡献:对且可追溯 1'
|
||
&& (await text(page, '[data-eval-outcomes-kind="verdict"]')) === '判定:对但没引用 1 · 错 1 · 不能评分 1',
|
||
await text(page, '[data-eval-outcomes-by-kind]'))
|
||
await page.getByRole('tab', { name: '逐题' }).click()
|
||
await page.locator('[data-eval-results="4"]').waitFor()
|
||
await shot(page, 'run-results-analysis')
|
||
const cell = (id) => text(page, `[data-eval-result="${id}"] [data-eval-score-judgement]`)
|
||
check('贡献:期望「主要来源:华东(下降)」→ 答案引用的结论集中在华东、华北', (await cell('r-a1'))
|
||
=== '主要来源:华东(下降) → 主要来源:华东、华北(下降)', await cell('r-a1'))
|
||
check('判定:常规波动 → 正文说的常规波动(对但没引用)', (await cell('r-a2')) === '常规波动 → 常规波动'
|
||
&& (await page.locator('[data-eval-result="r-a2"]').innerText()).includes('对但没引用'))
|
||
check('说成相反:超出常规波动(上升)→ 常规波动,错', (await cell('r-a3')) === '超出常规波动(上升) → 常规波动'
|
||
&& (await page.locator('[data-eval-result="r-a3"]').innerText()).includes('不通过'))
|
||
check('期望算不了:照实写原因、不能评分', (await cell('r-a4')).includes('没有做成:期次列「周」里没有本期')
|
||
&& (await page.locator('[data-eval-result="r-a4"]').innerText()).includes('不能评分'))
|
||
await page.locator('[data-eval-result="r-a1"] [data-eval-result-toggle]').click()
|
||
const detail = page.locator('[data-eval-result-detail="r-a1"] [data-eval-analysis-detail]')
|
||
await detail.waitFor()
|
||
const d1 = await detail.innerText()
|
||
check('点开:期望的判定和判定语、答案引用了哪一份结论和它的判定语', d1.includes('期望的判定:主要来源:华东(下降) · 变化拆解(可加)')
|
||
&& d1.includes(PHRASE_EAST) && d1.includes('引用了 A1') && d1.includes('集中在华东、华北'), d1)
|
||
await page.locator('[data-eval-result="r-a3"] [data-eval-result-toggle]').click()
|
||
const d3 = await text(page, '[data-eval-result-detail="r-a3"]')
|
||
check('点开说反了的那题:写正文那一句和对不上的原因', d3.includes('正文写的「取消单数没有异常」') && d3.includes('明显超出过去 23 周'), d3)
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 发起评测
|
||
|
||
await section('发起评测:估不出金额时估算和总览不写成 $0', async () => {
|
||
// 服务端 eval/runner.estimate:上一次的调用金额都估不出 → total_usd 为空、带次数;估出一部分 → at_least
|
||
const shaped = (patch) => ({ ...ESTIMATE(['当前目录']), estimate: { ...ESTIMATE(['当前目录']).estimate, ...patch } })
|
||
const none = shaped({ per_item_usd: null, total_usd: null, unpriced_calls: 6,
|
||
note: '上一次评测的 6 次模型调用金额都估不出(模型不在价格目录中,或用量没有报全):金额上限只管得住估得出金额的调用' })
|
||
const some = shaped({ at_least: true, unpriced_calls: 2,
|
||
note: '按这个题集上一次评测的每题平均花费估算,实际至少这么多:上一次另有 2 次调用金额估不出,没有算进去' })
|
||
for (const [label, body] of [['都估不出', none], ['估出一部分', some]]) {
|
||
const state = freshState({ override: (method, path) => (method === 'POST' && path.endsWith('/evaluations/estimate')
|
||
? { json: body } : null) })
|
||
const { page, errors } = await open('/evals/suites/suite-a', { state })
|
||
await page.locator('[data-eval-suite-start]').click()
|
||
await page.locator('[data-eval-estimate-items]').waitFor()
|
||
const cost = await text(page, '[data-eval-estimate-cost]')
|
||
if (label === '都估不出') {
|
||
check('估算:上一次都估不出时写「估不出花费」和原因,不写 $0', cost.includes('估不出花费') && cost.includes('6 次') && !cost.includes('$0'), cost)
|
||
} else {
|
||
check('估算:估出一部分时写「至少约」', cost.includes('至少约 $0.042') && cost.includes('每项至少约 $0.014') && cost.includes('2 次'), cost)
|
||
}
|
||
check(`没有页面报错(${label})`, !errors.length, errors.join(' | '))
|
||
}
|
||
const state = freshState()
|
||
state.evaluations['ev-1'].summary = { ...state.evaluations['ev-1'].summary, cost_usd: 0, unpriced_calls: 6, per_case_cost_usd: null }
|
||
const { page, errors } = await open('/evals/runs/ev-1', { state })
|
||
await page.locator('[data-eval-overview]').waitFor()
|
||
const card = await text(page, '[data-eval-card="cost"]')
|
||
check('总览:都估不出时花费写「金额无法估算」,不写每题 $0', card.includes('金额无法估算') && !card.includes('每题'), card)
|
||
state.evaluations['ev-1'].summary = { ...state.evaluations['ev-1'].summary, cost_usd: 0.042, unpriced_calls: 2, per_case_cost_usd: 0.014 }
|
||
await page.reload({ waitUntil: 'networkidle' })
|
||
await page.locator('[data-eval-overview]').waitFor()
|
||
const partly = await text(page, '[data-eval-card="cost"]')
|
||
check('总览:估出一部分时写「≥」和「每题至少约」', partly.includes('≥ $0.042') && partly.includes('每题至少约 $0.014'), partly)
|
||
check('没有页面报错(总览)', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
await section('发起评测:估算和发起同一份请求体;评一次、A/B、目录贡献对照', async () => {
|
||
const { page, state, errors } = await open('/evals/suites/suite-a')
|
||
await page.locator('[data-eval-suite-start]').click()
|
||
await page.locator('[data-eval-estimate-items]').waitFor()
|
||
const est = await text(page, '[data-eval-estimate]')
|
||
check('估算:项数、按上一次估的花费、今天已花照服务端写', est.includes('共 3 项') && est.includes('$0.042') && est.includes('评测今天已花 $0.370'), est)
|
||
await page.locator('[data-eval-start-mode="ab"]').click()
|
||
await until(async () => (await page.locator('[data-eval-arm]').count()) === 2)
|
||
await page.locator('#eval-arm-1-catalog').selectOption('off')
|
||
await until(() => writes(state, /estimate$/).some((w) => w.body?.arms?.[1]?.catalog?.mode === 'off'))
|
||
const ab = lastWrite(state, /estimate$/)?.body
|
||
check('A/B:两臂各自的变体(第二臂关目录),每题 3 次', ab?.arms?.length === 2 && ab.arms[1].catalog.mode === 'off' && ab.arms[0].repeats === 3,
|
||
JSON.stringify(ab))
|
||
await page.locator('[data-eval-start-mode="catalog"]').click()
|
||
await page.locator('[data-eval-reviewed-arm]').check()
|
||
await until(() => writes(state, /estimate$/).some((w) => w.body?.preset === 'catalog_reviewed'))
|
||
const preset = lastWrite(state, /estimate$/)?.body
|
||
check('目录贡献对照:预设 catalog_reviewed,变体里不带目录(三臂由服务端定)', preset?.preset === 'catalog_reviewed' && !('catalog' in (preset.variant ?? {})),
|
||
JSON.stringify(preset))
|
||
await until(async () => (await page.locator('[data-eval-estimate-arm]').count()) === 3)
|
||
await shot(page, 'start-catalog')
|
||
check('估算写三臂', await page.locator('[data-eval-estimate-arm]').count() === 3)
|
||
check('目录贡献对照:目录由预设定、不摆,分析工具照样能选', await page.locator('[data-eval-catalog-mode]').count() === 0
|
||
&& await page.locator('[data-eval-analysis-tools]').count() === 1)
|
||
await page.locator('[data-eval-start-mode="analysis_tools"]').click()
|
||
await until(() => writes(state, /estimate$/).some((w) => w.body?.preset === 'analysis_tools'))
|
||
const tools = lastWrite(state, /estimate$/)?.body
|
||
check('分析工具贡献对照:预设 analysis_tools,变体里不带分析工具和名字(开、关两臂由服务端定),每题 3 次',
|
||
tools?.preset === 'analysis_tools' && !('analysis' in (tools.variant ?? {})) && !('label' in (tools.variant ?? {}))
|
||
&& tools.variant?.repeats === 3 && tools.variant?.catalog?.mode === 'live', JSON.stringify(tools))
|
||
check('……分析工具由预设定、不摆,目录照样能选', await page.locator('[data-eval-analysis-tools]').count() === 0
|
||
&& await page.locator('[data-eval-catalog-mode]').count() === 1)
|
||
await until(async () => (await page.locator('[data-eval-estimate-arm]').count()) === 2)
|
||
check('……估算写开、关两臂,各自的变体那一句照服务端写', (await text(page, '[data-eval-estimate-arm="分析工具关"]')).includes('分析工具关')
|
||
&& (await text(page, '[data-eval-estimate-arm="分析工具开"]')).includes('分析工具开'), await text(page, '[data-eval-estimate]'))
|
||
await shot(page, 'start-analysis-tools')
|
||
await page.locator('[data-eval-start-mode="single"]').click()
|
||
await page.locator('#eval-arm-0-analysis').selectOption('off')
|
||
await until(() => writes(state, /estimate$/).some((w) => !w.body?.preset && w.body?.variant?.analysis?.tools === 'off'))
|
||
check('评一次:分析工具选「关」,变体里带 analysis.tools=off', lastWrite(state, /estimate$/)?.body?.variant?.analysis?.tools === 'off',
|
||
JSON.stringify(lastWrite(state, /estimate$/)?.body))
|
||
await sleep(500)
|
||
await page.locator('[data-eval-start-submit]').click()
|
||
await until(() => lastWrite(state, /^POST \/evals\/suites\/suite-a\/evaluations$/))
|
||
const started = lastWrite(state, /^POST \/evals\/suites\/suite-a\/evaluations$/)?.body
|
||
const lastEst = writes(state, /estimate$/).at(-1)?.body
|
||
check('发起交上去的就是最后一次估算的那份请求体', JSON.stringify(started) === JSON.stringify(lastEst), `${JSON.stringify(started)} vs ${JSON.stringify(lastEst)}`)
|
||
check('发起后进这次评测的详情', await until(() => new URL(page.url()).pathname === '/evals/runs/ev-new'))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 评测详情
|
||
|
||
await section('评测详情:进度、正在做什么、停止和重试、重评、设为基线', async () => {
|
||
const state = freshState()
|
||
state.evaluations['ev-new'] = { ...clone(EVAL_DONE), id: 'ev-new', status: 'running', running: true, summary: {}, counts: { total: 3, done: 1 },
|
||
current: [{ case_seq: 2, repeat: 0, arm: null, doing: '正在建图' }],
|
||
actions: { stop: true, retry: false, rescore: false, pin: false, unpin: false, open: 2, cleaned: false } }
|
||
const { page, errors } = await open('/evals/runs/ev-new', { state })
|
||
await page.locator('[data-eval-progress="1/3"]').waitFor()
|
||
await shot(page, 'run-live')
|
||
check('进度:已评 1 / 3 项,第 2 题正在建图', (await text(page, '[data-eval-run-head]')).includes('已评 1 / 3 项')
|
||
&& (await text(page, '[data-eval-current]')).includes('第 2 题:正在建图'))
|
||
check('在跑:只给「停止」(重试、重评、设为基线照服务端的 actions 不摆)', await page.locator('[data-eval-stop]').count() === 1
|
||
&& await page.locator('[data-eval-retry]').count() === 0 && await page.locator('[data-eval-rescore]').count() === 0
|
||
&& await page.locator('[data-eval-pin]').count() === 0)
|
||
await page.locator('[data-eval-stop]').click()
|
||
await page.locator('[data-eval-retry]').waitFor()
|
||
check('停止之后:服务端说能重试,给「重试没做完的」,悬停写几项', (await page.locator('[data-eval-retry]').getAttribute('title')).includes('2 项')
|
||
&& writes(state, /\/stop$/).length === 1)
|
||
check('……停下的原因照服务端写「评测已停止」,不当成出错;没评的 2 项单列,不算出错', await until(async () =>
|
||
(await page.locator('[data-eval-stop-reason]').count()) === 1)
|
||
&& (await text(page, '[data-eval-stop-reason]')).includes('评测已停止') && await page.locator('[data-eval-error]').count() === 0
|
||
&& (await text(page, '[data-eval-not-run]')).startsWith('2 项没评') && await page.locator('[data-eval-errors]').count() === 0,
|
||
await text(page, '[data-eval-run-head]'))
|
||
await page.locator('[data-eval-retry]').click()
|
||
await until(() => writes(state, /\/retry$/).length === 1)
|
||
check('重试:发了一次', writes(state, /\/retry$/).length === 1)
|
||
|
||
await page.goto(`${WEB}/evals/runs/ev-1`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-eval-overview]').waitFor()
|
||
await shot(page, 'run-overview')
|
||
const ov = await text(page, '[data-eval-overview]')
|
||
check('总览:准确率、可追溯准确率、花费、逐题 E1 照总表写', (await text(page, '[data-eval-card="accuracy"]')).includes('66.7%')
|
||
&& (await text(page, '[data-eval-card="traced"]')).includes('33.3%') && (await text(page, '[data-eval-card="cost"]')).includes('$0.042')
|
||
&& (await text(page, '[data-eval-card="e1"]')).includes('50%'), ov.slice(0, 200))
|
||
check('和上一次比:退步 1、进步 1,归因、写作模型变了', (await text(page, '[data-eval-section="baseline"]')).includes('退步 1 题 · 进步 1 题')
|
||
&& (await text(page, '[data-eval-section="baseline"]')).includes('写作模型'))
|
||
check('分布:结论、作答路径、按角色的用量', ov.includes('通过 2 · 不通过 1') && ov.includes('运行完成 2 · 反问 1')
|
||
&& (await text(page, '[data-eval-usage-role="assistant"]')).includes('$0.030'))
|
||
await page.locator('[data-eval-rescore]').click()
|
||
await until(() => writes(state, /\/rescore$/).length === 1)
|
||
check('重评:发了一次,提示有几项结论变了', await until(async () => (await page.locator('[data-toast]').allInnerTexts()).some((x) => x.includes('1 项结论有变化'))))
|
||
await page.locator('[data-eval-pin="off"]').click()
|
||
await until(() => writes(state, /\/pin$/).length === 1)
|
||
check('设为基线:交 pinned=true,按钮换成「取消基线」', JSON.stringify(lastWrite(state, /\/pin$/)?.body) === JSON.stringify({ pinned: true })
|
||
&& await until(async () => await page.locator('[data-eval-pin="on"]').count() === 1))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
await section('逐题结果、和上一次比、A/B 配对表', async () => {
|
||
const { page, state, errors } = await open('/evals/runs/ev-1?case=case-2')
|
||
await page.locator('[data-eval-results="3"]').waitFor()
|
||
await shot(page, 'run-results')
|
||
const r2 = page.locator('[data-eval-result="r-2"]')
|
||
const t2 = await r2.innerText()
|
||
check('?case= 打开逐题、定位并展开那道题', await page.locator('[data-eval-result-detail="r-2"]').count() === 1)
|
||
check('逐题:结论、期望和答案里的数、SQL 差在哪', t2.includes('不通过') && t2.includes('330') && t2.includes('310') && t2.includes('时间口径'), t2)
|
||
check('一题的花费照服务端加好的写', (await page.locator('[data-eval-result="r-1"]').innerText()).includes('$0.014'))
|
||
check('逐题的问法写实际问的(变量渲染好了),模板原文另起一行', await text(page, '[data-eval-result="r-2"] [data-eval-result-question]')
|
||
=== '10 月 5 日到 10 月 11 日的周销售额?'
|
||
&& (await text(page, '[data-eval-result="r-2"] [data-eval-result-template]')).startsWith('题目原文:{{ schedule.last_week_start }}')
|
||
&& await page.locator('[data-eval-result="r-1"] [data-eval-result-template]').count() === 0,
|
||
await text(page, '[data-eval-result="r-2"] [data-eval-result-question]'))
|
||
check('点开去运行详情', await page.locator('[data-eval-result-detail="r-2"] [data-eval-result-run]').getAttribute('href') === '/runs/run-answer-2')
|
||
await page.locator('[data-eval-result-details]').waitFor()
|
||
check('展开的明细写作答的经过和答案原文(明细工件)', (await text(page, '[data-eval-result-details]')).includes('自动重来了 1 次')
|
||
&& (await page.locator('[data-eval-result-details] details').count()) === 1)
|
||
|
||
await page.getByRole('tab', { name: '和上一次比' }).click()
|
||
await page.locator('[data-eval-transitions]').waitFor()
|
||
check('对比:指纹差异写哪几项变了', (await text(page, '[data-eval-fingerprint]')).includes('写作模型:model-a → model-b')
|
||
&& (await text(page, '[data-eval-fingerprint]')).includes('表结构:变了'))
|
||
check('对比:分析工具开、关照服务端写前后', (await text(page, '[data-eval-fingerprint]')).includes('分析工具:开 → 关'),
|
||
await text(page, '[data-eval-fingerprint]'))
|
||
check('转移和归因', (await text(page, '[data-eval-transitions]')).includes('退步 1') && (await text(page, '[data-eval-transitions]')).includes('做法变 1')
|
||
&& await page.locator('[data-eval-transition="regressed"]').count() === 1)
|
||
await shot(page, 'run-compare')
|
||
await page.locator('[data-eval-compare-base]').selectOption('ev-old')
|
||
check('换基准:按点名的那一次重新比', await until(() => (state.compareBases ?? []).includes('ev-old')), JSON.stringify(state.compareBases))
|
||
|
||
await page.goto(`${WEB}/evals/runs/ev-pa?tab=pair`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-eval-pair-row]').waitFor()
|
||
await shot(page, 'run-pair')
|
||
check('A/B:都对 1、只 A 对 2、只 B 对 0、都错 1', await text(page, '[data-eval-pair-both]') === '1' && await text(page, '[data-eval-pair-only-a]') === '2'
|
||
&& await text(page, '[data-eval-pair-only-b]') === '0' && await text(page, '[data-eval-pair-neither]') === '1')
|
||
check('p 值和「这批题上看不出差别」照服务端写', (await text(page, '[data-eval-pair-p]')).includes('p = 0.5') && (await text(page, '[data-eval-pair-p]')).includes('看不出差别'))
|
||
check('两臂各自链到自己的详情', await page.locator('[data-eval-pair-arm="关目录"]').getAttribute('href') === '/evals/runs/ev-pb?tab=pair')
|
||
|
||
// 分析工具贡献对照:两臂的名字、变体那一句(分析工具开 / 关)照服务端写
|
||
await page.goto(`${WEB}/evals/runs/ev-tb?tab=pair`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-eval-pair-row]').waitFor()
|
||
check('分析工具贡献对照:两臂各写自己的分析工具开、关', (await text(page, '[data-eval-pair-arm="分析工具开"]')).includes('分析工具开 · 结论裁判关')
|
||
&& (await text(page, '[data-eval-pair-arm="分析工具关"]')).includes('分析工具关 · 结论裁判关')
|
||
&& (await text(page, '[data-eval-pair-row]')).includes('分析工具开 vs 分析工具关'))
|
||
check('……这一臂的详情页头写它的变体(分析工具关)', (await text(page, '[data-eval-run-head]')).includes(TOOLS_TEXT(false)),
|
||
await text(page, '[data-eval-run-head]'))
|
||
await shot(page, 'run-pair-tools')
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 口径命中与重复度
|
||
|
||
await section('口径命中与重复度:参考线、分档、趋势、明细、加入题集、提取模板', async () => {
|
||
const { page, state, errors } = await open('/evals/signals')
|
||
await page.locator('[data-eval-signals="ok"]').waitFor()
|
||
await page.locator('[data-eval-signals-detail]').waitFor()
|
||
await shot(page, 'signals')
|
||
check('E1:35%,值得按已治理的口径走(档位由服务端给)', (await text(page, '[data-eval-card="e1"]')).includes('35%')
|
||
&& (await text(page, '[data-eval-card="e1"]')).includes('值得按已治理的口径走'))
|
||
check('E3:12%,反复问的问题还不多,按 SQL 形状的下限 4%', (await text(page, '[data-eval-card="e3"]')).includes('12%')
|
||
&& (await text(page, '[data-eval-card="e3"]')).includes('还不多') && (await text(page, '[data-eval-card="e3"]')).includes('4%'))
|
||
check('参考线照服务端写', (await text(page, '[data-eval-signals-criteria]')).includes('≥ 30%') && (await text(page, '[data-eval-signals-criteria]')).includes('≥ 20%'))
|
||
check('趋势:三个窗口各一根', await page.locator('[data-eval-trend-point="e1"]').count() === 3)
|
||
await page.locator('[data-eval-signals-detail]').waitFor()
|
||
check('明细直接来自口径账本的统计(按同一个窗口)', (await text(page, '[data-eval-signals-detail]')).includes('等价已证明 7')
|
||
&& state.ledgerSince === SIGNALS.window.since, state.ledgerSince)
|
||
await page.locator('[data-eval-signals-days]').selectOption('90')
|
||
check('换窗口:按 90 天重新取', await until(() => (state.signalDays ?? []).includes('90')), JSON.stringify(state.signalDays))
|
||
await page.locator('[data-eval-cluster="4"]').waitFor()
|
||
check('没挂运行的问题类不能提取模板', await page.locator('[data-eval-cluster="3"] [data-eval-cluster-extract]').isDisabled())
|
||
await page.locator('[data-eval-cluster="4"] [data-eval-cluster-add]').click()
|
||
await page.locator('[data-eval-cluster-suite]').selectOption('suite-b')
|
||
await page.locator('[data-eval-cluster-go]').click()
|
||
check('加入题集:去那个题集打开加题,问法带过去', await until(() => new URL(page.url()).pathname === '/evals/suites/suite-b')
|
||
&& await until(async () => (await page.locator('[data-eval-case-question]').inputValue()) === '上周华东的销售额是多少?'))
|
||
await page.goto(`${WEB}/evals/signals`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-eval-cluster="4"] [data-eval-cluster-extract]').click()
|
||
check('提取模板:从最近一次的运行提取,去画布', await until(() => new URL(page.url()).pathname === '/studio/wf-drafted')
|
||
&& JSON.stringify(lastWrite(state, /from-run$/)?.body) === JSON.stringify({ run_id: 'run-chat-1' }), JSON.stringify(lastWrite(state, /from-run$/)?.body))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 目录快照
|
||
|
||
await section('目录快照:列表、拍当前目录、影子起草先估算再开始、停止', async () => {
|
||
const { page, state, errors } = await open('/evals/snapshots')
|
||
await page.locator('[data-eval-snapshot="snap-live"]').waitFor()
|
||
await shot(page, 'snapshots')
|
||
check('影子起草在跑:写进度、给停止', (await text(page, '[data-eval-snapshot="snap-shadow"]')).includes('已起草 1 / 2 张表')
|
||
&& await page.locator('[data-eval-snapshot="snap-shadow"] [data-eval-snapshot-stop]').count() === 1)
|
||
await page.locator('[data-eval-snapshot-take]').click()
|
||
await page.locator('[data-eval-snapshot-sources]').selectOption(['ds-shop'])
|
||
await page.locator('[data-eval-snapshot-take-confirm]').click()
|
||
await until(() => lastWrite(state, /catalog-snapshots$/))
|
||
check('拍当前目录:交选的数据源', JSON.stringify(lastWrite(state, /catalog-snapshots$/)?.body) === JSON.stringify({ source_ids: ['ds-shop'] }))
|
||
await page.locator('[data-eval-snapshot-shadow]').click()
|
||
await page.locator('[data-eval-shadow] [data-eval-snapshot-sources]').selectOption(['ds-shop'])
|
||
check('没估算不能开始', await page.locator('[data-eval-shadow-start]').isDisabled())
|
||
await page.locator('[data-eval-shadow-estimate]').click()
|
||
await page.locator('[data-eval-shadow-plan]').waitFor()
|
||
await shot(page, 'shadow')
|
||
check('估算:几张表、调几次模型、多少钱、表结构里没有的表照服务端写', (await text(page, '[data-eval-shadow-plan]')).includes('2 张表')
|
||
&& (await text(page, '[data-eval-shadow-plan]')).includes('$0.020') && (await text(page, '[data-eval-shadow-plan]')).includes('nope'))
|
||
await page.locator('[data-eval-shadow-start]').click()
|
||
await until(() => lastWrite(state, /shadow$/))
|
||
const shadow = lastWrite(state, /shadow$/)?.body
|
||
check('开始:和估算是同一份请求体', JSON.stringify(shadow) === JSON.stringify(lastWrite(state, /shadow\/estimate$/)?.body), JSON.stringify(shadow))
|
||
await page.locator('[data-eval-snapshot="snap-shadow"] [data-eval-snapshot-stop]').click()
|
||
check('停止:发了一次', await until(() => writes(state, /snap-shadow\/stop$/).length === 1))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 记录页、设置
|
||
|
||
await section('记录页:评测运行写来源「评测」,能跳回那道题', async () => {
|
||
const { page, errors } = await open('/runs', { state: freshState({ runs: [clone(EVAL_RUN)] }) })
|
||
const tag = page.locator('[data-run-eval]').first()
|
||
await tag.waitFor()
|
||
check('列表:评测运行带「评测」来源和作答这一步', (await page.locator('[data-run-origin="eval"]').first().innerText()).includes('评测')
|
||
&& (await tag.innerText()).includes('作答'))
|
||
check('标记指向评测里的那道题', (await tag.getAttribute('data-run-eval')) === '/evals/runs/ev-1?case=case-2')
|
||
await tag.click()
|
||
check('点标记:去评测详情、定位到那道题', await until(() => page.url().endsWith('/evals/runs/ev-1?case=case-2')), page.url())
|
||
await page.goto(`${WEB}/runs/run-eval-ans`, { waitUntil: 'networkidle' })
|
||
const link = page.locator('[data-run-eval-link]')
|
||
await link.waitFor()
|
||
check('运行详情页头:键盘到得了的链接', (await link.evaluate((el) => el.tagName)) === 'A'
|
||
&& (await link.getAttribute('href')) === '/evals/runs/ev-1?case=case-2' && (await link.innerText()).includes('来自评测'))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
await section('设置:评测一组和今天已花;存的时候只交 eval 一组', async () => {
|
||
const { page, state, errors } = await open('/settings/prefs')
|
||
const sec = page.locator('[data-eval-settings]')
|
||
await sec.waitFor()
|
||
await sec.scrollIntoViewIfNeeded()
|
||
await shot(page, 'settings')
|
||
check('今天已花照服务端写', (await text(page, '[data-eval-spend]')).includes('今天评测已花 $0.370 / 每日上限 $5.00'))
|
||
check('四项预算、保留次数照设置写', await page.locator('[data-eval-setting="max_parallel"]').inputValue() === '2'
|
||
&& await page.locator('[data-eval-setting="keep_runs"]').inputValue() === '20')
|
||
await page.locator('[data-eval-setting="max_parallel"]').fill('3')
|
||
await page.locator('[data-eval-setting="judge"]').check()
|
||
await page.locator('[data-eval-settings-save]').click()
|
||
await until(() => lastWrite(state, /^PUT \/settings$/))
|
||
const put = lastWrite(state, /^PUT \/settings$/)?.body
|
||
check('保存:只交 eval 一组,六项都在', JSON.stringify(Object.keys(put?.values ?? {})) === '["eval"]' && put.values.eval.max_parallel === 3
|
||
&& put.values.eval.judge === true && put.values.eval.keep_runs === 20, JSON.stringify(put))
|
||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||
})
|
||
|
||
await section('写请求都在浏览器层拦下', async () => {
|
||
check('没有一条写请求漏到服务端', !leaked.length, leaked.join('、'))
|
||
})
|
||
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 评测页检查全部通过')
|
||
process.exit(failed ? 1 : 0)
|