mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
合入 W2-c 前端:分析卡节点与分析项编辑器、报告节点两个新字段、设置「分析」一节、问数据时间线的分析步骤与出具横幅三项、运行对比的分析卡一区、证据面板与正文接上分析结论(六步出处链、闭合核对、两期可比性、说法违规标注、分析范围)、可验证报告核对页与分析样本页、口径账本摘要防 NaN;服务端补分析工具事件里的判定与分析结论的单独出处接口
# Conflicts: # backend/tests/test_eval_schedule.py
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 各占一行,这一行只说有几条
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('、'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user