合入 W2-c 前端:分析卡节点与分析项编辑器、报告节点两个新字段、设置「分析」一节、问数据时间线的分析步骤与出具横幅三项、运行对比的分析卡一区、证据面板与正文接上分析结论(六步出处链、闭合核对、两期可比性、说法违规标注、分析范围)、可验证报告核对页与分析样本页、口径账本摘要防 NaN;服务端补分析工具事件里的判定与分析结论的单独出处接口

# Conflicts:
#	backend/tests/test_eval_schedule.py
This commit is contained in:
ThearchyHelios
2026-10-21 19:34:24 +08:00
50 changed files with 15055 additions and 105 deletions
+2 -1
View File
@@ -147,7 +147,8 @@ const TIMEOUT_MS = Number(process.env.CHECK_TIMEOUT_MS ?? 15 * 60_000)
// 各检查只跑其中几段、只跑一套主题的开关。壳里留着一个 ONLY=… 就会让整套检查悄悄只跑一小截,
// 却照样报「全部通过」,所以一律不传给子进程
const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'FX_SKIP', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'RUN_STATES_SKIP',
'UI_KIT_ONLY', 'CHECK_THEME', 'EVIDENCE_ONLY', 'PUBLISH_ONLY', 'RUNS_ONLY']
'UI_KIT_ONLY', 'CHECK_THEME', 'EVIDENCE_ONLY', 'PUBLISH_ONLY', 'RUNS_ONLY', 'STREAM_ONLY', 'COMPARE_ONLY', 'INQUIRY_ONLY',
'SHARE_ONLY', 'ACCESS_KEYS_ONLY', 'APP_EDITOR_ONLY', 'CAPABILITIES_ONLY']
// 另起的后端不拿会话里的模型密钥:seed 见到它们会种一个真的 provider
const SECRETS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY']
// 另起的几套不许占这几个口:开发(5273 / 8000)和沙箱(5373 / 8100)
+79 -3
View File
@@ -22,7 +22,9 @@
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列(卡的节点完成事件只给哈希)、同一张卡配上证据
// 目录里写着号码的老文档(文档只给哈希、页面写明原因)、设了遮罩的数据源上按地区汇总的查询、每种显示
// 格式各引用一处的经营月报、单位带尾随空白的经营月报、引用旁边是写作者自己写的普通文字的经营月报(「统一」「——」
// 「SKU-」「共…家」,原样导出全绿)),逐份断言各项结果和原因文案;有一项无法核对的样本页总览是中性的,不是绿的;
// 「SKU-」「共…家」,原样导出全绿)、带分析结论和系统写的分析范围的异动周报(判定语、具名的数、结果表的格,范围里的
// 编号点得开)),逐份断言各项结果和原因文案;有一项无法核对的样本页总览是中性的,不是绿的;异动周报上只改判定语、具名的数
// 的显示并重算全部哈希,各自变红并点名;
// 经营月报上只改文字取值、数字、换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它
// 旁边的(链接地址藏起数字、整段改成分隔线、文字片段冒用数字的 id;数字前面补一位数、加负号(半角、数学减号、负号
// 前面是汉字的)、后面接「0 万」,隔着显示时不占位置的控制字符补一位数,这几种变红并点名「正文里紧挨着这个数的字改变了
@@ -611,7 +613,7 @@ await section('样本页:各种形状', async () => {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 18, Object.keys(samples).join('、'))
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 19, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:有无法核对的项,总览是中性的「可核对的项全部通过,另有 N 项无法核对」,不是绿色的通过', neutral(s),
@@ -904,6 +906,57 @@ await section('样本页:各种形状', async () => {
check('prose:17 个数字和取值都显示成可点的数字、都通过', s.numberStates.pass === 17 && s.numbers === 17,
JSON.stringify(s.numberStates))
})
// 异动周报(分析能力一轮):Agent 的三份分析结论(按地区的变化拆解、算不了的波动基线、两期可比性检查)和两份输入快照
// 随文件附带、导出时服务端复算过;报告引用拆解的判定语、具名的数、结果表的格(连同一整张结果表),正文后面是系统写的
// 「分析范围」。页面只核哈希、核判定语和数是不是结论里写的那几个字,不在浏览器里重算统计
await sample('analysis', async (s, page) => {
check('analysis:总览「全部通过」,没有一项无法核对', s.overall === 'pass' && s.text === '全部通过'
&& !s.items.some((i) => i.result !== 'pass'), `${s.overall}:${s.text};${describe(s)}`)
const kinds = s.items.filter((i) => i.check === 'artifacts').map((i) => i.text)
check('analysis:③ 三份分析结论、两份输入快照、报告文档都随文件附带、通过',
kinds.filter((t) => t.includes('分析结论')).length === 3 && kinds.filter((t) => t.includes('查询快照')).length === 2
&& kinds.some((t) => t.includes('报告文档')), kinds.join(' | '))
check('analysis:④ 输入快照按「已核对的分析结论的输入」认', hasItem(s, 'chain', 'pass', '分析结论(A1)的输入')
&& hasItem(s, 'chain', 'pass', '分析结论(A2)的输入'), s.items.filter((i) => i.check === 'chain').map((i) => i.text).join(' | '))
check('analysis:⑤ 分开数数字、取值、判定语', s.sums.numbers.includes('判定语 1 个'), s.sums.numbers)
const phrase = page.locator('.doc button.num[data-kind="analysis"]')
check('analysis:判定语是一个可点的数,读屏读作「判定语 …,通过」', await phrase.count() === 1
&& (await phrase.getAttribute('aria-label') ?? '').startsWith('判定语 销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%),通过'),
await phrase.getAttribute('aria-label'))
const detail = await detailText(page, phrase)
check('analysis:点开判定语:判定语、判定标准、方法与版本(服务端带的叫法)', detail.includes('判定语')
&& detail.includes('一段占总变化的一半以上算主要来源') && detail.includes('变化拆解(可加) · decompose.additive@1'),
detail.replace(/\s+/g, ' ').slice(0, 300))
check('analysis:……附带说明、Agent 写的假设', detail.includes('附带说明') && detail.includes('Agent 写的假设')
&& detail.includes('下降是不是集中在某个地区'), detail.replace(/\s+/g, ' ').slice(0, 400))
check('analysis:……输入(角色、编号、各自的核对状态)', ['基期总数 · Q1', '本期总数 · Q1', '数据 · Q2'].every((t) => detail.includes(t)),
detail.replace(/\s+/g, ' ').slice(0, 500))
check('analysis:……复算写导出时服务端复算的结论、本页不重算统计', detail.includes('复算一致:按封存的输入重新计算,结论逐项相同')
&& detail.includes('本页只核哈希,不在浏览器里重算统计'), detail.replace(/\s+/g, ' ').slice(-300))
const share = page.locator('.doc p button.num').filter({ hasText: /^61\.9%$/ })
const shareDetail = await share.count() ? await detailText(page, share.first()) : '找不到「61.9%」'
check('analysis:具名的数(占比):出处写分析结论 A1 的具名的数「top_share」,和结论里记的值一致', shareDetail.includes('分析结论 A1 的具名的数「top_share」')
&& shareDetail.includes('与按显示规则渲染的结果一致'), shareDetail.replace(/\s+/g, ' ').slice(0, 300))
const cell = page.locator('.doc p button.num').filter({ hasText: /^-120$/ })
const cellDetail = await cell.count() ? await detailText(page, cell.first()) : '找不到「-120」'
check('analysis:结果表的格:出处写分析结论 A1 的结果表 · 第 1 行 · delta', cellDetail.includes('分析结论 A1 的结果表 · 第 1 行 · delta'),
cellDetail.replace(/\s+/g, ' ').slice(0, 300))
check('analysis:结果表整表引用:表头是给人看的列名,数字列右对齐', (await page.locator('.doc table th').allTextContents()).join('、') === '地区、基期、本期、变化'
&& await page.locator('.doc td.n button.num').count() === 12, (await page.locator('.doc table th').allTextContents()).join('、'))
const scope = page.locator('[data-scope]')
const st = (await scope.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('analysis:正文后面是系统写的「分析范围」:现象 A3、拆过 A1(按地区)、算不了 A2、没拆的渠道', st.includes('分析范围')
&& st.includes('写作者改不了') && /现象 A3/.test(st) && /拆过 A1(按地区)/.test(st) && /算不了 A2/.test(st) && /没拆 渠道/.test(st), st)
const a2 = scope.locator('button.num').filter({ hasText: /^A2$/ })
const a2Detail = await a2.count() ? await detailText(page, a2.first()) : '找不到 A2'
check('analysis:点范围里的 A2(正文没引用):随文件附带,看得到算不了的判定语和复算', a2Detail.includes('波动基线没有做成:只有 0 期历史,至少要 8 期')
&& a2Detail.includes('复算一致'), a2Detail.replace(/\s+/g, ' ').slice(0, 300))
check('analysis:……范围里的编号不核「正文显示」', !a2Detail.includes('正文显示'), a2Detail.replace(/\s+/g, ' ').slice(0, 300))
})
await sample('analysis', async (s) => {
check('analysis:390 宽不横向溢出', s.overflow <= 0, `${s.overflow}px`)
}, { width: 390 })
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
{
@@ -987,7 +1040,7 @@ await section('样本页:篡改', async () => {
data.artifacts.push({ id: card.id.replace(/^./, (c) => (c === '0' ? '1' : '0')), kind: 'retrieval', masked: true })
return '多一份标着遮罩的检索快照'
}, (s) => [...turnsRed('artifacts')(s),
['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照、口径卡和报告文档会因遮罩不随文件附带')]])
['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照、口径卡、分析结论和报告文档会因遮罩不随文件附带')]])
// 遮罩卡在服务端被改过时,服务端照样只给 id、另标 mismatch(tests/test_bundle_masked_cards.py 守着服务端这一边):
// 不能因为不嵌内容就和正常的遮罩卡一样「可核对的项全部通过」
await tamper(samples['masked-card'], '遮罩卡标成服务端的文件与 id 不一致', (data) => {
@@ -1083,6 +1136,29 @@ await section('样本页:篡改', async () => {
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain', 'meta'])(s),
['报告徽标不通过', s.reports[0]?.result === 'fail' && s.reports[0].parts.includes('不通过 1'), JSON.stringify(s.reports[0])],
], { seal: true })
// 分析结论:只改正文显示的判定语、具名的数(引用不动、重算全部哈希),⑤ 里只有那一项不通过,原因点名正文显示的字
for (const [label, ref, change] of [['判定语', 'a:A1', (t) => t.replace('华东', '华南')], ['具名的数', 'a:A1.top_share|pct', () => '71.9%']]) {
await tamper(samples.analysis, `分析结论的${label}的显示并重算全部哈希`, (data) => rewriteDoc(data, (doc) => {
const seg = doc.blocks.flatMap((b) => b.units).flatMap((u) => u.segments).find((x) => x.ref === ref)
if (!seg) return null
const text = change(seg.text)
doc.markdown = doc.markdown.replace(seg.text, text)
seg.text = text
seg.cite.rendered = text
return { what: `片段 ${seg.id}(${ref})`, seg }
}), (s, forged) => {
const failed = s.items.filter((i) => i.check === 'numbers' && i.result === 'fail' && !i.folded)
return [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
['⑤ 里只有这一项不通过', failed.length === 1, `${failed.length} 项`],
['原因点名正文显示的字', failed[0]?.text.includes(`正文显示「${forged.seg.text}」`), failed[0]?.text ?? '没有不通过的项'],
...(ref === 'a:A1' ? [['判定语另写结论里的判定语,不写「引用的值是空值」', failed[0]?.text.includes('分析结论里的判定语是「销售额下降 194(-8.7%),主要来自华东')
&& !failed[0]?.text.includes('空值'), failed[0]?.text ?? '']] : []),
['正文里那一处标成不通过', s.failedButtons.length === 1 && s.failedButtons[0].includes(`${forged.seg.text},不通过`),
s.failedButtons.join(' | ')],
]
}, { seal: true })
}
// 只改显示文字、引用值不动、重算全部哈希(验收里的 T4、T5):经营月报上改文字取值、千分位加货币单位的数字、换算成万的
// 口径卡引用,各自只有那一项不通过。原因点名正文显示的字和引用的值,数字另写按显示规则应显示的字
const shownForgeries = [
+55
View File
@@ -180,6 +180,28 @@ const NOTE = '基准运行中「退货明细」的查询结果已截断(只保
/** 以更早的一次(B2)为基准时多一条说明:换基准、过时答复的检查靠它认出页面上是哪一份 */
const NOTE_B2 = '以更早的一次运行为基准'
/**
* 分析卡(分析能力一轮,engine/compare._analyses):两次运行的分析卡按节点和项的标识对齐。形状照服务端真算出来的(用
* tests/fixtures/compare_runs 的虚构周报加分析卡的 analysis.end 跑 compare.compare):判定变了两项、判定一样判定语的数变了
* 一项、这次新增(算不了)一项、这次没有一项
*/
const side = (verdict, direction, phrase, status = 'ok', item = '') => ({ status, verdict, direction, phrase, artifact: `art-${item}-${verdict}` })
const ANALYSES = [
{ node_id: 'insight', title: '异动分析', item: 'wow', op: 'baseline', mode: null, change: 'verdict',
base: side('within_band', 'down', '销售额下降 3.1%,在过去 8 周的常规波动范围内(-6.2% 至 5.8%)', 'ok', 'wow'),
target: side('outside_band', 'down', '销售额下降 9.8%,超出过去 8 周的常规波动范围(-6.2% 至 5.8%)', 'ok', 'wow') },
{ node_id: 'insight', title: '异动分析', item: 'where', op: 'decompose', mode: 'additive', change: 'verdict',
base: side('driver', 'down', '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)', 'ok', 'where'),
target: side('dispersed', 'down', '销售额下降 120(-4.5%),分散在各地区,没有哪一段占到一半', 'ok', 'where') },
{ node_id: 'insight', title: '异动分析', item: 'trend', op: 'trend', mode: null, change: 'phrase',
base: side('rising', 'up', '过去 8 周线上销售额呈上升趋势(每周约 2.1%)', 'ok', 'trend'),
target: side('rising', 'up', '过去 8 周线上销售额呈上升趋势(每周约 2.4%)', 'ok', 'trend') },
{ node_id: 'insight', title: '异动分析', item: 'refund', op: 'differ', mode: 'rate', change: 'added', base: null,
target: side(null, null, '差异检验没有做成:两组各只有 1 个样本,拿不到 p 值', 'refused', 'refund') },
{ node_id: 'insight', title: '异动分析', item: 'gone', op: 'comparable', mode: null, change: 'removed',
base: side('comparable', null, '两期可比', 'ok', 'gone'), target: null },
]
function comparison(target, base) {
return { target: refOf(target), base: refOf(base), summary: SUMMARY, template: TEMPLATE, metrics: METRICS, queries: QUERIES,
notes: base === B2 ? [NOTE, NOTE_B2] : [NOTE] }
@@ -1418,6 +1440,39 @@ const overflow = (page) => page.evaluate(() => {
return { root: root.scrollWidth - root.clientWidth, right: Math.round(r.right - innerWidth), wide: wide.slice(0, 3) }
})
await section('分析卡:两次的判定按节点和分析项对齐,变了没有照服务端', async () => {
const { page, errors } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), analyses: ANALYSES } }) })
await settled(page)
const box = page.locator('[data-compare-analyses]')
check('分析卡一区:标题、项数、说明(只列判定,不拆变化来源)', (await box.innerText().catch(() => '')).includes('分析卡')
&& (await box.innerText()).includes('不拆变化来源'), (await box.innerText().catch(() => '')).slice(0, 80))
const rows = box.locator('[data-compare-analysis]')
check('一项一行,先后照服务端', JSON.stringify(await rows.evaluateAll((els) => els.map((e) => e.dataset.compareAnalysis)))
=== JSON.stringify(ANALYSES.map((a) => `${a.node_id}:${a.item}`)))
const row = (item) => box.locator(`[data-compare-analysis="insight:${item}"]`)
const wow = (await row('wow').innerText()).replace(/\s+/g, ' ')
check('判定变了:写「判定变了」,两边的判定语照原样', wow.includes('判定变了') && wow.includes('在过去 8 周的常规波动范围内')
&& wow.includes('超出过去 8 周的常规波动范围'), wow)
check('……标题写分析卡的名字和项的标识、算子的叫法(契约)', wow.includes('异动分析 · wow') && wow.includes('波动基线')
&& (await row('where').innerText()).includes('变化拆解(可加)'))
check('判定一样、判定语里的数变了', (await row('trend').innerText()).includes('判定一致,判定语里的数变了'))
const added = (await row('refund').innerText()).replace(/\s+/g, ' ')
check('这次新增的一项:基准一边写「—」,算不了的判定语照原样', added.includes('这次新增') && added.includes('—')
&& added.includes('差异检验没有做成'), added)
check('这次没有的一项', (await row('gone').innerText()).includes('这次没有'))
check('……变化的说法用服务端的代码挑(data-change)', JSON.stringify(await rows.evaluateAll((els) => els.map((e) => e.dataset.change)))
=== JSON.stringify(ANALYSES.map((a) => a.change)))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
// 老服务端、两次都没有分析卡:不画这一区
const old = await open(comparePath(T, B))
await settled(old.page)
check('没有分析卡(老服务端没有这一项):不画这一区', await old.page.locator('[data-compare-analyses]').count() === 0)
const narrow = await open(comparePath(T, B), { width: 390, height: 900, compare: (t, b) => ({ json: { ...comparison(t, b), analyses: ANALYSES } }) })
await settled(narrow.page)
const o = await overflow(narrow.page)
check('窄屏:分析卡一区不撑出横向滚动', o.root <= 0 && o.right <= 0, JSON.stringify(o))
})
await section('窄屏', async () => {
for (const theme of ['dark', 'light']) {
const { page, errors } = await open(comparePath(T, B), { width: 390, height: 844, theme })
+62
View File
@@ -112,6 +112,68 @@ await section('知识检索', async () => {
check('未知事件的类型名不上标题和副标题', !unknown.some((x) => x.title.includes('brand.new') || x.sub?.includes('brand.new')))
})
await section('分析:Agent 的分析工具、分析卡的每一项', async () => {
// 真跑出来的两次运行(fixtures.analysis_agent / analysis_card:后端测试库里的虚构零售库 shop,模型换成脚本)。Agent 查两次库、
// 写错一次变化拆解(列不存在)、做变化拆解、波动基线(两周历史不够,算不了)、可比性检查;分析卡两项。判定语、编号都是服务端
// 给的(tool.end 的 analysis、analysis.end),preview 只是结论 JSON 的前一截,不拿来拼
const all = flatten(mod.decodeRun(fixtures.analysis_agent, { status: 'succeeded' }))
const rows = all.filter((s) => s.kind === 'analysis')
check('四次分析调用各一行(kind analysis),挂在 Agent 节点下', rows.length === 4 && rows.every((s) => s.nodeId === 'bot'),
rows.map((s) => s.title).join(' | '))
const [wrong, decompose, baseline, comparable] = rows
check('标题写算子的叫法(契约),不写 analysis_decompose', rows.every((s) => !s.title.includes('analysis_')) && wrong?.title === '变化拆解(可加)',
wrong?.title)
check('写错的调用:这一次没成、副标题说没有记成结论,下一步写 Agent 会照着改', wrong?.status === 'failed'
&& wrong.sub === '调用有误,没有记成结论' && !!wrong.next?.includes('Agent 会按这条说明改正参数'), `${wrong?.status} ${wrong?.sub}`)
check('……服务端的原话留在展开区,调用错误的代码记在这一行', !!wrong?.result?.includes('Q2 里没有「sales」这一列')
&& wrong.analysis?.callError === 'unknown_column', wrong?.result)
check('做成的:标题接上本节点的编号(A1,和交回模型的「【分析 A1】」同一个)', decompose?.title === '变化拆解(可加) · A1', decompose?.title)
check('……副标题是服务端渲染的判定语', decompose?.sub === '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)', decompose?.sub)
check('……结论工件可下钻,Agent 写的假设另记、不进参数', !!decompose?.artifact && decompose.analysis?.hypothesis === '下降是不是集中在某个地区'
&& !decompose.detail?.includes('hypothesis') && !decompose.detail?.includes('null'), decompose?.detail?.slice(0, 80))
check('……preview(结论 JSON 的前一截)不摆出来', !decompose?.result, decompose?.result?.slice(0, 40))
check('算不了的:一条结论(不是失败),提醒色,判定语就是「…没有做成:原因」', baseline?.status === 'done' && baseline.level === 'warn'
&& baseline.title === '波动基线 · A2' && baseline.sub === '波动基线没有做成:只有 0 期历史,至少要 8 期', `${baseline?.title} ${baseline?.sub}`)
check('……下一步说能照实写原因、不能据此下判断', !!baseline?.next?.includes('算不了也是一条结论'))
check('……紧跟着的「没有做成」提醒折进这一行,不另起一行', !all.some((s) => s.code === 'analysis_refused'),
all.filter((s) => s.code === 'analysis_refused').map((s) => s.title).join(' | '))
check('可比性检查也有编号和判定语', comparable?.title === '可比性检查 · A3' && !!comparable.sub?.startsWith('两期可比'), comparable?.title)
check('分析那几行是正常的子步骤:不当成运行出错(运行照样完成)', all.some((s) => s.kind === 'lifecycle' && s.title === '完成'))
const claims = all.find((s) => s.code === 'report_analysis_claims')
check('报告的分析说法重写后仍有:说成人话(几处、已在报告中标注),副标题点名判断词', claims?.title === '报告有 3 处分析说法未通过核对,已在报告中标注'
&& claims.sub === '「主要来自」、「异常」、「由于」' && !!claims.next?.includes('判断要挂对应的分析结论'), `${claims?.title} ${claims?.sub}`)
// 老服务端:tool.end 没有 analysis 那一小段。只照参数写叫法,不编号、不从 preview 里拼判定语
const legacy = fixtures.analysis_agent.map((e) => (e.type === 'tool.end' && e.data?.analysis
? { ...e, data: Object.fromEntries(Object.entries(e.data).filter(([k]) => k !== 'analysis')) } : e))
const old = flatten(mod.decodeRun(legacy, { status: 'succeeded' })).filter((s) => s.kind === 'analysis')
check('老服务端:照参数写叫法,没有编号和判定语,也不摆 preview', old[1]?.title === '变化拆解(可加)' && !old[1].sub && !old[1].result,
`${old[1]?.title} ${old[1]?.sub ?? ''}`)
// 老事件里「没有做成」的提醒找不到那一行时照结构化字段说
const lone = flatten(mod.decodeRun([
{ seq: 1, type: 'node.started', node_id: 'bot', ts: 1, data: { node_type: 'agent', label: '问数据' } },
{ seq: 2, type: 'log', node_id: 'bot', ts: 2, data: { level: 'warn', code: 'analysis_refused', tool: 'analysis_trend', call_id: 'x9',
reason_code: 'history_short', reason: '只有 3 期历史,至少要 6 期', message: 'analysis_trend 没有做成:只有 3 期历史,至少要 6 期' } },
])).find((s) => s.code === 'analysis_refused')
check('……那一行不在时:照结构化字段说哪一种分析没有做成、原因', lone?.title === '趋势判断没有做成' && lone.sub === '只有 3 期历史,至少要 6 期',
`${lone?.title} ${lone?.sub}`)
// 分析卡:每项一行(analysis.end),标题写算子的叫法和这一项的标识
const card = flatten(mod.decodeRun(fixtures.analysis_card, { status: 'succeeded' }))
const items = card.filter((s) => s.kind === 'analysis')
check('分析卡的两项各一行,挂在分析卡下', items.length === 2 && items.every((s) => s.nodeId === 'card'), items.map((s) => s.title).join(' | '))
check('……标题写叫法和标识,副标题是判定语', items[0]?.title === '波动基线 · wow' && items[1]?.title === '变化拆解(可加) · where'
&& items[1].sub === '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)', items.map((s) => `${s.title}:${s.sub}`).join(' | '))
check('……算不了的那一项提醒色,结论工件可下钻', items[0]?.level === 'warn' && !!items[0].artifact && !!items[1]?.artifact)
check('……analysis.end 不再落成「未识别的事件」,紧跟着的提醒也不另起一行', !card.some((s) => s.title === '未识别的事件')
&& !card.some((s) => s.code === 'analysis_refused'))
// 协作成员的分析:编号是成员自己的,副标题接着写是谁调用的
const team = flatten(mod.decodeRun(synthetic.analysisTeamRun(), { status: 'succeeded' })).filter((s) => s.kind === 'analysis')
check('协作成员调分析工具:编号是成员自己的,副标题写判定语和谁调用的', team[0]?.title === '变化拆解(可加) · A1'
&& team[0].sub === `${synthetic.TEAM_PHRASE} · 分析员 调用` && team[1]?.title === '波动基线 · A2', team.map((s) => `${s.title}:${s.sub}`).join(' | '))
})
await section('合并查询', async () => {
// merge.end 是合并查询节点做完的那一条:哪几个输入、合并 SQL、几行。和查询同一类(kind query),
// 合并 SQL 进详情、预览进结果、工件可下钻;警告的原文由紧跟着的 log 各占一行,这一行只说有几条
+231
View File
@@ -41,6 +41,10 @@ const JUDGE_SETS = [fxj.formal, fxj.explore, fxj.plain, fxv.formal, fxv.explore]
// Excel 导入期 4:推断的来源。文档由 compose_doc 真跑(带文档标记),片段答复由 _chain 真跑,推断来源的答复按契约
// (backend/app/data/provenance_types.py)拼;cases 写明每个场景是哪个片段、片段文字、覆盖 P4-SPEC 4.4 的哪几条
const fxp = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-provenance.json`, 'utf8'))
// 分析能力一轮:分析结论(判定语、具名的数、结果表的格)、说法违规、系统写的分析范围、口径卡指标上的两期可比性。文档、片段接口、
// 证据图、审计、分析范围每个编号的出处链(GET …/evidence/analyses/{alias})都是真后端答的(/tmp/agentlab-fix/w2c 的录制脚本)
const fxa = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-analysis.json`, 'utf8'))
const FXA = [fxa.agent, fxa.card, fxa.metric]
/** 推断来源夹具里各期导入清单的工件 id:夹具只收了两份清单,其余的按「工件不存在」答,不落到真服务端 */
const FXP_MANIFESTS = new Set(Object.values(fxp.provenance).flatMap((a) => a.version?.parts ?? []).map((p) => p.manifest))
/** 夹具里某段文字是哪个片段:检查按文字认片段,重新生成夹具时编号变了也不用改 */
@@ -553,6 +557,233 @@ await section('artifacts', '记录页的工件:口径卡指标集、报告文
check('两种都算证据(列表里醒目)', got.evidence)
})
/** 分析夹具的三次运行:片段、证据图、分析范围编号的出处链按运行 id 答(排在 routed 前面,别的交回 routed) */
async function analysisRoutes(page, hits, graphOf = (set) => set.graph) {
await page.route((u) => FXA.some((x) => new URL(u).pathname.includes(`/runs/${x.run_id}`)), (r) => {
const url = new URL(r.request().url())
const set = FXA.find((x) => url.pathname.includes(`/runs/${x.run_id}`))
const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (seg) {
const id = decodeURIComponent(seg[1])
hits.push(`seg:${id}`)
const body = set.segments[id]
return body ? r.fulfill({ json: body }) : r.fulfill({ status: 404, json: { detail: '报告中没有这个片段', code: 'evidence_segment_not_found' } })
}
const an = url.pathname.match(/\/evidence\/analyses\/([^/]+)$/)
if (an) {
const alias = decodeURIComponent(an[1])
hits.push(`analysis:${alias}`)
const body = set.analyses[alias]
return body ? r.fulfill({ json: body })
: r.fulfill({ status: 404, json: { detail: '这份报告的证据目录里没有这份分析结论', code: 'evidence_analysis_not_found' } })
}
if (/\/evidence$/.test(url.pathname)) return r.fulfill({ json: graphOf(set) })
if (url.pathname.endsWith(`/runs/${set.run_id}`)) return r.fulfill({ json: { id: set.run_id, run_class: 'exploratory', status: 'succeeded' } })
return r.fulfill({ status: 404, json: { detail: '检查脚本没有这一项' } })
})
}
await section('analysis', '分析结论:判定语、具名的数、结果表的格的出处链(判定 → 方法 → 计算 → 输入 → 复算 → 封存)', async () => {
const hits = []
const { ctx, page, errors } = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, hits) })
const box = page.locator('#evidence-analysis')
const A = fxa.agent
const phrase = box.locator('[data-seg="s3"]')
check('判定语是一个可点的片段,画「有出处」', await phrase.count() === 1 && (await phrase.getAttribute('data-ev-state')) === 'deterministic')
check('……读屏写出处:分析结论 A1 · 判定语', (await phrase.getAttribute('aria-label') ?? '').includes('有出处:分析结论 A1 · 判定语'),
await phrase.getAttribute('aria-label'))
await phrase.click()
const p = panel(page)
await p.locator('[data-ev-analysis="A1"]').waitFor({ timeout: 8000 })
const judged = p.locator('[data-ev-analysis="A1"]')
const jt = (await judged.innerText()).replace(/\s+/g, ' ')
check('判定一步:标题写编号和方法的叫法(服务端的 method_label)', jt.includes('分析结论 A1 · 变化拆解(可加)'), jt.slice(0, 60))
check('……判定语照原样、判定标准、点名的段、方向', (await judged.locator('[data-ev-analysis-phrase]').innerText()) === A.segments.s3.chain[0].phrase
&& jt.includes('一段占总变化的一半以上算主要来源') && jt.includes('华东') && jt.includes('下降'), jt.slice(0, 200))
check('……谁做的:Agent 现场做的分析、哪个节点', jt.includes('Agent 现场做的分析') && jt.includes('问数据'))
check('……附带说明、Agent 写的假设(另写一句它不是结论)', jt.includes('没能核对本期的数据是否到齐')
&& (await judged.locator('[data-ev-analysis-hypothesis]').innerText()).includes('下降是不是集中在某个地区')
&& jt.includes('不是分析结论'), jt.slice(-200))
check('……复算一致(服务端复算的那一句)、封存范围内的台账记过', await judged.locator('[data-ev-analysis-recompute="ok"]').count() === 1
&& jt.includes('复算一致:按封存的输入重新计算,结论逐项相同') && await judged.locator('[data-ev-analysis-seal="ledger"]').count() === 1)
check('……这一处引用的是判定语', (await judged.locator('[data-ev-analysis-cited]').getAttribute('data-ev-analysis-cited')) === 'phrase')
const method = (await p.locator('[data-ev-analysis-method]').innerText()).replace(/\s+/g, ' ')
check('方法一步:方法与版本、实际用的阈值(契约的叫法)、规范化后的参数', method.includes('变化拆解(可加) · decompose.additive@1')
&& method.includes('「主要来源」的占比 0.5') && (await p.locator('[data-ev-analysis-params]').innerText()).includes('"by"'), method.slice(0, 160))
const calc = p.locator('[data-ev-analysis-calc]')
check('计算一步:关键数的代入式照服务端', (await calc.locator('[data-ev-analysis-formulas]').innerText()).includes('华东的占比 = 华东的变化 ÷ 总变化 = -300 ÷ (-340) = 88.2%'))
check('……具名的数、结果表(表头是给人看的列名)', await calc.locator('[data-ev-analysis-value="top_share"]').count() === 1
&& (await calc.locator('[data-ev-analysis-table] th').allInnerTexts()).join('、').includes('占变化的比重'))
const inputs = p.locator('[data-ev-analysis-input]')
check('输入一步:每份一节(基期总数、本期总数、数据)', JSON.stringify(await inputs.evaluateAll((els) => els.map((e) => e.dataset.evAnalysisInput)))
=== JSON.stringify(['headline_base', 'headline_target', 'data']))
check('……总数那一格和各段之和的闭合核对(服务端算好的数)', (await inputs.nth(0).innerText()).includes('总数 2,700 与各段之和 2,700 闭合')
&& await inputs.nth(0).locator('[data-ev-analysis-closure="closed"]').count() === 1)
check('……数据:用到的列、全部行', (await inputs.nth(2).innerText()).includes('region、week、gmv') && (await inputs.nth(2).innerText()).includes('全部行'))
check('输入快照的查询步骤接在后面,写明是 A1 的输入', await p.locator('[data-ev-input-of="A1"]').count() === 2)
await inputs.nth(2).locator('[data-ev-goto-query="Q2"]').click()
await page.waitForTimeout(150)
check('……点输入里的 Q2:焦点跳到那个查询步骤', await page.evaluate(() => document.activeElement?.closest('[data-ev-query-alias]')?.getAttribute('data-ev-query-alias')) === 'Q2')
check('分析结论的数不走口径账本:没有「口径」一步', await p.locator('[data-ev-ledger]').count() === 0)
check('面板底下写服务端的说明(判定只说明变化来自哪里)', (await p.locator('[data-ev-analysis-note]').innerText()).includes('判定只说明变化来自哪里'))
// 具名的数、结果表的格:同一条链,「这一处引用的是」写清是哪一项,被引用的那个描出来
await box.locator('[data-seg="s9"]').click()
await p.locator('[data-ev-analysis-cited="value"]').waitFor({ timeout: 8000 })
check('具名的数(88.2%):这一处引用的是具名的数「top_share」,那个数在计算里描出来', (await p.locator('[data-ev-analysis-cited]').innerText()) === '具名的数「top_share」'
&& await p.locator('[data-ev-analysis-value="top_share"][data-ev-cited]').count() === 1)
await box.locator('[data-seg="s7"]').click()
await p.locator('[data-ev-analysis-cited="cell"]').waitFor({ timeout: 8000 })
check('结果表的格(-300):引用的是结果表第 1 行「delta」,那一格高亮', (await p.locator('[data-ev-analysis-cited]').innerText()) === '结果表第 1 行「delta」'
&& await p.locator('[data-ev-analysis-table] td[data-highlight="cell"]').count() === 1)
check('……读屏写出处:分析结论 A1 · 结果表第 1 行「delta」', (await box.locator('[data-seg="s7"]').getAttribute('aria-label') ?? '').includes('分析结论 A1 · 结果表第 1 行「delta」'))
check('片段接口按片段 id 取(s3、s9、s7)', ['seg:s3', 'seg:s9', 'seg:s7'].every((h) => hits.includes(h)), hits.join(','))
// 分析卡的结论:谁做的写分析卡(模板定好的分析);表格里引用结果表的格
const card = page.locator('#evidence-analysis-card')
await card.locator('[data-seg="s3"]').click()
await p.locator('[data-ev-analysis="A2"]').waitFor({ timeout: 8000 })
check('分析卡的结论:谁做的写「分析卡(模板定好的分析)」', (await p.locator('[data-ev-analysis-origin]').getAttribute('data-ev-analysis-origin')) === 'card'
&& (await p.locator('[data-ev-analysis="A2"]').innerText()).includes('分析卡(模板定好的分析)'))
await card.locator('[data-seg="s6"]').click()
await p.locator('[data-ev-analysis="A1"]').waitFor({ timeout: 8000 })
check('算不了的结论:标「这次分析没有做成」、写算不了的原因', await p.locator('[data-ev-analysis-refused]').count() === 1
&& (await p.locator('[data-ev-analysis="A1"]').innerText()).includes('只有 0 期历史,至少要 8 期'))
// 窄栏:分析的几步栏内展开,不出横向滚动
const narrow = page.locator('#evidence-analysis-narrow')
await narrow.locator('[data-seg="s3"]').click()
await page.waitForTimeout(400)
const over = await narrow.evaluate((el) => el.scrollWidth - el.clientWidth)
check('360 宽:分析结论的出处链栏内展开、不出横向滚动', over <= 1 && await narrow.locator('[data-ev-analysis-calc]').count() === 1, `${over}px`)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(150)
await box.locator('[data-seg="s3"]').click()
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/evidence-analysis-${theme}.png` })
}
}
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await ctx.close()
})
await section('analysis-claims', '说法违规标在正文上、点开看原话和所挂的结论;n 跳得到;系统写的分析范围编号点得开;整句的依据写派生自谁', async () => {
const hits = []
const { ctx, page, errors } = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, hits) })
const box = page.locator('#evidence-analysis')
const flagged = box.locator('[data-ev-state="claim"]')
check('三句说法违规各是一个可点的片段', JSON.stringify(await flagged.evaluateAll((els) => els.map((e) => e.dataset.seg))) === '["s12","s14","s16"]')
const words = await box.locator('[data-ev-claim-word]').allInnerTexts()
check('……只在判断词下面画线(主要来自、异常、由于)', JSON.stringify(words) === '["主要来自","异常","由于"]', JSON.stringify(words))
const deco = await box.locator('[data-ev-claim-word]').first().evaluate((el) => getComputedStyle(el).textDecorationStyle)
check('……线型是波浪线(和无证据的点状线分得开)', deco === 'wavy', deco)
check('……整段文字不画线', await flagged.first().evaluate((el) => getComputedStyle(el).textDecorationLine) === 'none')
check('……句末挂「说法待核」', await box.locator('[data-ev-tag="claim"]').count() === 3)
check('……读屏写种类', (await flagged.first().getAttribute('aria-label') ?? '').includes('说法待核:判断与分析结论不符'))
await flagged.first().click()
const p = panel(page)
await p.locator('[data-ev-claim-flags]').waitFor({ timeout: 8000 })
const flag = p.locator('[data-ev-claim-flag="analysis_claim_mismatch"]')
const ft = (await flag.innerText()).replace(/\s+/g, ' ')
check('点开:种类的叫法(同审计)、服务端的原话', ft.includes('判断与分析结论不符') && ft.includes('「主要来自华南」与所挂的分析结论 A1 对不上'), ft)
await flag.locator('[data-ev-claim-analysis="A1"]').click()
await page.waitForTimeout(400)
check('……所挂的结论 A1 点一下:落到正文里 A1 的判定语、打开它的出处链', await active(page) === 's3'
&& await p.locator('[data-ev-analysis="A1"]').count() === 1, await active(page))
await box.locator('[data-seg="s14"]').click()
await p.locator('[data-ev-claim-flag="analysis_claim_uncited"]').waitFor({ timeout: 8000 })
check('判断词没挂分析结论:写这句没有挂分析结论', (await p.locator('[data-ev-claim-flag="analysis_claim_uncited"]').innerText()).includes('这句没有挂分析结论'))
await box.locator('[data-seg="s16"]').click()
await p.locator('[data-ev-claim-flag="causal_unsupported"]').waitFor({ timeout: 8000 })
check('因果说法没有依据:原话照服务端', (await p.locator('[data-ev-claim-flag="causal_unsupported"]').innerText()).includes('是原因判断'))
// n:从头跳,第一处就是说法违规
await box.locator('[data-seg="s3"]').focus()
await page.keyboard.press('n')
await page.waitForTimeout(150)
check('n 跳到下一处有问题的地方:说法违规那一段', ['s12', 's14', 's16'].includes(await active(page)), await active(page))
await page.keyboard.press('Escape')
// 违规清单:三条说法违规的叫法、能定位
await page.evaluate(() => document.querySelector('#evidence-analysis [data-evidence-list]')?.click())
await p.locator('[data-ev-violations]').waitFor({ timeout: 8000 })
const vt = (await p.locator('[data-ev-violations]').innerText()).replace(/\s+/g, ' ')
check('违规清单:三条都写种类的叫法、都能定位', ['判断与分析结论不符', '判断词未挂分析结论', '因果说法没有依据'].every((t) => vt.includes(t))
&& await p.locator('[data-ev-violation][data-ev-locatable="yes"]').count() === 3, vt.slice(0, 160))
await page.keyboard.press('Escape')
// 分析范围:系统写的一段,编号点得开(正文没引用的 A2、A3 也点得开)
const scope = box.locator('[data-ev-scope]')
const st = (await scope.innerText()).replace(/\s+/g, ' ')
check('正文后面是系统写的「分析范围」:现象、拆过(按地区)、算不了、没拆(渠道)', st.includes('分析范围') && st.includes('系统按这次的分析结论写的')
&& JSON.stringify(await scope.locator('[data-ev-scope-group]').evaluateAll((els) => els.map((e) => e.dataset.evScopeGroup)))
=== '["phenomena","decomposed","refused","not_decomposed"]' && st.includes('按地区') && st.includes('候选维度里没拆过的:渠道'), st.slice(0, 200))
await scope.locator('[data-seg="scope:A2"]').click()
await p.locator('[data-ev-analysis="A2"]').waitFor({ timeout: 8000 })
check('点范围里的 A2(正文没引用):取它的出处链(…/evidence/analyses/A2),面板写判定和算不了的原因', hits.includes('analysis:A2')
&& await p.locator('[data-ev-analysis-refused]').count() === 1 && (await p.locator('[data-ev-analysis="A2"]').innerText()).includes('至少要 8 期'),
hits.join(','))
check('……不画所在的句子(它不在正文里)', await p.locator('[data-ev-sentence]').count() === 0)
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('……Esc 关掉:焦点回到范围里的那个编号', await page.evaluate(() => document.activeElement?.getAttribute('data-seg')) === 'scope:A2')
await scope.locator('[data-seg="scope:A1"]').click()
await p.locator('[data-ev-analysis="A1"]').waitFor({ timeout: 8000 })
check('点范围里的 A1:同一条链(方法、计算、输入都在)', await p.locator('[data-ev-analysis-method]').count() === 1
&& await p.locator('[data-ev-analysis-input]').count() === 3)
await page.keyboard.press('Escape')
// 审计行:说法违规的那几行带问题代码(记录页的审计表按它写种类的叫法,不写「无证据」)
const audit = await page.evaluate(async (a) => (await import('/src/lib/evidence.ts')).auditFromApi(a)
.filter((r) => r.kind === 'violation').map((r) => r.issue), fxa.agent.audit)
check('审计:说法违规的行带问题代码', JSON.stringify(audit) === '["analysis_claim_mismatch","analysis_claim_uncited","causal_unsupported"]', JSON.stringify(audit))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await ctx.close()
// 整句面板:挂的依据是分析结论时写它派生自哪几份查询(证据图的 derived_from 边)。这份报告没开裁判,句末没有徽标;
// 证据图上加一条封存后的按需判定(探索运行点「判这一句」后就是这样),u1 句末才有徽标、点得开整句
const judged = (set) => set !== fxa.agent ? set.graph : { ...set.graph, reports: set.graph.reports.map((r) => ({ ...r,
post_seal_verdicts: { u1: { status: 'supported', rationale: '判定语照 A1 的判定写', judge: 'judge-model-b', post_seal: true, used: ['A1'] } } })) }
const second = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, [], judged) })
const badge = second.page.locator('#evidence-analysis [data-ev-claim="u1"]')
await badge.waitFor({ timeout: 8000 })
await badge.click()
const cites = panel(second.page).locator('[data-ev-cites]')
await cites.waitFor({ timeout: 8000 })
await second.page.waitForTimeout(300)
const derived = cites.locator('[data-ev-cite="A1"] [data-ev-derived]')
check('整句的依据是 A1:写「派生自 Q1、Q2」(证据图的 derived_from 边,按这份报告认)', await derived.count() === 1
&& (await derived.getAttribute('data-ev-derived')) === 'Q1,Q2' && (await derived.innerText()).includes('Q1、Q2'),
(await cites.innerText()).replace(/\s+/g, ' '))
check('……挂的依据里 A1 写分析结论的叫法', (await cites.locator('[data-ev-cite="A1"]').innerText()).includes('变化拆解'),
(await cites.locator('[data-ev-cite="A1"]').innerText()).replace(/\s+/g, ' '))
check('没有运行时报错(整句面板)', second.errors.length === 0, second.errors.join(' | '))
await second.ctx.close()
})
await section('analysis-metric', '口径卡指标上的两期可比性:判定、判定语,展开看那份可比性结论的整条链(随卡封存)', async () => {
const hits = []
const { ctx, page, errors } = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, hits) })
const box = page.locator('#evidence-analysis-metric')
await box.locator('[data-seg="s1"]').click()
const p = panel(page)
await p.locator('[data-ev-comparability]').waitFor({ timeout: 8000 })
const row = p.locator('[data-ev-comparability]')
check('指标一步里多一行「两期可比性」:判定写叫法、判定语照服务端', (await row.getAttribute('data-ev-comparability')) === 'comparable'
&& (await row.innerText()).includes('两期可比') && (await row.locator('[data-ev-comparability-phrase]').innerText()) === '两期可比,窗口长度、整期、星期构成核对不了')
await row.locator('[data-ev-comparability-toggle]').click()
await page.waitForTimeout(200)
const chain = row.locator('[data-ev-comparability-chain]')
check('展开:那份可比性结论的判定、方法、计算、输入', await chain.locator('[data-ev-analysis-origin="metric"]').count() === 1
&& await chain.locator('[data-ev-analysis-method]').count() === 1 && await chain.locator('[data-ev-analysis-input]').count() === 2)
check('……谁做的写「口径卡执行时自动做的可比性检查」,封存写随卡封存(不进台账)', (await chain.innerText()).includes('口径卡执行时自动做的可比性检查')
&& await chain.locator('[data-ev-analysis-seal="card"]').count() === 1)
check('……复算一致', await chain.locator('[data-ev-analysis-recompute="ok"]').count() === 1)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await ctx.close()
})
await section('long', '长报告:块级 content-visibility、折叠、跳进折叠区自动展开', async () => {
const box = '#evidence-long'
const info = await page.evaluate((sel) => {
+4 -1
View File
@@ -458,7 +458,9 @@ await section('使用者看到的入口照服务端的动作:不能出具的
meta: { v: 1, runId: RUN_U, runClass: 'exploratory', runStatus: 'succeeded', evidence: true } }
const refused = { ...ISSUE, available: false, auto: false, reason: '这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具' }
delete refused.auto_reason
const LED = ledgerOf(RUN_U, { labels: WHOLE_LABELS, whole: WHOLE, actions: [refused, RECONCILE],
// 摘要只认四种标签(分析结论的数不和口径比对,服务端不给它们标签):混进来认不出的种类,不计、不出 NaN
const LED = ledgerOf(RUN_U, { labels: { ...WHOLE_LABELS, s40: { kind: 'analysis' }, s42: { reason: '没有种类' } },
whole: WHOLE, actions: [refused, RECONCILE],
// 出具过的是另一份口径(订单口径):周报口径这一项还没出具过,照服务端的原因写为什么不能出具
issued: [{ run_id: 'run-iss-u', status: 'succeeded', workflow_id: 'wf-orders', version: 1, tier: 'degraded', tier_text: '降档出具', auto: true }] })
const reply = (method, p) => {
@@ -478,6 +480,7 @@ await section('使用者看到的入口照服务端的动作:不能出具的
const shown = await box.waitFor({ timeout: 12000 }).then(() => true, () => false)
const text = shown ? (await box.innerText()).replace(/\s+/g, ' ') : ''
check('使用者也看到摘要', text.includes('3 个数来自口径「周报口径」v2(受管)'), text)
check('……认不出种类的标签不计、不出 NaN', !text.includes('NaN') && !text.includes('undefined'), text)
check('不能出具:照服务端的原因写「请搭建者出具」,不摆出具按钮', text.includes('请搭建者出具')
&& await page.locator('[data-ledger-issue-button]').count() === 0, text)
check('能对账:照样摆对账按钮', await page.locator('[data-ledger-turn-reconcile]').count() === 1)
+81
View File
@@ -1135,6 +1135,87 @@ await section('设置 · 证据裁判', async () => {
await old.close()
})
await section('设置 · 分析(判定用的阈值、次数上限:叫法同契约,整组写 analysis,值不合规不让存,服务端的报错照原话显示)', async () => {
// 读的是真后端补齐缺省之后的一组(有就用它),写走假的一份
let stored = await get('/settings')
if (!stored.analysis) {
check('服务端的设置里有 analysis 一组(分析能力一轮)', false, Object.keys(stored).join(','))
return
}
const saved = { ...stored.analysis }
let reject = null
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
if (reject) return json({ detail: reject }, 422)(route)
stored = { ...stored, ...body.values }
return json(stored)(route)
}],
],
})
const sec = page.locator('[data-analysis-settings]')
await sec.waitFor({ timeout: 8000 }).catch(() => {})
const py = readFileSync(new URL('../backend/app/analysis/contract.py', import.meta.url), 'utf8')
const labels = [...(py.match(/^LABELS: dict\[str, str\] = \{([\s\S]*?)^\}/m)?.[1] ?? '').matchAll(/"([^"]+)":\s*"([^"]+)"/g)]
const text = await sec.innerText().catch(() => '')
check('这一节在偏好设置里,十七项的叫法都是契约 LABELS 的', labels.length === 17 && labels.every(([, , label]) => text.includes(label)),
labels.filter(([, , label]) => !text.includes(label)).map(([, k]) => k).join('、'))
check('……分成四组:变化拆解、波动基线与趋势判断、差异检验、次数与层数',
(await sec.locator('[data-analysis-settings-group]').evaluateAll((els) => els.map((e) => e.dataset.analysisSettingsGroup))).join('、')
=== '变化拆解、波动基线与趋势判断、差异检验、次数与层数')
const val = (k) => page.locator(`[data-analysis-setting="${k}"]`).inputValue()
check('显示的是服务端生效的值', await val('main_share') === String(saved.main_share) && await val('min_history') === String(saved.min_history)
&& await val('max_calls') === String(saved.max_calls), `${await val('main_share')} / ${await val('min_history')} / ${await val('max_calls')}`)
check('……每项下面写着范围(同契约 RANGES)', text.includes('0.3 到 0.9 之间') && text.includes('4 到 104 之间的整数'))
await page.locator('[data-analysis-setting="main_share"]').fill('0.95')
const bar = page.locator('[data-prefs-bar]')
await bar.waitFor({ timeout: 3000 }).catch(() => {})
check('超出范围:当场说(说法同服务端),保存键不能点', (await sec.innerText()).includes('「主要来源」的占比要在 0.3 到 0.9 之间')
&& await bar.getByRole('button', { name: '保存设置' }).isDisabled())
await page.locator('[data-analysis-setting="min_history"]').fill('9.5')
check('整数项填了小数:说要填整数', (await sec.innerText()).includes('至少几期历史要填整数'))
await page.locator('[data-analysis-setting="main_share"]').fill('0.6')
await page.locator('[data-analysis-setting="min_history"]').fill('10')
const barText = await bar.innerText().catch(() => '')
check('保存条写改了哪几项(契约的叫法)', barText.includes('有 2 项未保存') && barText.includes('「主要来源」的占比')
&& barText.includes('至少几期历史'), barText.replace(/\s+/g, ' '))
await bar.getByRole('button', { name: '保存设置' }).click()
await page.waitForTimeout(500)
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values ?? {}
const a = put.analysis ?? {}
check('整组写 analysis(十七项都在,没改的照原值、写成数)', Object.keys(a).length === 17 && a.main_share === 0.6 && a.min_history === 10
&& a.k_warn === saved.k_warn && a.table_top_k === saved.table_top_k, JSON.stringify(a).slice(0, 160))
check('……只改了分析:不碰别的组', Object.keys(put).join(',') === 'analysis', Object.keys(put).join(','))
check('……存完显示「已保存」', (await bar.innerText().catch(() => '')).includes('已保存'))
// 两两之间的大小交给服务端核:「明显超出」不大于「超出常规波动」时 422,原话写在保存条上,草稿留着
reject = '「明显超出」的倍数要大于「超出常规波动」的倍数'
await page.locator('[data-analysis-setting="k_alert"]').fill('1.5')
await bar.getByRole('button', { name: '保存设置' }).click()
await page.waitForTimeout(500)
const failed = await bar.innerText().catch(() => '')
check('服务端拒收:保存条照原话写为什么,草稿还在', failed.includes(reject) && await val('k_alert') === '1.5', failed.replace(/\s+/g, ' '))
reject = null
await page.locator('[data-analysis-setting="k_alert"]').fill(String(stored.analysis.k_alert))
check('改回原值:不再算改动', !(await bar.innerText().catch(() => '')).includes('未保存'))
if (SHOTS) {
await sec.scrollIntoViewIfNeeded()
await page.waitForTimeout(200)
await sec.screenshot({ path: `${SHOTS}/prefs-analysis-${THEME}.png` })
}
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// 老后端没有 analysis 这一组:不显示,也不写
const legacy = { ...stored }
delete legacy.analysis
const old = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(legacy)(route)]] })
check('老后端没有分析设置:这一组不出现', await old.page.locator('[data-analysis-settings]').count() === 0)
await old.close()
})
await section('数据 · 数据库', async () => {
// 上传的表格按来源标记认(origin),不按路径:上传库的路径随版本变
const dbs = sources.filter((s) => s.origin !== 'upload')
+53
View File
@@ -2244,6 +2244,59 @@ await section('证据页签:裁判拆档——证据相矛盾、证据不足
}
})
await section('证据页签:分析说法违规的审计行写种类的叫法;工件页签认得分析结论(分析能力一轮)', async () => {
// 夹具是后端真跑的(evidence-analysis.json 的 agent:Agent 做了变化拆解、波动基线(算不了)、可比性检查,报告有三处说法违规)。
// 审计行由 api/evidence.py 给出:说法违规列在「无证据」一组,issue 是 analysis_claim_mismatch 这几种
const fxa = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-analysis.json', import.meta.url), 'utf8'))
const a = fxa.agent
const EV_A = 'fake0evanalysis00000000000000'
const output = { 周报: a.doc.markdown, _evidence: { report_node: 'write', doc_artifact: a.doc_artifact, fields: ['周报'] }, _issuance: a.issuance }
evFakes(EV_A, { output, graph: a.graph, audit: a.audit })
fakes.set(`GET /api/artifacts/${a.doc_artifact}`, () => ({ status: 200, json: { id: a.doc_artifact, content: a.doc } }))
const results = Object.values(a.doc.catalog).filter((e) => e?.kind === 'analysis')
const at = new Date(Date.now() - 7198e3).toISOString()
fakes.set(`GET /api/runs/${EV_A}/artifacts`, () => ({ status: 200, json: results.map((e) => ({ id: e.artifact, kind: 'analysis_result',
node_id: 'fetch', size: 1800, meta: { op: a.analyses[e.alias].chain[0].op, status: a.analyses[e.alias].chain[0].status }, created_at: at })) }))
try {
await openEvidence(EV_A)
await page.locator('[data-audit-table]').waitFor({ timeout: 5000 }).catch(() => {})
const rows = await page.locator('[data-audit-table] tr[data-audit-row]').evaluateAll((els) => els.map((e) => ({
issue: e.querySelector('[data-audit-issue]')?.getAttribute('data-audit-issue') ?? '', state: e.children[1]?.textContent ?? '',
label: e.querySelector('[data-audit-focus]')?.getAttribute('aria-label') ?? '' })))
const claimed = rows.filter((r) => r.issue.startsWith('analysis_claim') || r.issue === 'causal_unsupported')
check('审计表:三条说法违规各一行,状态写种类的叫法(同正文、面板),字形照「无证据」那一组,不写「无证据」',
JSON.stringify(claimed.map((r) => r.state)) === '["?判断与分析结论不符","?判断词未挂分析结论","?因果说法没有依据"]', JSON.stringify(rows.map((r) => r.state)))
check('……三行都点得开(正文里那段文字画着波浪线、点得开),读屏写种类', claimed.length === 3
&& claimed.every((r) => r.label.includes(',') && !r.label.includes('无证据')), JSON.stringify(claimed.map((r) => r.label)))
check('……别的无证据行照旧写「无证据」(没挂依据的两句结论)', rows.filter((r) => !r.issue && r.state === '?无证据').length === 2,
JSON.stringify(rows.map((r) => `${r.issue}:${r.state}`)))
check('……有出处那一组:判定语、具名的数、结果表的格三行', rows.filter((r) => r.state.includes('有出处')).length === 3, JSON.stringify(rows.map((r) => r.state)))
await page.route(new RegExp(`/api/runs/${EV_A}/evidence/segments/`), (r) => {
const sid = decodeURIComponent(new URL(r.request().url()).pathname.split('/').pop())
return a.segments[sid] ? r.fulfill({ json: a.segments[sid] }) : r.fulfill({ status: 404, json: { detail: '没有这个片段' } })
})
await page.locator('[data-audit-table] [data-audit-open="s12"]').click()
const dock = page.locator('[data-evidence-dock] [data-evidence-panel]')
await dock.locator('[data-ev-claim-flags]').waitFor({ timeout: 5000 }).catch(() => {})
check('点说法违规那一行:右边打开那段文字,写种类和服务端的原话', (await dock.locator('[data-ev-claim-flag="analysis_claim_mismatch"]').innerText()
.catch(() => '')).includes('与所挂的分析结论 A1 对不上'))
await page.keyboard.press('Escape')
await page.goto(`${WEB}/runs/${EV_A}?view=artifacts`, { waitUntil: 'networkidle' })
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor({ timeout: 5000 }).catch(() => {})
const shown = await page.locator('[data-run-artifacts] [data-artifact-id]').evaluateAll((els) => els.map((e) => ({
kind: e.querySelector('[data-artifact-kind-label]')?.textContent ?? '', about: e.querySelector('[data-artifact-about]')?.textContent ?? '',
group: e.getAttribute('data-artifact-class') })))
check('工件页签:分析结论写种类的叫法、哪一种分析,算不了的写「没有做成」,归在证据快照一组',
JSON.stringify(shown) === JSON.stringify([{ kind: '分析结论', about: '变化拆解', group: 'evidence' },
{ kind: '分析结论', about: '波动基线(没有做成)', group: 'evidence' }, { kind: '分析结论', about: '可比性检查', group: 'evidence' }]),
JSON.stringify(shown))
} finally {
for (const k of [...fakes.keys()]) if (k.includes(EV_A) || k.includes(a.doc_artifact)) fakes.delete(k)
await page.unroute(new RegExp(`/api/runs/${EV_A}/evidence/segments/`)).catch(() => {})
}
})
await section('证据页签:没有报告的运行照实说明(none / 旧运行猜测 / 老后端)', async () => {
evFakes(EV_LEGACY, { output: fxe.legacy.output, graph: fxe.legacy.graph, audit: fxe.legacy.audit, report: false })
await openEvidence(EV_LEGACY)
+81
View File
@@ -25,7 +25,10 @@ const check = (name, cond, detail = '') => {
* 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,接着跑下一节,
* 不让一处卡住把后面的检查一起吞掉。各节自己开页面
*/
// STREAM_ONLY=分析,出具横幅 只跑段名里含这些字的段(逗号分隔),改一处时不用整本跑;check-all 不会把它传下来
const ONLY = (process.env.STREAM_ONLY ?? '').split(',').filter(Boolean)
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${name} ===`)
try {
await fn()
@@ -735,6 +738,84 @@ await section('合并查询:输入、合并 SQL、结果预览,警告各占
await narrow.page.close()
})
await section('分析:Agent 的分析工具、分析卡的每一项一行,判定语在副标题,展开看判定语、假设和参数;算不了、调用有误照实说', async () => {
// 用例 analysis(dev/fixtures/analysis.tsx):两轮真跑出来的事件(fixtures.json 的 analysis_agent、analysis_card)
const { page, errors } = await open('syn=analysis', { w: 1100, h: 1000, name: 'analysis' })
const rows = page.locator('[data-step-kind="analysis"]')
await rows.first().waitFor({ timeout: 8000 }).catch(() => {})
// 没成的那一行图标位上是失败的标记(同别的步骤),其余五行是分析的图标
check('六行分析(Agent 四次调用、分析卡两项),没成的那行之外都是分析的图标', await rows.count() === 6
&& await page.locator('[data-step-kind="analysis"]:not([data-step-status="failed"]) svg.lucide-chart-column-stacked').count() === 5,
String(await rows.count()))
const line = async (i) => (await rows.nth(i).innerText().catch(() => '')).replace(/\s+/g, ' ')
check('做成的一行:标题写算子的叫法和编号,副标题是判定语', (await line(1)).includes('变化拆解(可加) · A1')
&& (await line(1)).includes('主要来自华东(-300,占 88.2%)'), await line(1))
check('……标题里不出现工具名 analysis_decompose', !(await page.locator('[data-turn]').first().innerText()).includes('analysis_decompose'))
await rows.nth(1).locator('button').first().click()
await page.waitForTimeout(200)
const open1 = rows.nth(1)
check('展开:判定语完整写出、Agent 写的假设另列', (await open1.locator('[data-step-analysis-phrase]').innerText().catch(() => ''))
.startsWith('销售额下降 340') && (await open1.locator('[data-step-analysis-hypothesis]').innerText().catch(() => '')) === '下降是不是集中在某个地区')
check('……参数(没填的不列)、结论工件可点开', (await open1.innerText()).includes('参数') && !(await open1.innerText()).includes('null')
&& await open1.getByText(/查看完整证据/).count() === 1)
const wrong = rows.nth(0)
check('调用有误:这一次没成,副标题写没有记成结论,下一步写在行下面', (await wrong.getAttribute('data-step-status')) === 'failed'
&& (await line(0)).includes('调用有误,没有记成结论')
&& (await wrong.locator('[data-step-next]').innerText().catch(() => '')).includes('Agent 会按这条说明改正参数'), await line(0))
await wrong.locator('button').first().click()
await page.waitForTimeout(150)
check('……展开看服务端的原话', (await wrong.innerText()).includes('Q2 里没有「sales」这一列'))
const refused = rows.nth(2)
check('算不了:提醒色(不是失败),判定语就是原因,下一步说照实写原因', (await refused.getAttribute('data-step-status')) === 'done'
&& (await line(2)).includes('波动基线没有做成:只有 0 期历史,至少要 8 期')
&& (await refused.locator('[data-step-next]').innerText().catch(() => '')).includes('算不了也是一条结论'), await line(2))
const card = await line(5)
check('分析卡的一项:标题写叫法和这一项的标识,副标题是判定语', card.includes('变化拆解(可加) · where') && card.includes('主要来自华东'), card)
const body = await page.locator('body').innerText()
check('没有「未识别的事件」,「没有做成」的提醒不另起一行', !body.includes('未识别的事件') && !body.includes('analysis_baseline 没有做成'))
check('报告的分析说法:一行说几处、已标注,下一步写怎么改', body.includes('报告有 3 处分析说法未通过核对,已在报告中标注'))
const banner = page.locator('[data-issuance-banner]').first()
const claims = (await banner.locator('[data-issuance-analysis-claims]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('出具横幅:check 的分析说法单列一块,写不影响档位,三处各一条(种类的叫法同审计)', claims.includes('3 处分析说法未通过核对(已标注,不影响出具档位)')
&& claims.includes('「主要来自」') && claims.includes('判断与分析结论不符') && claims.includes('因果说法没有依据'), claims)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await page.close()
const narrow = await open('syn=analysis&dense=1', { w: 380, h: 900 })
await narrow.page.locator('[data-step-kind="analysis"] button').nth(1).click()
await narrow.page.waitForTimeout(200)
const overflow = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('窄栏展开分析那一行:判定语、参数都不撑出横向滚动', overflow <= 0, `${overflow}px`)
await narrow.page.close()
const team = await open('syn=analysis-team', { w: 1100, h: 900 })
const tl = (await team.page.locator('[data-step-kind="analysis"]').first().innerText().catch(() => '')).replace(/\s+/g, ' ')
check('协作成员的分析:编号是成员自己的,副标题接着写是谁调用的', tl.includes('变化拆解(可加) · A1') && tl.includes('分析员 调用'), tl)
await team.page.close()
})
await section('出具横幅:分析说法(strict 记缺口)、指标的两期可比性、追不到原始数据的指标各列一块', async () => {
for (const [w, label] of [[1100, '宽栏'], [390, '390px']]) {
const { page, errors } = await open('syn=analysis-issued', { w, h: 900, name: w === 1100 ? 'analysis-issued' : undefined })
const banner = page.locator('[data-issuance-banner=degraded]')
await banner.waitFor({ timeout: 8000 }).catch(() => {})
const claims = (await banner.locator('[data-issuance-analysis-claims="counted"]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check(`${label}:strict 的分析说法写「已计入出具缺口」,原话照服务端`, claims.includes('1 处分析说法未通过核对,已计入出具缺口')
&& claims.includes('「主要来自华南」与所挂的分析结论 A1 对不上'), claims)
const comp = (await banner.locator('[data-issuance-comparability]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check(`${label}:指标的两期可比性一条一个,判定语和原因照服务端`, comp.includes('指标的两期可比性')
&& comp.includes('指标「环比」:两期不可比') && comp.includes('指标「月环比」:两期有条件可比')
&& await banner.locator('[data-issuance-comparability-item]').count() === 2, comp)
const untraced = (await banner.locator('[data-issuance-untraced="counted"]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check(`${label}:追不到原始数据的指标写追到哪为止、已计入缺口`, untraced.includes('报告引用的指标未追到原始数据(已计入出具缺口)')
&& untraced.includes('指标「客单价」') && untraced.includes('vars.aov 的出处追到「estimate」为止'), untraced)
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check(`${label}:页面不横向溢出`, over <= 0, `${over}px`)
check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | '))
await page.close()
}
})
await section('并行查询各配各的;查询的 SQL 检查;指标的问题指到来源查询的 SQL(A1、B2)', async () => {
const { page, errors } = await open('syn=parallel&link=1', { w: 1100, h: 900, name: 'parallel' })
const qa = page.locator('[data-node-id="q_a"]').filter({ hasText: 'SQL 检查:1 处错误' }).last()
+226
View File
@@ -131,6 +131,31 @@ const MERGE_GRAPH = {
edge('q_visits', 'merge'), edge('think', 'merge'), edge('merge', 'done')],
}
// 分析卡(分析能力一轮):整体周销售、按地区两周各查一次,分析卡做两项(波动基线、按地区的变化拆解,带总数核对),报告引用它的
// 结论。上游另有一个模型调用节点,检查器的输入下拉里不该有它。全是虚构零售库的通用名
const TOTALS_SQL = 'SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week'
const REGION_SQL = 'SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week'
const ANALYSIS_GRAPH = {
nodes: [
node('start', 'input', 0, 200, '输入', { fields: [{ name: 'week', required: true }] }),
node('weekly', 'tool', 300, 60, '整体周销售', { tool: 'db_query__shop', args: { sql: TOTALS_SQL } }),
node('by_region', 'tool', 300, 220, '按地区两周', { tool: 'db_query__shop', args: { sql: REGION_SQL } }),
node('think', 'llm', 300, 400, '先想一想', { prompt: '{{ input.week }}' }),
node('card', 'analysis', 640, 200, '异动分析', {
items: [
{ id: 'wow', op: 'baseline', from: 'weekly', params: { period: 'week', measure: 'gmv', label: '销售额' } },
{ id: 'where', op: 'decompose', mode: 'additive', from: 'by_region', hypothesis: '下降是不是集中在某个地区',
params: { by: ['region'], period: 'week', base: '2026-W41', target: '2026-W42', measure: 'gmv',
headline: { base: 'weekly.r0.gmv', target: 'weekly.r1.gmv' }, label: '销售额' } },
],
}),
node('write', 'report', 980, 200, '写周报', { instructions: '写本周销售的异动' }),
node('done', 'output', 1300, 200, '成果', { fields: [{ name: 'answer', value: '{{ nodes.write.text }}' }] }),
],
edges: [edge('start', 'weekly'), edge('start', 'by_region'), edge('start', 'think'), edge('weekly', 'card'),
edge('by_region', 'card'), edge('think', 'card'), edge('card', 'write'), edge('write', 'done')],
}
const wf = (id, extra) => ({
id, name: extra.name, description: extra.description ?? '检查脚本伪造的工作流', graph: extra.graph ?? GRAPH,
tags: extra.tags ?? [], version: extra.version ?? 3, is_template: !!extra.is_template,
@@ -147,6 +172,7 @@ const FAKES = {
'st-lint': wf('st-lint', { name: '__studio_check_lint__', graph: TEAM_GRAPH, version: 1 }),
'st-ev': wf('st-ev', { name: '__studio_check_ev__', graph: EV_GRAPH, version: 2 }),
'st-merge': wf('st-merge', { name: '__studio_check_merge__', graph: MERGE_GRAPH, version: 1 }),
'st-analysis': wf('st-analysis', { name: '__studio_check_analysis__', graph: ANALYSIS_GRAPH, version: 1 }),
}
const VERSIONS = [
{ id: 'v3', version: 3, note: '', created_at: '2026-09-26T02:00:00Z' },
@@ -3142,6 +3168,206 @@ await section('受管工作流在画布上改过之后:页头、版本历史
}
})
// ================================================================ 分析卡
await section('分析卡:叫法和服务端契约一字不差(算子、模式、阈值、字段、报告节点两个新字段的取值)', async () => {
// 叫法只有服务端契约一份(analysis/contract.py 的 OP_LABELS / MODE_LABELS / LABELS,engine/labels.py 引用它们):界面上的
// 字和校验报错里的字不一样,人就找不到报错说的是哪一项。不开浏览器也能比,terms 借页面的模块加载
const py = readFileSync(new URL('../backend/app/analysis/contract.py', import.meta.url), 'utf8')
const dict = (name) => Object.fromEntries([...(py.match(new RegExp(`^${name}: dict\\[str, [^\\]]+\\] = \\{([\\s\\S]*?)^\\}`, 'm'))?.[1] ?? '')
.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
const ranges = Object.fromEntries([...(py.match(/^RANGES: dict\[[^\]]+\]\] = \{([\s\S]*?)^\}/m)?.[1] ?? '')
.matchAll(/"([^"]+)":\s*\((float|int),\s*([\d._]+),\s*([\d._]+)\)/g)].map((m) => [m[1], [m[2], Number(m[3].replace(/_/g, '')), Number(m[4].replace(/_/g, ''))]]))
const labelsPy = readFileSync(new URL('../backend/app/engine/labels.py', import.meta.url), 'utf8')
const fieldBlock = labelsPy.match(/"analysis": \{([^}]*)\}/)?.[1] ?? ''
const fields = Object.fromEntries([...fieldBlock.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
const opt = (key) => Object.fromEntries([...(labelsPy.match(new RegExp(`"${key}": \\{([^}]*)\\}`))?.[1] ?? '')
.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
const top = Object.fromEntries([...(labelsPy.match(/^FIELD_LABEL: dict\[str, str\] = \{([\s\S]*?)^\}/m)?.[1] ?? '')
.matchAll(/"(analysis_claims|analysis_scope)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
const { ctx, page } = await open({ path: '/studio/st-analysis' })
const t = await page.evaluate(async () => {
const m = await window.__appImport('/src/lib/termsAnalysis.ts')
return { op: m.ANALYSIS_OP_LABEL, mode: m.ANALYSIS_MODE_LABEL, setting: m.ANALYSIS_SETTING_LABEL, range: m.ANALYSIS_SETTING_RANGE,
field: m.ANALYSIS_FIELD_LABEL, option: m.ANALYSIS_OPTION_LABEL, keys: m.ANALYSIS_SETTING_KEYS }
})
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort())
const diff = (a, b) => Object.keys({ ...a, ...b }).filter((k) => a[k] !== b[k]).map((k) => `${k}:${a[k]} ≠ ${b[k]}`).join(';')
check('从 contract.py 读出了 OP_LABELS、MODE_LABELS、LABELS、RANGES', Object.keys(dict('OP_LABELS')).length === 5
&& Object.keys(dict('MODE_LABELS')).length === 6 && Object.keys(dict('LABELS')).length >= 17 && Object.keys(ranges).length >= 17)
check('算子的叫法和 OP_LABELS 一字不差', same(t.op, dict('OP_LABELS')), diff(t.op, dict('OP_LABELS')))
check('模式的叫法和 MODE_LABELS 一字不差', same(t.mode, dict('MODE_LABELS')), diff(t.mode, dict('MODE_LABELS')))
check('阈值的叫法和 LABELS 一字不差、键的先后同 DEFAULTS', same(t.setting, dict('LABELS'))
&& t.keys.join(',') === Object.keys(dict('LABELS')).join(','), diff(t.setting, dict('LABELS')))
const r = Object.fromEntries(Object.entries(t.range).map(([k, v]) => [k, v.join(',')]))
const rp = Object.fromEntries(Object.entries(ranges).map(([k, v]) => [k, v.join(',')]))
check('阈值的范围和 RANGES 一致', same(r, rp), diff(r, rp))
check('分析卡字段的叫法同 engine/labels.py(分析项、阈值覆盖)', t.field.items === fields.items && t.field.overrides === fields.overrides,
JSON.stringify(fields))
check('报告节点两个新字段的叫法同 FIELD_LABEL', t.field.analysis_claims === top.analysis_claims && t.field.analysis_scope === top.analysis_scope,
JSON.stringify(top))
check('……取值的短说法同 _OPTION_LABEL', same(t.option.analysis_claims, opt('analysis_claims')) && same(t.option.analysis_scope, opt('analysis_scope')),
`${JSON.stringify(opt('analysis_claims'))} ${JSON.stringify(opt('analysis_scope'))}`)
await ctx.close()
})
await section('分析卡:节点库认得、卡片摘要、检查器按方法摆参数格、只列上游查询节点、阈值覆盖、服务端校验落到那一项、定位、连线带出输入', async () => {
const { page, errors } = await open({ path: '/studio/st-analysis' })
const entry = page.locator('[aria-label="节点库"] button[data-node-type="analysis"]')
check('节点库里有「分析卡」', await entry.count() === 1 && (await entry.innerText()).includes('分析卡'))
const card = page.locator('.react-flow__node[data-id="card"]')
const summary = (await card.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('卡片摘要写做哪几种分析(契约的叫法)', summary.includes('波动基线、变化拆解(可加)'), summary.slice(0, 80))
const tint = await card.evaluate((el) => getComputedStyle(el.querySelector('.nt-analysis') ?? el).getPropertyValue('--nt').trim()).catch(() => '')
check('分析卡有自己的类型色(--nt-analysis)', !!tint, tint)
await S(page, () => window.__studio.getState().select('card'))
const box = page.locator('[data-inspector-sheet] [data-field="items"]')
await box.waitFor({ timeout: 4000 }).catch(() => {})
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((th) => document.documentElement.setAttribute('data-theme', th), theme)
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/studio-analysis-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
const items = box.locator('[data-item]')
check('两项各一行,标识 wow、where', await items.count() === 2
&& (await items.nth(0).locator('[data-sub="id"] input').inputValue()) === 'wow'
&& (await items.nth(1).locator('[data-sub="id"] input').inputValue()) === 'where')
const ops = await items.nth(0).locator('[data-sub="op"] option:not([disabled])').allInnerTexts()
check('分析方法下拉是五个算子的叫法', ops.join('、') === '变化拆解、波动基线、趋势判断、差异检验、可比性检查', ops.join('、'))
check('波动基线没有模式;变化拆解有模式(可加 / 比率 / 乘积)', await items.nth(0).locator('[data-sub="mode"]').count() === 0
&& (await items.nth(1).locator('[data-sub="mode"] option:not([disabled])').allInnerTexts()).join('、') === '可加、比率、乘积')
const from = await items.nth(1).locator('[data-sub="from"] option:not([disabled])').evaluateAll((els) => els.map((e) => e.value))
check('输入只列上游的查询节点,不列模型调用', from.sort().join(',') === 'by_region,weekly', from.join(','))
const params = await items.nth(1).locator('[data-analysis-params] [data-sub]').evaluateAll((els) => els.map((e) => e.dataset.sub))
check('可加拆解摆维度列、期次、基期、本期、度量列、度量的叫法', params.join(',') === 'params.by,params.period,params.base,params.target,params.measure,params.label',
params.join(','))
check('……多列用顿号写', (await items.nth(1).locator('[data-sub="params.by"] input').inputValue()) === 'region')
check('……总数核对两格照原样', (await items.nth(1).locator('[data-sub="params.headline.base"] input').inputValue()) === 'weekly.r0.gmv'
&& (await items.nth(1).locator('[data-sub="params.headline.target"] input').inputValue()) === 'weekly.r1.gmv')
check('……在验证什么写在这一项上', (await items.nth(1).locator('[data-sub="hypothesis"] input').inputValue()) === '下降是不是集中在某个地区')
check('下游取判定的写法写在标识下面', (await items.nth(0).innerText()).includes('nodes.card.results.wow.verdict'))
const config = () => S(page, () => JSON.stringify(window.__studio.getState().nodes.find((n) => n.id === 'card')?.data.config))
const cfg = async () => JSON.parse(await config())
// 改模式:参数只留这种模式用得到的(度量列去掉,补分子、分母两格)
await items.nth(1).locator('[data-sub="mode"] select').selectOption('ratio')
await page.waitForTimeout(150)
const ratio = (await cfg()).items[1]
check('换成比率模式:度量列去掉,参数格换成分子、分母', ratio.mode === 'ratio' && !('measure' in ratio.params)
&& await items.nth(1).locator('[data-sub="params.numerator"]').count() === 1, JSON.stringify(ratio.params))
await items.nth(1).locator('[data-sub="params.numerator"] input').fill('refunds')
await items.nth(1).locator('[data-sub="params.by"] input').fill('region、channel')
await page.waitForTimeout(150)
const typed = (await cfg()).items[1].params
check('……填的列写回配置(几列写成列表)', typed.numerator === 'refunds' && JSON.stringify(typed.by) === '["region","channel"]', JSON.stringify(typed))
// 换分析方法:基线换成趋势判断,基线才有的参数去掉
await items.nth(0).locator('[data-sub="op"] select').selectOption('trend')
await page.waitForTimeout(150)
const trend = (await cfg()).items[0]
check('换成趋势判断:留下期次列、度量列、叫法,输入不动', trend.op === 'trend' && trend.from === 'weekly'
&& Object.keys(trend.params).sort().join(',') === 'label,measure,period', JSON.stringify(trend))
// 两份查询:基期、本期各选一个查询节点,期次三格不写
await items.nth(1).locator('[data-analysis-two]').check()
await page.waitForTimeout(150)
const two = (await cfg()).items[1]
check('勾「基期、本期是两份查询」:输入挪到基期查询,期次三格去掉', two.params.base_query === 'by_region' && !two.from
&& !('period' in two.params) && await items.nth(1).locator('[data-sub="params.target_query"] select').count() === 1, JSON.stringify(two))
await items.nth(1).locator('[data-analysis-two]').uncheck()
await page.waitForTimeout(150)
check('……取消勾选:基期查询回到输入', (await cfg()).items[1].from === 'by_region')
// 添加一项:新标识不重名;变化拆解才能下钻,下钻的上一层只列排在前面的变化拆解
await box.locator('[data-analysis-add]').click()
await page.waitForTimeout(150)
const added = (await cfg()).items
check('添加分析项:标识 a3,还没选分析方法', added.length === 3 && added[2].id === 'a3' && !added[2].op, JSON.stringify(added[2]))
await items.nth(2).locator('[data-sub="op"] select').selectOption('decompose')
await page.waitForTimeout(150)
const parentOpts = await items.nth(2).locator('[data-sub="params.parent"] select option').evaluateAll((els) => els.map((e) => e.value))
check('……选了变化拆解:下钻的上一层可以选前面那一项拆解(where)', parentOpts.includes('where') && !parentOpts.includes('wow'), parentOpts.join(','))
await items.nth(2).locator('[data-sub="params.parent"] select').selectOption('where')
await items.nth(2).locator('[data-sub="params.parent"] input').fill('华东')
await page.waitForTimeout(150)
check('……下钻写成上一层的标识和那一段的维度值', JSON.stringify((await cfg()).items[2].params.parent) === '{"of":"where","key":["华东"]}',
JSON.stringify((await cfg()).items[2].params))
await items.nth(2).locator('[data-sub="id"] input').fill('wow')
await page.waitForTimeout(150)
check('标识和另一项重复:当场说明', (await items.nth(2).innerText()).includes('与另一项重复'))
await items.nth(2).locator('[data-sub="id"] input').fill('east')
// 阈值覆盖:从没写的里面选一项,范围当场说
const ov = page.locator('[data-inspector-sheet] [data-field="overrides"]')
check('没写覆盖时写明全部跟随设置', (await ov.innerText()).includes('跟随设置'))
await ov.locator('[data-analysis-add-override]').selectOption('main_share')
await page.waitForTimeout(150)
await ov.locator('[data-analysis-override="main_share"]').fill('0.95')
await page.waitForTimeout(150)
check('……超出范围当场说(说法同服务端)', (await ov.innerText()).includes('「主要来源」的占比要在 0.3 到 0.9 之间'), (await ov.innerText()).slice(0, 120))
await ov.locator('[data-analysis-override="main_share"]').fill('0.6')
await page.waitForTimeout(150)
check('……填对了写回成数', JSON.stringify((await cfg()).overrides) === '{"main_share":0.6}', JSON.stringify((await cfg()).overrides))
await ov.getByRole('button', { name: '删除阈值覆盖「「主要来源」的占比」' }).click()
await page.waitForTimeout(150)
check('……删掉最后一项:配置里不留空对象', (await cfg()).overrides === undefined, String((await cfg()).overrides))
// 服务端校验:参数里写了阈值、下钻的上一层排在后面——问题落在那一项的那一格下面(校验是真的服务端算的)
await S(page, () => {
const st = window.__studio.getState()
const n = st.nodes.find((x) => x.id === 'card')
const next = n.data.config.items.map((it) => (it.id === 'where' ? { ...it, params: { ...it.params, k_warn: 3 } } : it))
st.updateNode('card', { config: { ...n.data.config, items: next } })
})
await S(page, () => window.__studio.getState().validate())
await page.waitForTimeout(800)
const whereText = await items.nth(1).innerText()
check('参数里写了阈值:服务端的说法落在这一项下面', whereText.includes('阈值不写在分析项里'), whereText.slice(-160))
const r = await page.evaluate(async () => {
const m = await window.__appImport('/src/canvas/InspectorSheet.tsx')
window.__studio.getState().select(null)
await new Promise((res) => setTimeout(res, 100))
m.revealField('card', 'items[1].params.headline.target', { focus: true })
await new Promise((res) => setTimeout(res, 600))
const a = document.activeElement
return { at: a?.closest('[data-sub]')?.dataset.sub ?? null, value: (a && 'value' in a) ? a.value : null }
}).catch((e) => ({ error: e.message.split('\n')[0] }))
check('问题落在 items[1].params.headline.target:定位到那一格、光标落进去', r.at === 'params.headline.target' && r.value === 'weekly.r1.gmv',
JSON.stringify(r))
const r2 = await page.evaluate(async () => {
const m = await window.__appImport('/src/canvas/InspectorSheet.tsx')
m.revealField('card', 'items[2].params.parent.key', { focus: true })
await new Promise((res) => setTimeout(res, 600))
return document.activeElement?.closest('[data-sub]')?.dataset.sub ?? null
}).catch((e) => e.message.split('\n')[0])
check('……落到没有自己一格的子键(params.parent.key):退到所在的那一块', r2 === 'params.parent', String(r2))
// 报告节点:分析说法核对缺省是「重写一次后标出」,分析范围在高级选项里
await S(page, () => window.__studio.getState().select('write'))
await page.waitForSelector('[data-inspector-sheet] [data-field="analysis_claims"]', { timeout: 4000 }).catch(() => {})
const claims = page.locator('[data-inspector-sheet] [data-field="analysis_claims"] select')
const shown = await claims.evaluate((el) => el.options[el.selectedIndex]?.textContent ?? '').catch(() => '')
check('报告节点「分析说法核对」没写时显示缺省(重写一次后标出)', shown.startsWith('重写一次后标出'), shown)
const opts = await claims.locator('option').allInnerTexts().catch(() => [])
check('……三个取值以服务端的短说法开头', opts.length === 3 && opts[1].startsWith('重写后仍有的计入缺口') && opts[2] === '不核对', opts.join(' | '))
// 连线带出输入:查询节点连到新拖进来的分析卡,第一项(还没选输入的)选上它;模型调用不算
const mid = await S(page, () => {
window.__studio.getState().addNode('analysis', { x: 640, y: 620 })
return window.__studio.getState().selectedId
})
const connect = (a, b) => S(page, ([x, y]) => window.__studio.getState().onConnect({ source: x, target: y, sourceHandle: 'out', targetHandle: null }), [a, b])
const firstFrom = () => S(page, (x) => window.__studio.getState().nodes.find((n) => n.id === x)?.data.config.items?.[0]?.from ?? '', mid)
await connect('think', mid)
check('新拖进来的分析卡带一项空的;模型调用连进来不算输入', await firstFrom() === '')
await connect('weekly', mid)
check('……查询节点连进来:那一项的输入选上它', await firstFrom() === 'weekly', await firstFrom())
await S(page, () => window.__studio.getState().undo())
check('……撤销连线时一起退回', await firstFrom() === '')
check('全程页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
})
check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`)
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))