结论句裁判改造:「不支持」拆成「证据相矛盾」和「证据不足」,补全交给裁判的证据,旧判定可按新规则重判

- 判定取值改为:支持 / 部分支持 / 证据相矛盾 / 证据不足 / 不是结论。只有证据和句子冲突才判相矛盾;
  证据里没有相关信息判证据不足,并写明缺什么(比如 SQL、字段清单)
- 出具判档不变松:证据相矛盾按 on_unsupported 降档或不予出具;证据不足记一条缺口,最多降档、不能判完整出具;
  改造前的「证据不支持」照旧当相矛盾处理
- 补全证据摘录:表名附字段清单和类型(最多 60 个,快照不全时注明,遮罩列不列);口径卡指标附上每个输入
  取自哪次查询、哪一格,以及那次查询的 SQL;一律只读封存范围内的工件
- 理由上限从 60 字放宽到 120 字,在句子边界截断,不再截在半句中间
- 裁判模型:按需裁判也判断是否和写作模型相同、是否在价格表里,证据面板里醒目提示;
  设置页没配裁判模型时写明「未单独配置,将使用助手的模型」
- 新判定带规则版本:改造前的判定在界面上标「按旧规则判定」,可以请模型按当前规则重新判断
- 界面:证据相矛盾用红色;证据不足用中性色和 ○,写明缺什么;讲方法、讲结构的句子在证据面板里直接列出
  对应的 SQL 和字段清单;出具横幅、审计表、报告节点印章的计数都分开这两档
- 顺带写稳 check-publish 里一条等按钮状态的断言(并行时偶发)
This commit is contained in:
ThearchyHelios
2026-09-30 10:04:52 +08:00
parent 03c18c4d78
commit bd8306bf95
36 changed files with 11713 additions and 284 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ const ORDER = [
// 拆开的两项看 SPLIT 里各份的 est。2026-09-29 八道并行、关浏览器不干等之后的实测
const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 75, 'ui-kit': 67, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
evidence: 85, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+394 -8
View File
@@ -33,7 +33,10 @@ const fxe = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-
// 第四期:结论句裁判。文档由 compose_doc 真跑,候选句由 judge.prepare 真跑,判定是剧本(模拟裁判模型),
// 经 judge 的 summarize / apply_judgement 写回:正式运行(节点里判好的)、探索运行(按需)、没开裁判的探索运行
const fxj = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-judge.json`, 'utf8'))
const JUDGE_SETS = [fxj.formal, fxj.explore, fxj.plain]
// 裁判拆档(JR):证据相矛盾、证据不足(写明缺什么)、讲方法的句子、裁判模型和写作模型相同、模型不在价格表里。
// 文档由 compose_doc 真跑、证据链由 api/evidence.py 的 _chain 真跑;判定和表名实体步骤的 fields 照前后端接口约定写
const fxv = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-verdict.json`, 'utf8'))
const JUDGE_SETS = [fxj.formal, fxj.explore, fxj.plain, fxv.formal, fxv.explore]
/** 夹具里某段文字是哪个片段:检查按文字认片段,重新生成夹具时编号变了也不用改 */
const segOf = (doc, text) => doc.blocks.flatMap((b) => b.units.flatMap((u) => u.segments)).find((s) => s.text === text)?.id
@@ -61,7 +64,7 @@ const browser = await chromium.launch({ executablePath: CHROME })
* 夹具里按句子备好了答复的用夹具的;别的句子给一条「有依据」。正式运行回 409
*/
function judgeReply(set, body) {
if (set === fxj.formal) return { status: 409, json: { detail: '正式运行的结论句已在报告撰写节点内完成裁判,结果随报告一起封存,不能再按需裁判', code: 'evidence_judge_formal' } }
if (set === fxj.formal || set === fxv.formal) return { status: 409, json: { detail: '正式运行的结论句已在报告撰写节点内完成裁判,结果随报告一起封存,不能再按需裁判', code: 'evidence_judge_formal' } }
const unit = body?.units?.[0]
const named = Object.entries(set.units).find(([, u]) => u === unit)?.[0]
if (named && set.judge?.[named]) return { json: set.judge[named] }
@@ -92,13 +95,20 @@ async function routed(page, { judge = null, segPatch = null } = {}) {
// 四期的报告工件:问数据页的答案(Output → EvidenceField)按工件 id 取文档
const jart = JUDGE_SETS.find((x) => url.pathname === `/api/artifacts/${x.doc_artifact}`)
if (jart) return r.fulfill({ json: { id: jart.doc_artifact, content: jart.doc } })
// 裁判拆档:讲方法的句子在「挂的依据」下列出 SQL,取的是查询快照工件
const vsnap = Object.entries(fxv.snapshots).find(([, x]) => url.pathname === `/api/artifacts/${x.artifact}`)
if (vsnap) {
hits.push(`artifact:V:${vsnap[0]}`)
return r.fulfill({ json: { id: vsnap[1].artifact, content: vsnap[1].content } })
}
if (jset) {
const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (seg) {
const id = decodeURIComponent(seg[1])
hits.push(`j:${id}`)
// 判过之后片段接口按最新的 evidence.judged 叠上判定(封存后追加):夹具里备着判过那一句的答复
const after = jset.segments_after && judged.has(`${jset.run_id}:${jset.units.bad}`) ? jset.segments_after : jset.segments
const trigger = jset.units.bad ?? jset.units.method
const after = jset.segments_after && judged.has(`${jset.run_id}:${trigger}`) ? jset.segments_after : jset.segments
let body = structuredClone(after[id])
if (body && segPatch) body = segPatch(jset, body)
return body ? r.fulfill({ json: body })
@@ -1214,7 +1224,7 @@ await section('studio', '报告撰写节点:画布认得、检查器能配(R
const shown = await p.locator('[data-field="claims"] select').evaluate((el) => el.options[el.selectedIndex]?.textContent ?? '')
check('config 里写着无法识别的值:下拉照实显示,不装成 off', shown.includes('sometimes') && shown.includes('无法识别'), shown)
// 四期:选模型裁判,高级选项里出现结论句裁判的子配置(裁判模型、三项上限各自能设不限、改写一次、证据不支持时)
// 四期:选模型裁判,高级选项里出现结论句裁判的子配置(裁判模型、三项上限各自能设不限、改写一次、证据相矛盾时)
check('没选模型裁判时不出结论句裁判那一组', await p.locator('[data-field="judge"]').count() === 0)
await p.locator('[data-field="claims"] select').selectOption('judge')
check('选「请模型逐句判断」写进 config.claims = judge', (await cfgOf())?.claims === 'judge')
@@ -1223,12 +1233,16 @@ await section('studio', '报告撰写节点:画布认得、检查器能配(R
const jlabel = await jf.locator('label').first().innerText().catch(() => '')
check('高级选项里出现「结论句裁判」一组(中文标签)', await jf.count() === 1 && jlabel.includes('结论句裁判'), jlabel)
const keys = await jf.locator('[data-judge-key]').evaluateAll((els) => els.map((e) => e.getAttribute('data-judge-key')))
check('……裁判模型、三项上限、改写一次、证据不支持时', keys.join(',') === 'model,max_claims,max_cost_usd,timeout_s,rewrite_once,on_unsupported',
check('……裁判模型、三项上限、改写一次、证据相矛盾时', keys.join(',') === 'model,max_claims,max_cost_usd,timeout_s,rewrite_once,on_unsupported',
keys.join(','))
const labels = await jf.innerText()
// 细节只报缺了哪几个:整段文字里有沙箱的接入名(下拉的选项),日志常被贴进报告
const missingLabels = ['裁判模型', '最多裁判句数', '金额上限(美元)', '时长上限(秒)', '退回改写一次', '证据不支持时'].filter((t) => !labels.includes(t))
// 细节只报缺了哪几个:整段文字里有沙箱的接入名(下拉的选项),日志常被贴进报告。裁判拆档后 on_unsupported
// 管的是证据相矛盾(键名不变),标签跟着改
const missingLabels = ['裁判模型', '最多裁判句数', '金额上限(美元)', '时长上限(秒)', '退回改写一次', '证据相矛盾时'].filter((t) => !labels.includes(t))
check('……每一项都有中文标签', !missingLabels.length, `缺:${missingLabels.join('、')}`)
const onHint = await jf.locator('[data-judge-key="on_unsupported"] [data-judge-hint]').innerText().catch(() => '')
check('……「证据相矛盾时」下面写明证据不足的句子最多降档、不会因此不予出具', onHint.includes('证据不足的句子最多降档')
&& onHint.includes('不会因此不予出具'), onHint)
check('……没写的上限说明跟着设置里的默认值', (await jf.locator('[data-judge-key="max_cost_usd"] [data-judge-note]').innerText()).includes('默认值'))
const cost = jf.locator('[data-judge-key="max_cost_usd"]')
await cost.locator('input[type="checkbox"]').click()
@@ -1277,7 +1291,7 @@ await section('studio', '报告撰写节点:画布认得、检查器能配(R
check('发布前修复的预览:judge.max_cost_usd 写成「结论句裁判 · 金额上限(美元)」', previews[0] === '结论句裁判 · 金额上限(美元)', previews[0])
check('……null 写「不限」(没写才是「(空)」)、金额带单位', previews[1] === '不限' && previews[2] === '0.05 美元', JSON.stringify(previews.slice(1, 3)))
check('……补预算按整份 judge 记时一键一行', previews[3] === '裁判模型:x;金额上限(美元):不限', previews[3])
check('……证据不支持时写选项文字', previews[4] === '不予出具', previews[4])
check('……证据相矛盾时写选项文字', previews[4] === '不予出具', previews[4])
check('……别处的 model 不借用裁判的叫法', previews[5] === '工作流默认设置 · model', previews[5])
if (SHOTS) {
await jf.scrollIntoViewIfNeeded()
@@ -2108,6 +2122,378 @@ await section('j-stream', '四期:问数据页、画布右栏的横幅(Outpu
await s.ctx.close()
})
// ---------------------------------------------------------------------------
// 裁判拆档(JR):证据相矛盾 / 证据不足、缺什么、裁判模型的两条提醒、理由 120 字、字段清单、讲方法的句子
// ---------------------------------------------------------------------------
const V = '#evidence-verdict'
const VN = '#evidence-verdict-narrow'
const VX = '#evidence-verdict-explore'
/** 一组句末徽标的样子:判定、字形、计算色、读屏名、悬停说明,外加整句的浅底(没有浅底是 null) */
const badgeLook = (pg, sel, units) => pg.evaluate(({ sel, units }) => Object.fromEntries(Object.entries(units).map(([k, id]) => {
const el = document.querySelector(`${sel} [data-ev-claim="${id}"]`)
if (!el) return [k, null]
const shade = document.querySelector(`${sel} [data-ev-claim-shade="${id}"]`)
return [k, { verdict: el.getAttribute('data-ev-verdict'), glyph: el.textContent, color: getComputedStyle(el).color,
label: el.getAttribute('aria-label'), title: el.getAttribute('title'),
shade: shade ? getComputedStyle(shade).backgroundColor : null }]
})), { sel, units })
const softOf = (pg, token) => tokenColor(pg, 'backgroundColor', `var(${token})`)
const firstNumber = (doc, uid) => doc.blocks.flatMap((b) => b.units).find((x) => x.id === uid).segments.find((s) => s.kind === 'number').id
await section('jr', '裁判拆档:证据相矛盾、证据不足四通道分得开,证据不足不用红、不和未裁判混;n / N 先走相矛盾、再走证据不足', async () => {
const u = fxv.formal.units
await page.waitForSelector(`${V} [data-ev-claim="${u.insuff}"]`, { timeout: 4000 }).catch(() => {})
const look = await badgeLook(page, V, u)
check('新取值各挂各的徽标:证据相矛盾、证据不足(讲方法的那句也判成证据相矛盾)',
look.contra?.verdict === 'contradicted' && look.insuff?.verdict === 'insufficient' && look.method?.verdict === 'contradicted',
JSON.stringify(Object.fromEntries(Object.entries(look).map(([k, v]) => [k, v?.verdict]))))
check('字形:证据相矛盾 !、证据不足 ○,和未裁判的 ? 分得开', look.contra?.glyph === '!' && look.insuff?.glyph === '○'
&& look.limit?.glyph === '?' && look.insuff.glyph !== look.limit.glyph,
[look.contra?.glyph, look.insuff?.glyph, look.limit?.glyph].join(' '))
const [failed, waiting, neutral, done] = await Promise.all(['--st-failed', '--st-waiting', '--st-cancelled', '--st-done']
.map((t) => colorOf(page, t)))
check('颜色令牌:证据相矛盾 --st-failed(同原来的证据不支持),证据不足 --st-cancelled(中性)',
look.contra?.color === failed && look.method?.color === failed && look.insuff?.color === neutral,
JSON.stringify({ got: [look.contra?.color, look.insuff?.color], want: [failed, neutral] }))
check('证据不足不用红,也不用提醒色和确定性的绿', !!look.insuff && ![failed, waiting, done].includes(look.insuff.color), look.insuff?.color)
const soft = await softOf(page, '--st-failed-soft')
check('证据相矛盾的整句铺浅底(--st-failed-soft)', look.contra?.shade === soft && look.method?.shade === soft && soft !== 'rgba(0, 0, 0, 0)',
JSON.stringify({ contra: look.contra?.shade, method: look.method?.shade, soft }))
check('证据不足不铺底(中性样式)', look.insuff?.shade === null, String(look.insuff?.shade))
check('读屏名:证据相矛盾', look.contra?.label === '结论句「各渠道的订单数相加与总订单数一致。」,模型判断:证据相矛盾', look.contra?.label)
check('读屏名:证据不足接着说缺什么', look.insuff?.label === `结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不足,缺少:${fxv.missing}`,
look.insuff?.label)
check('悬停说明:证据不足写明谁判的、非确定、缺什么', look.insuff?.title === `模型判断:证据不足(${fxv.model} · 非确定)。缺少:${fxv.missing}`,
look.insuff?.title)
const tally = await page.locator(`${V} [data-evidence-claims]`).innerText().catch(() => '')
check('证据条的结论句计数带上证据相矛盾、证据不足', tally.includes('结论 6 句(有依据 1 · 部分有依据 1 · 证据相矛盾 2 · 证据不足 1 · 未裁判 1)'), tally)
const summary = await page.locator(`${V} [data-evidence-summary]`).innerText()
check('读屏摘要的按键说明:证据有问题的句子 n 也跳到,证据不足的排在最后', summary.includes('证据有问题的句子(证据不足的排在最后)'), summary)
// n / N:第 1 轮是证据相矛盾、部分有依据(按正文顺序),第 2 轮是证据不足——它在正文里排第二,照样最后才到
await page.locator(`${V} [data-seg="${fxv.formal.segs.gmv}"]`).focus()
const jumps = []
for (let i = 0; i < 5; i++) {
await page.keyboard.press('n')
jumps.push(await activeKey(page))
}
const want = [u.contra, u.partial, u.method, u.insuff, u.contra].map((x) => `claim:${x}`)
check('n:先按正文顺序走证据相矛盾、部分有依据,再走证据不足,走完接回开头', jumps.join(',') === want.join(','), jumps.join(','))
const back = []
for (let i = 0; i < 2; i++) {
await page.keyboard.press('N')
back.push(await activeKey(page))
}
check('N:倒着走,从第一处退到证据不足(最后一处),再退到第 1 轮的最后一处', back.join(',') === `claim:${u.insuff},claim:${u.method}`,
back.join(','))
check('只是走动不打开面板', await panel(page).count() === 0)
// 拆档之前的取值:探索运行证据图里的 unsupported 照旧显示「证据不支持」,样式同证据相矛盾
const x = fxv.explore.units
await page.waitForSelector(`${VX} [data-ev-claim="${x.contra}"][data-ev-verdict="unsupported"]`, { timeout: 4000 }).catch(() => {})
const old = (await badgeLook(page, VX, { contra: x.contra, insuff: x.insuff }))
check('旧取值 unsupported:照旧叫「证据不支持」(读屏名、悬停说明)', old.contra?.verdict === 'unsupported'
&& old.contra.label?.endsWith(',模型判断:证据不支持,封存后追加') && old.contra.title?.startsWith('模型判断:证据不支持 · 封存后追加'),
JSON.stringify(old.contra))
check('……样式同证据相矛盾:!、--st-failed、整句浅底', old.contra?.glyph === '!' && old.contra.color === failed && old.contra.shade === soft,
JSON.stringify(old.contra))
check('……封存后追加的证据不足同样是 ○、中性色', old.insuff?.verdict === 'insufficient' && old.insuff.glyph === '○' && old.insuff.color === neutral,
JSON.stringify(old.insuff))
const xt = await page.locator(`${VX} [data-evidence-claims]`).innerText().catch(() => '')
check('……计数里旧取值照样算进去,和证据不足并列', xt.includes('结论 7 句(证据不支持 1 · 证据不足 1 · 未裁判 5)'), xt)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(V).screenshot({ path: `${SHOTS}/jr-doc-${theme}.png` })
await page.locator(VX).screenshot({ path: `${SHOTS}/jr-doc-explore-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
})
await section('jr-panel', '裁判拆档:面板写明缺什么、裁判模型的两条提醒、理由 120 字不截断、字段清单、讲方法的句子直接看到 SQL 和字段清单', async () => {
const r = await open('/ui-harness.html?evidence=1')
const pg = r.page
const p = pg.locator('[data-evidence-panel]')
const u = fxv.formal.units
const [failed, waiting, neutral] = await Promise.all(['--st-failed', '--st-waiting', '--st-cancelled'].map((t) => colorOf(pg, t)))
await badge(pg, V, u.insuff).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="insufficient"]')
check('面板:证据不足(字形 ○)', (await p.locator('[data-ev-verdict]').innerText()).trim() === '○模型判断:证据不足',
await p.locator('[data-ev-verdict]').innerText())
const head = await p.locator('header [data-ev-badge]').evaluate((el) => ({ code: el.getAttribute('data-ev-badge'), color: getComputedStyle(el).color }))
.catch(() => null)
check('……面板标题的徽标是中性色,不用红', head?.code === 'insufficient' && head.color === neutral && head.color !== failed, JSON.stringify(head))
const missing = await p.locator('[data-ev-judge] [data-ev-missing]').innerText().catch(() => '')
check('……「模型的解释」里写明缺什么', missing === `缺少:${fxv.missing}`, missing)
const same = await p.locator('[data-ev-judge] [data-ev-judge-same]').innerText().catch(() => '')
const unpriced = await p.locator('[data-ev-judge] [data-ev-judge-unpriced]').innerText().catch(() => '')
check('裁判模型和写作模型相同:「模型的解释」下写「裁判模型和写作模型相同,结果仅供参考」', same === '裁判模型和写作模型相同,结果仅供参考', same)
check('模型不在价格表里:写「该模型不在价格表中,金额上限不生效」', unpriced === '该模型不在价格表中,金额上限不生效', unpriced)
const caution = await p.locator('[data-ev-judge-caution]').evaluate((el) => {
const cs = getComputedStyle(el)
return { border: cs.borderLeftColor, width: cs.borderLeftWidth, style: cs.borderLeftStyle, color: cs.color, bg: cs.backgroundColor,
icon: getComputedStyle(el.querySelector('svg')).color }
}).catch(() => null)
check('……醒目但不刺眼:左侧一道 2px 提醒色竖线、图标用提醒色,字不用红、不铺底', !!caution && caution.border === waiting
&& caution.width === '2px' && caution.style === 'solid' && caution.icon === waiting && caution.color !== failed
&& caution.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(caution))
const sameTitle = await p.locator('[data-ev-judge-same]').getAttribute('title').catch(() => '')
check('……悬停写明是哪个模型', (sameTitle ?? '').includes(fxv.model), sameTitle ?? '')
// 没判的句子、别的报告不挂
await badge(pg, V, u.limit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unjudged"]')
check('到上限没判的句子不挂这两条提醒(模型没看过这句)', await p.locator('[data-ev-judge-caution]').count() === 0)
await badge(pg, J, fxj.formal.units.bad).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]')
check('对照:裁判模型和写作模型不同、模型在价格表里的报告不挂提醒', await p.locator('[data-ev-judge-caution]').count() === 0)
check('……旧取值的面板照旧写「证据不支持」', (await p.locator('[data-ev-verdict]').innerText()).includes('模型判断:证据不支持'))
// 理由最长 120 字:整句显示,侧边面板里折行、不溢出
await badge(pg, V, u.contra).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="contradicted"]')
const why = await p.locator('[data-ev-rationale]').evaluate((el) => {
const cs = getComputedStyle(el)
return { text: el.textContent, sw: el.scrollWidth, cw: el.clientWidth, ellipsis: cs.textOverflow, clamp: cs.webkitLineClamp,
overflow: cs.overflow, lines: Math.round(el.getBoundingClientRect().height / parseFloat(cs.lineHeight)) }
}).catch(() => null)
check('理由 120 字整句显示,不截断', why?.text === fxv.long_rationale && [...fxv.long_rationale].length === 120, why?.text)
check('……在侧边面板里折成几行、不溢出、不加省略号', !!why && why.sw <= why.cw && why.ellipsis !== 'ellipsis'
&& (why.clamp === 'none' || why.clamp === '') && why.lines >= 3, JSON.stringify(why))
check('证据相矛盾:面板写「模型判断:证据相矛盾」,不写缺什么', (await p.locator('[data-ev-verdict]').innerText()).trim() === '!模型判断:证据相矛盾'
&& await p.locator('[data-ev-missing]').count() === 0)
const orders = fxv.formal.segments[fxv.formal.segs.orders].chain.find((c) => c.step === 'entity')
const wantFields = orders.fields.map((f) => [f.name, f.type])
// 讲方法的句子:「挂的依据」下直接列出 SQL 和字段清单(先于点开句中的表名:取数只能来自这一块)
const hitsBefore = r.hits.length
check('……此前没取过句中表名的片段', !r.hits.includes(`j:${fxv.formal.segs.orders}`))
await badge(pg, V, u.method).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-cites] [data-ev-basis]')
// SQL、字段清单没画出来时也往下走:让下面几条断言自己报没通过
await pg.waitForSelector('[data-evidence-panel] [data-ev-cites] [data-ev-basis] [data-ev-sql]', { timeout: 3000 }).catch(() => {})
await pg.waitForSelector('[data-evidence-panel] [data-ev-basis-table] [data-ev-field]', { timeout: 3000 }).catch(() => {})
const basis = p.locator('[data-ev-cites] [data-ev-basis]')
const sqls = await basis.locator('[data-ev-basis-sql]').evaluateAll((els) => els.map((e) =>
[e.getAttribute('data-ev-basis-sql'), e.querySelector('[data-ev-sql]')?.textContent ?? '']))
check('讲方法的句子:「挂的依据」下直接列出 SQL(直接挂的 Q1,再补表名出现过的 Q2)',
JSON.stringify(sqls) === JSON.stringify([['Q1', fxv.snapshots.Q1.content.sql], ['Q2', fxv.snapshots.Q2.content.sql]]), JSON.stringify(sqls))
const bf = await basis.locator('[data-ev-basis-table="t:orders"] [data-ev-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-ev-field')))
check('……表的字段清单也直接列出', JSON.stringify(bf) === JSON.stringify(wantFields.map(([n]) => n)), JSON.stringify(bf))
const bhead = await basis.locator('[data-ev-basis-table="t:orders"]').innerText().catch(() => '')
check('……写明是哪张表的字段清单', bhead.startsWith(`表 orders 的字段清单(${wantFields.length} 个)`), bhead.split('\n')[0])
const bc = await basis.locator('[data-ev-basis-column]').allInnerTexts()
check('……挂的字段写类型', bc.some((t) => t.includes('orders.status') && t.includes('VARCHAR(12)'))
&& bc.some((t) => t.includes('orders.created_at') && t.includes('TIMESTAMP')), bc.join(' | '))
check('……SQL 带复制', await basis.locator('[data-ev-basis-sql="Q1"] button', { hasText: '复制 SQL' }).count() === 1)
// 这一页只有「挂的依据」会取查询快照工件(完整快照要点按钮);这之前没点开过句中的表名,字段清单那次片段请求只能是
// 它自己发的。Q1、Q2 的 SQL 在前面几句(只挂了整份查询)打开时已经取过,缓存着不再取
const fresh = r.hits.slice(hitsBefore)
check('……SQL 取自查询快照工件,字段清单取自表名片段的证据链', r.hits.includes('artifact:V:Q1') && r.hits.includes('artifact:V:Q2')
&& fresh.includes(`j:${fxv.formal.segs.orders}`), fresh.join(','))
check('……判定的理由里提到的 created_at 在字段清单里看得到', bf.includes('created_at'))
await badge(pg, V, u.insuff).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="insufficient"]')
await pg.waitForSelector('[data-evidence-panel] [data-ev-basis] [data-ev-sql]').catch(() => {})
const insuffSql = await p.locator('[data-ev-basis] [data-ev-basis-sql]').evaluateAll((els) => els.map((e) => e.getAttribute('data-ev-basis-sql')))
check('只挂了整份查询的句子(证据不足的那句):同样列出 SQL,读的人看得出缺的是什么', insuffSql.join(',') === 'Q1', insuffSql.join(','))
await badge(pg, V, u.ok).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge="supported"]')
await pg.waitForTimeout(200)
check('对照:挂了数字的结论句只列依据的编号,不另列 SQL', await p.locator('[data-ev-basis]').count() === 0
&& (await p.locator('[data-ev-cites]').innerText()).includes('Q1'))
// 表名实体步骤:字段清单和类型
await pg.locator(`${V} [data-seg="${fxv.formal.segs.orders}"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-entity] [data-ev-entity-queries]')
// 字段清单没画出来时也往下走:让下面两条断言自己报没通过,而不是整节在等待上出错
await pg.waitForSelector('[data-evidence-panel] [data-ev-entity] [data-ev-fields]', { timeout: 3000 }).catch(() => {})
const fields = await p.locator('[data-ev-entity] [data-ev-field]').evaluateAll((els) => els.map((e) =>
[e.getAttribute('data-ev-field'), e.querySelector('[data-ev-field-type]')?.textContent ?? '']))
check('表名实体步骤列出字段清单和类型(照证据接口的 fields,顺序不变)', JSON.stringify(fields) === JSON.stringify(wantFields), JSON.stringify(fields))
const fhead = await p.locator('[data-ev-entity] [data-ev-fields]').innerText().catch(() => '')
check('……写明一共几个字段;没有多出来的不写「另有」', fhead.startsWith(`字段清单(${wantFields.length} 个)`)
&& await p.locator('[data-ev-fields-more]').count() === 0, fhead.split('\n')[0])
check('没有运行时报错(裁判拆档 · 面板)', r.errors.length === 0, r.errors.join(' | '))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
for (const [name, unit] of [['insufficient', u.insuff], ['contradicted', u.contra], ['method', u.method]]) {
await badge(pg, V, unit).click()
await pg.waitForTimeout(100)
if (await p.count() === 0) await badge(pg, V, unit).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-judge]')
await pg.waitForTimeout(300)
await pg.screenshot({ path: `${SHOTS}/jr-panel-${name}-${theme}.png` })
}
await pg.locator(`${V} [data-seg="${fxv.formal.segs.orders}"]`).click()
await pg.waitForSelector('[data-evidence-panel] [data-ev-fields]')
await pg.waitForTimeout(300)
await pg.screenshot({ path: `${SHOTS}/jr-panel-fields-${theme}.png` })
await pg.keyboard.press('Escape')
}
await pg.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await r.ctx.close()
// 字段多于 60 个、表结构快照只存了部分表
const more = await open('/ui-harness.html?evidence=1', { segPatch: (set, body) => (set === fxv.formal && body?.segment?.id === fxv.formal.segs.orders
? { ...body, chain: body.chain.map((c) => (c.step === 'entity' ? { ...c, fields: c.fields.slice(0, 5), fields_more: 55, snapshot_truncated: true,
fields_masked: true } : c)) }
: body) })
const mp = more.page.locator('[data-evidence-panel]')
await more.page.locator(`${V} [data-seg="${fxv.formal.segs.orders}"]`).click()
await more.page.waitForSelector('[data-evidence-panel] [data-ev-fields-more]').catch(() => {})
check('字段多于 60 个:列出的之外写「另有 N 个」', (await mp.locator('[data-ev-fields-more]').innerText().catch(() => '')) === '另有 55 个字段未列出',
await mp.locator('[data-ev-fields-more]').innerText().catch(() => '(没写)'))
check('……总数按列出的加另有的算', (await mp.locator('[data-ev-entity] [data-ev-fields]').innerText().catch(() => '')).startsWith('字段清单(60 个)')
&& await mp.locator('[data-ev-entity] [data-ev-field]').count() === 5)
check('有列按遮罩没列出(fields_masked):照实说一句', (await mp.locator('[data-ev-entity] [data-ev-fields-masked]').innerText().catch(() => ''))
=== '部分字段已按数据源的遮罩设置隐藏,未列出')
await badge(more.page, V, u.method).click()
await more.page.waitForSelector('[data-evidence-panel] [data-ev-basis-table] [data-ev-fields-partial]').catch(() => {})
check('讲方法的句子:表结构快照只存了部分表时,字段清单下照实注明', (await mp.locator('[data-ev-basis-table] [data-ev-fields-partial]')
.innerText().catch(() => '')).includes('表结构快照仅包含部分表'))
check('……另有的字段也写出来', (await mp.locator('[data-ev-basis-table] [data-ev-fields-more]').innerText().catch(() => '')) === '另有 55 个字段未列出')
await more.ctx.close()
// 探索运行:封存后追加的证据不足、按需裁判答复里的 same_model / priced
const x = fxv.explore.units
const ex = await open('/ui-harness.html?evidence=1')
const xp = ex.page.locator('[data-evidence-panel]')
await ex.page.waitForSelector(`${VX} [data-ev-claim="${x.insuff}"][data-ev-verdict="insufficient"]`, { timeout: 4000 }).catch(() => {})
await badge(ex.page, VX, x.insuff).click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="insufficient"]')
await ex.page.waitForTimeout(300)
check('封存后追加的证据不足:同样写明缺什么', (await xp.locator('[data-ev-missing]').innerText().catch(() => '')) === `缺少:${fxv.missing}`)
check('……证据图里最近一次按需裁判的模型和写作模型不同、在价格表里:不挂提醒', await xp.locator('[data-ev-judge-caution]').count() === 0)
await badge(ex.page, VX, x.method).click()
await xp.locator('[data-ev-judge-ask]').click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="contradicted"]').catch(() => {})
check('按需裁判回来:讲方法的那句判成证据相矛盾,正文徽标跟着变', await badge(ex.page, VX, x.method).getAttribute('data-ev-verdict') === 'contradicted')
check('……这一次的答复说裁判模型和写作模型相同、不在价格表里:两条提醒都挂上',
(await xp.locator('[data-ev-judge-same]').innerText().catch(() => '')) === '裁判模型和写作模型相同,结果仅供参考'
&& (await xp.locator('[data-ev-judge-unpriced]').innerText().catch(() => '')) === '该模型不在价格表中,金额上限不生效')
await ex.page.keyboard.press('Escape')
await ex.page.waitForTimeout(150)
await ex.page.locator(`${VX} [data-seg="${firstNumber(fxv.explore.doc, x.ok)}"]`).focus()
const xj = []
for (let i = 0; i < 3; i++) {
await ex.page.keyboard.press('n')
xj.push(await activeKey(ex.page))
}
check('n:旧的证据不支持、证据相矛盾在前,证据不足在后(它在正文里更靠前也一样)',
xj.join(',') === [x.contra, x.method, x.insuff].map((k) => `claim:${k}`).join(','), xj.join(','))
// 封存后追加的旧判定(拆档之前的 unsupported):片段接口给 on_demand.reason = outdated、available 为真——照常给按钮,
// 旧判定旁边注明「按旧规则判定」;按当前规则重判之后注明和按钮都收起
const outdatedMsg = fxv.explore.segments[fxv.explore.doc.blocks.flatMap((b) => b.units).find((u2) => u2.id === x.contra)
.segments.find((sg) => sg.kind !== 'structural').id].unit.on_demand
// 面板可能还开着、正停在这一句上(上面 n 跟着走过来):点一下是收起,没开出来就再点一次
await badge(ex.page, VX, x.contra).click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]', { timeout: 1500 }).catch(() => {})
if (await xp.locator('[data-ev-judge="unsupported"]').count() === 0) await badge(ex.page, VX, x.contra).click()
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="unsupported"]', { timeout: 4000 }).catch(() => {})
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge-outdated]').catch(() => {})
const chip = xp.locator('[data-ev-judge-outdated]')
check('旧判定(片段接口说 outdated):旁边注明「按旧规则判定」,悬停是接口的原话', outdatedMsg?.reason === 'outdated'
&& (await chip.innerText().catch(() => '')) === '按旧规则判定' && await chip.getAttribute('title').catch(() => '') === outdatedMsg.message,
JSON.stringify(outdatedMsg))
check('……照常给按钮(再次请模型判断),按钮旁写明为什么', (await xp.locator('[data-ev-judge-ask]').innerText().catch(() => '')).includes('再次请模型判断')
&& (await xp.locator('[data-ev-judge-outdated-why]').innerText().catch(() => '')) === outdatedMsg.message)
await xp.locator('[data-ev-judge-ask]').click().catch(() => {})
await ex.page.waitForSelector('[data-evidence-panel] [data-ev-judge="contradicted"]').catch(() => {})
check('……按当前规则重判:判成证据相矛盾,「按旧规则判定」和按钮都收起', await badge(ex.page, VX, x.contra).getAttribute('data-ev-verdict') === 'contradicted'
&& await xp.locator('[data-ev-judge-outdated]').count() === 0 && await xp.locator('[data-ev-judge-ask]').count() === 0
&& ex.hits.some((h) => h === `judge:${fxv.explore.run_id}:${x.contra}:write`), ex.hits.filter((h) => h.startsWith('judge:')).join(' '))
if (SHOTS) {
// 重新开一页截「按旧规则判定」那一刻(上面已经重判掉了)
const shot = await open('/ui-harness.html?evidence=1')
await shot.page.waitForSelector(`${VX} [data-ev-claim="${x.contra}"][data-ev-verdict="unsupported"]`).catch(() => {})
for (const theme of ['dark', 'light']) {
await shot.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await badge(shot.page, VX, x.contra).click()
await shot.page.waitForTimeout(100)
if (await shot.page.locator('[data-evidence-panel]').count() === 0) await badge(shot.page, VX, x.contra).click()
await shot.page.waitForSelector('[data-evidence-panel] [data-ev-judge-outdated]').catch(() => {})
await shot.page.waitForTimeout(300)
await shot.page.screenshot({ path: `${SHOTS}/jr-panel-outdated-${theme}.png` })
}
await shot.ctx.close()
}
check('没有运行时报错(裁判拆档 · 探索运行)', ex.errors.length === 0, ex.errors.join(' | '))
await ex.ctx.close()
})
await section('jr-narrow', '裁判拆档在 360px:理由 120 字、字段清单、SQL 栏内展开不横向滚动;底部抽屉整页不横向滚动', async () => {
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth, w: el.getBoundingClientRect().width }
}, VN)
const before = await overflow()
await badge(page, VN, fxv.formal.units.contra).click()
await page.waitForSelector(`${VN} [data-evidence-panel="inline"] [data-ev-rationale]`)
const why = await page.locator(`${VN} [data-ev-rationale]`).evaluate((el) => ({ text: el.textContent, sw: el.scrollWidth, cw: el.clientWidth }))
const after = await overflow()
check('窄栏里理由 120 字整句显示、在栏内折行', why.text === fxv.long_rationale && why.sw <= why.cw, JSON.stringify({ sw: why.sw, cw: why.cw }))
check('……面板打开前后都没有横向滚动', Math.round(before.w) === 360 && before.sw <= before.cw && after.sw <= after.cw, JSON.stringify({ before, after }))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(VN).screenshot({ path: `${SHOTS}/jr-360-rationale-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await badge(page, VN, fxv.formal.units.method).click()
await page.waitForSelector(`${VN} [data-evidence-panel="inline"] [data-ev-basis] [data-ev-sql]`).catch(() => {})
await page.waitForSelector(`${VN} [data-evidence-panel="inline"] [data-ev-basis-table] [data-ev-field]`).catch(() => {})
const basis = await overflow()
const pre = await page.locator(`${VN} [data-ev-basis] [data-ev-sql]`).first().evaluate((el) => ({ sw: el.scrollWidth, cw: el.clientWidth }))
.catch(() => null)
check('窄栏里讲方法的句子:SQL、字段清单都在栏内,不横向滚动', basis.sw <= basis.cw && !!pre && pre.sw <= pre.cw
&& await page.locator(`${VN} [data-ev-basis-table] [data-ev-field]`).count() > 0, JSON.stringify({ basis, pre }))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator(VN).screenshot({ path: `${SHOTS}/jr-360-basis-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
await page.locator(`${VN} [data-evidence-panel] button`, { hasText: '回到正文' }).click()
await page.waitForTimeout(150)
const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 })
const scroll = () => small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
await badge(small.page, V, fxv.formal.units.contra).click()
await small.page.waitForSelector('[data-evidence-panel] [data-ev-rationale]')
const d = await scroll()
const dwhy = await small.page.locator('[data-evidence-panel] [data-ev-rationale]').evaluate((el) => ({ text: el.textContent, sw: el.scrollWidth, cw: el.clientWidth }))
check('360px 宽的屏幕:底部抽屉里理由整句显示、整页没有横向滚动',
await small.page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel') === 'drawer' && d.sw <= d.vw
&& dwhy.text === fxv.long_rationale && dwhy.sw <= dwhy.cw, JSON.stringify({ d, sw: dwhy.sw, cw: dwhy.cw }))
await small.ctx.close()
})
await section('jr-banner', '裁判拆档:出具横幅的结论句计数带上证据相矛盾、证据不足和旧的证据不支持', async () => {
const at = (key) => page.locator(`#issuance-claims [data-issuance-case="${key}"]`)
const rework = await at('rework').locator('[data-evidence-line]').innerText().catch(() => '')
check('计数:证据相矛盾、旧的证据不支持、证据不足各写各的', rework.includes('结论 7 句(有依据 1 · 部分有依据 1 · 证据相矛盾 2 · 证据不支持 1 · 证据不足 1 · 未裁判 1)'),
rework)
const [color, waiting] = [await at('rework').locator('[data-evidence-claims]').evaluate((el) => getComputedStyle(el).color).catch(() => ''),
await colorOf(page, '--st-waiting')]
check('……有问题的计数用提醒色', color === waiting, `${color} / ${waiting}`)
check('只有证据不足一种问题:照样给「定位下一处」', await at('insufficient').locator('[data-evidence-next]').count() === 1)
const only = await at('insufficient').locator('[data-evidence-claims]').evaluate((el) => getComputedStyle(el).color).catch(() => '')
check('……计数也用提醒色(证据不足也算问题)', only === waiting, only)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('#issuance-claims').screenshot({ path: `${SHOTS}/jr-banner-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
})
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
+37 -1
View File
@@ -626,9 +626,34 @@ await section('设置 · 证据裁判', async () => {
const box = page.locator('[data-judge-settings]')
const all = (await box.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('偏好里有「证据裁判」一组,写明判断是模型给的、不是系统核对', all.includes('证据裁判') && all.includes('非确定'), all.slice(0, 80))
// 裁判拆档:接入和模型都没填时,平常那句建议换成醒目的「未单独配置」提示(建议写在提示里);填了再换回来(见下)
check('裁判模型:接入和模型,提示建议和写作模型不同', await page.locator('#pref-judge-provider').count() === 1
&& await page.locator('#pref-judge-model').count() === 1 && all.includes('建议与撰写报告的模型不同'))
&& await page.locator('#pref-judge-model').count() === 1 && all.includes('建议配置一个与撰写报告的模型不同、能力更强的模型'))
check('……接入留空时跟随助手的模型', (await page.locator('#pref-judge-provider option').first().innerText()).includes('跟随助手的模型'))
const notSet = page.locator('[data-judge-model] [data-judge-not-set]')
check('没配裁判模型(接入、模型都是 null):提示「未单独配置,将使用助手的模型」',
(await notSet.locator('[data-judge-not-set-text]').innerText().catch(() => '')) === '未单独配置,将使用助手的模型',
await notSet.innerText().catch(() => '(没有这条提示)'))
const advice = await notSet.locator('[data-judge-not-set-advice]').innerText().catch(() => '')
check('……并建议配一个和写作模型不同、能力更强的模型', advice.includes('与撰写报告的模型不同') && advice.includes('能力更强'), advice)
const noteLook = await notSet.evaluate((el) => {
const probe = document.createElement('span')
probe.style.color = 'var(--st-waiting)'
document.body.appendChild(probe)
const waiting = getComputedStyle(probe).color
probe.remove()
const cs = getComputedStyle(el)
return { border: cs.borderLeftColor, width: cs.borderLeftWidth, icon: getComputedStyle(el.querySelector('svg')).color, waiting,
described: document.getElementById(el.closest('[data-judge-model]').getAttribute('aria-describedby'))?.hasAttribute('data-judge-not-set') }
}).catch(() => null)
check('……醒目:左侧提醒色竖线、提醒色图标', !!noteLook && noteLook.border === noteLook.waiting && noteLook.width === '2px'
&& noteLook.icon === noteLook.waiting, JSON.stringify(noteLook))
check('……读屏:模型那一组的说明就是这条提示(aria-describedby)', noteLook?.described === true)
if (SHOTS) {
await box.scrollIntoViewIfNeeded()
await page.waitForTimeout(200)
await box.screenshot({ path: `${SHOTS}/prefs-judge-not-set-${THEME}.png` })
}
const val = (id) => page.locator(`#${id}`).inputValue()
check('读回各项上限:40 句、30 秒、每次点击 $0.01、每日 $2', await val('pref-judge-claims') === '40' && await val('pref-judge-timeout') === '30'
&& await val('pref-judge-click') === '0.01' && await val('pref-judge-daily') === '2')
@@ -654,6 +679,8 @@ await section('设置 · 证据裁判', async () => {
await page.locator('#pref-judge-click').fill('0.02')
check('……接入留空时模型框的占位也写跟随助手的模型', await page.locator('#pref-judge-model').getAttribute('placeholder') === '留空:跟随助手的模型')
await page.locator('#pref-judge-model').fill('judge-model-b')
check('填了裁判模型:「未单独配置」的提示收起,换回平常那句建议', await notSet.count() === 0
&& (await box.innerText().catch(() => '')).includes('建议与撰写报告的模型不同'))
// 只填了模型:设置这一级整组生效(judge_model_spec 不跨级拼),后端按模型名找接入——已经不跟随助手的模型
const provFirst = await page.locator('#pref-judge-provider option').first().innerText().catch(() => '')
check('只填了模型:接入留空那一项不再叫「跟随助手的模型」,改叫「按模型名找接入」', provFirst === '按模型名找接入', provFirst)
@@ -690,6 +717,15 @@ await section('设置 · 证据裁判', async () => {
}
await close()
// 只填了接入(模型留空):也算单独配置了,不提示「未单独配置」
const byProvider = { ...stored, judge: { ...stored.judge, provider: 'judge-provider-x', model: null } }
const pv = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(byProvider)(route)],
[/^GET \/settings\/judge\/spend$/, (route) => json({ date: '2026-09-29', usd: 0, calls: 0, unpriced_calls: 0 })(route)]] })
await pv.page.waitForSelector('[data-judge-settings]', { timeout: 6000 }).catch(() => {})
check('只填了接入:不提示「未单独配置」', await pv.page.locator('[data-judge-settings]').count() === 1
&& await pv.page.locator('[data-judge-not-set]').count() === 0)
await pv.close()
// 老后端没有 judge 这一组:不显示,也不写
const legacy = { ...stored }
delete legacy.judge
+2
View File
@@ -712,6 +712,8 @@ await section('保存失败、接口不存在、画布改过:都有明确状
bad.state.save = 'ok'
await dialog(bad.page).getByRole('button', { name: '重试保存' }).click()
await waitFor(bad.page, async () => bad.state.checks.length === 2)
// 重查的请求发出去了,按钮的可用状态要等答复回来、界面更新之后才变:并行跑的时候机器挤,直接读会抢在前面
await waitFor(bad.page, async () => submit(bad.page).isEnabled())
check('重试保存成功后重新检查、发布可用', bad.state.patches.length === 2 && await submit(bad.page).isEnabled())
const nofix = await open({ backend: 'nofix' })
+52
View File
@@ -651,6 +651,9 @@ await section('提取模板', async () => {
fakes.set('POST /api/copilot/from-run', () => ({
status: 200, json: { workflow_id: wfs[0].id, name: '检查用草稿', nodes: 3, edges: 2, dropped_nodes: 1, source_run: timed?.id },
}))
// 跳进画布后,画布先找这张图的助手对话,没有就建一条(POST,会被探针拦下、算成没伪造的写请求)。
// 沙箱里 wfs[0] 有没有对话要看之前谁跑过什么:给一条现成的,和升级横幅那一节同一个做法
fakes.set('GET /api/conversations', () => ({ status: 200, json: [{ id: 'fake-conv-extract', title: '', kind: 'canvas', archived: false, turn_count: 0 }] }))
// 画布上还有没保存的改动(store 里的画布离开编排页也还在):先问要不要放弃,再建草稿。
// 以前建完才问,点了「取消」库里就多出一张没人要的草稿(3C REQ-15)
const extractPosts = () => writes.filter((w) => w === 'POST /api/copilot/from-run').length
@@ -684,6 +687,7 @@ await section('提取模板', async () => {
&& new URL(page.url()).pathname === `/studio/${wfs[0].id}`, `提取 ${extractPosts() - postsBefore} 次 · 问了 ${asks} 遍`)
check('提取成功后跳到新草稿', new URL(page.url()).pathname === `/studio/${wfs[0].id}`)
fakes.delete('POST /api/copilot/from-run')
fakes.delete('GET /api/conversations')
})
// ------------------------------------------------------------------ 运行中:接流、停止、不能删
@@ -1981,6 +1985,54 @@ await section('证据页签:同一段文字里好几条同样的违规、正
}
})
await section('证据页签:裁判拆档——证据相矛盾、证据不足的结论句各一行,点开是这一句的「模型的解释」', async () => {
// 夹具是后端(JR-back 之后)真算的:审计行由 api/evidence.py 的 _audit_doc 给出,判定行 issue 为
// contradicted_claim / insufficient_claim,带着 unit 和 verdict
const fxv = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-verdict.json', import.meta.url), 'utf8'))
const v = fxv.formal
const EV_V = 'fake0evverdict000000000000000'
const output = { answer: v.doc.markdown, _evidence: { report_node: 'write', doc_artifact: v.doc_artifact, fields: ['answer'] } }
evFakes(EV_V, { output, graph: v.graph, audit: v.audit })
fakes.set(`GET /api/artifacts/${v.doc_artifact}`, () => ({ status: 200, json: { id: v.doc_artifact, content: v.doc } }))
for (const snap of Object.values(fxv.snapshots)) {
fakes.set(`GET /api/artifacts/${snap.artifact}`, () => ({ status: 200, json: { id: snap.artifact, content: snap.content } }))
}
await page.route(new RegExp(`/api/runs/${EV_V}/evidence/segments/`), (r) => {
const sid = decodeURIComponent(new URL(r.request().url()).pathname.split('/').pop())
return v.segments[sid] ? r.fulfill({ json: v.segments[sid] }) : r.fulfill({ status: 404, json: { detail: '没有这个片段' } })
})
try {
await openEvidence(EV_V)
await page.locator('[data-audit-table]').waitFor({ timeout: 5000 }).catch(() => {})
const claimRows = await page.locator('[data-audit-table] tbody[data-audit-group=none] tr[data-audit-row]').evaluateAll((els) =>
els.map((e) => ({ state: e.getAttribute('data-audit-state'), open: e.querySelector('[data-audit-open]')?.getAttribute('data-audit-open') ?? null,
stateText: e.children[1]?.textContent ?? '', source: e.querySelector('[data-audit-source]')?.textContent ?? '' })))
const byState = (st) => claimRows.filter((r) => r.state === st)
check('审计表:证据相矛盾的结论句两行、证据不足一行,列在「无证据」一组', byState('contradicted').length === 2 && byState('insufficient').length === 1,
JSON.stringify(claimRows.map((r) => r.state)))
check('……状态照句末徽标的字形和叫法写', byState('contradicted').every((r) => r.stateText === '!模型判断:证据相矛盾')
&& byState('insufficient')[0]?.stateText === '○模型判断:证据不足', JSON.stringify(claimRows.map((r) => r.stateText)))
const note = v.audit.groups.flatMap((g) => g.rows).find((r) => r.issue === 'insufficient_claim')?.note ?? '(夹具里没有这一行)'
check('……原因照后端的原话(缺什么、理由)', byState('insufficient')[0]?.source === note && note.includes(`缺少:${fxv.missing}`), note)
check('……判定行点得开,打开的是这一句的句末徽标(claim:<句子>)', byState('insufficient')[0]?.open === `claim:${v.units.insuff}`
&& byState('contradicted').map((r) => r.open).join(',') === [v.units.contra, v.units.method].map((u) => `claim:${u}`).join(','),
JSON.stringify(claimRows.map((r) => r.open)))
await page.locator(`[data-audit-table] [data-audit-open="claim:${v.units.insuff}"]`).click()
await page.locator('[data-evidence-dock] [data-evidence-panel] [data-ev-judge="insufficient"]').waitFor({ timeout: 5000 }).catch(() => {})
const dock = page.locator('[data-evidence-dock] [data-evidence-panel]')
check('点开证据不足那一行:右边打开这一句的「模型的解释」,写明缺什么', (await dock.locator('[data-ev-missing]').innerText().catch(() => ''))
=== `缺少:${fxv.missing}`)
check('……裁判模型和写作模型相同、不在价格表里:两条提醒都在', await dock.locator('[data-ev-judge-same]').count() === 1
&& await dock.locator('[data-ev-judge-unpriced]').count() === 1)
const tally = await page.locator(`[data-evidence-report=write] [data-evidence-claims]`).innerText().catch(() => '')
check('报告上方的证据条:结论句计数带上证据相矛盾、证据不足', tally.includes('结论 6 句(有依据 1 · 部分有依据 1 · 证据相矛盾 2 · 证据不足 1 · 未裁判 1)'), tally)
await page.keyboard.press('Escape')
} finally {
for (const k of [...fakes.keys()]) if (k.includes(EV_V) || k.includes(v.doc_artifact)) fakes.delete(k)
for (const snap of Object.values(fxv.snapshots)) fakes.delete(`GET /api/artifacts/${snap.artifact}`)
}
})
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)
+97 -5
View File
@@ -458,9 +458,9 @@ await section('lib/evidence:证据状态的四通道元数据、报告节点
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
const codes = ev.EVIDENCE_STATES
// 方案 6.1 的八种(确定性、概率性四种、无证据、连接性、旧运行候选),加三期无证据的两个变体:
// 可疑实体(疑似不存在的名称)、无法核实(表结构快照不全)
ok('十种状态一种不少', ['deterministic', 'supported', 'partial', 'unsupported', 'unjudged', 'none', 'connective', 'candidate',
'suspect', 'unverified'].every((c) => codes.includes(c)) && codes.length === 10, codes.join(','))
// 可疑实体(疑似不存在的名称)、无法核实(表结构快照不全),加裁判拆档的两种:证据相矛盾、证据不足
ok('十二种状态一种不少', ['deterministic', 'supported', 'partial', 'contradicted', 'insufficient', 'unsupported', 'unjudged', 'none',
'connective', 'candidate', 'suspect', 'unverified'].every((c) => codes.includes(c)) && codes.length === 12, codes.join(','))
eq('可疑实体:点状线(同无证据)、?!、疑似不存在的名称', `${ev.EVIDENCE_STATE.suspect.line}/${ev.EVIDENCE_STATE.suspect.glyph}/${ev.EVIDENCE_STATE.suspect.label}`,
'dotted/?!/疑似不存在的名称')
ok('可疑实体醒目(进 n / N),核对不了只是标注', ev.EVIDENCE_STATE.suspect.alert && !ev.EVIDENCE_STATE.unverified.alert)
@@ -492,8 +492,12 @@ await section('lib/evidence:证据状态的四通道元数据、报告节点
const d = ev.EVIDENCE_STATE[c].decoration
return d === 'transparent' || /var\(--st-[\w-]+\)/.test(d)
}), codes.map((c) => ev.EVIDENCE_STATE[c].decoration).join(' | '))
const pairs = codes.map((c) => `${ev.EVIDENCE_STATE[c].line}/${ev.EVIDENCE_STATE[c].glyph}`)
ok('去掉颜色也分得开:线型 + 字形两两不同', new Set(pairs).size === codes.length, pairs.join(' '))
// 裁判拆档:旧取值「证据不支持」按规格和证据相矛盾长得一样(同一类判定,拆档前后的叫法不同,文字仍不同),
// 除它之外线型 + 字形两两不同
const pairOf = (c) => `${ev.EVIDENCE_STATE[c].line}/${ev.EVIDENCE_STATE[c].glyph}`
const pairs = codes.filter((c) => c !== 'unsupported').map(pairOf)
ok('去掉颜色也分得开:线型 + 字形两两不同(旧的证据不支持同证据相矛盾)', new Set(pairs).size === codes.length - 1
&& pairOf('unsupported') === pairOf('contradicted'), codes.map(pairOf).join(' '))
const labels = codes.map((c) => ev.EVIDENCE_STATE[c].label)
ok('文字两两不同', new Set(labels).size === codes.length, labels.join(' '))
eq('确定性:细实线、有出处', `${ev.EVIDENCE_STATE.deterministic.line}/${ev.EVIDENCE_STATE.deterministic.label}`, 'solid/有出处')
@@ -663,6 +667,94 @@ await section('lib/evidence:结论句裁判(四期)——判定的徽标
})) check(name, ok, detail)
})
await section('lib/evidence:裁判拆档(JR)——证据相矛盾、证据不足、计数、跳转轮次、模型提醒、审计行、讲方法的句子', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const { evidence: ev, terms: t } = window.__ui.lib
const fv = window.__ui.evidenceVerdict
const out = []
const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`])
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
const S = ev.EVIDENCE_STATE
eq('证据相矛盾、证据不足:句末徽标、字形 ! ○', ['contradicted', 'insufficient'].map((c) => `${S[c].line}/${S[c].glyph}`).join(' '),
'badge/! badge/○')
eq('……颜色令牌:证据相矛盾同原来的证据不支持(--st-failed),证据不足中性(--st-cancelled)',
['contradicted', 'insufficient', 'unsupported'].map((c) => S[c].color).join(' '), 'var(--st-failed) var(--st-cancelled) var(--st-failed)')
ok('……证据不足不用红、不用提醒色,字形不和未裁判的 ? 撞', !/--st-(failed|waiting|done)/.test(S.insufficient.color + S.insufficient.soft)
&& S.insufficient.glyph !== S.unjudged.glyph, JSON.stringify(S.insufficient))
eq('叫法:证据相矛盾、证据不足;旧取值照旧叫证据不支持', [S.contradicted.label, S.insufficient.label, S.unsupported.label].join(' / '),
'模型判断:证据相矛盾 / 模型判断:证据不足 / 模型判断:证据不支持')
eq('判定 → 徽标:新取值各是各的', [ev.verdictState({ status: 'contradicted' }), ev.verdictState({ status: 'insufficient' }),
ev.verdictState({ status: 'unsupported' })].join(','), 'contradicted,insufficient,unsupported')
ok('判过的算上新取值(不再给「请模型判断」)', ev.isJudged({ status: 'contradicted' }) && ev.isJudged({ status: 'insufficient' }))
eq('n / N 的轮次:证据相矛盾、旧的证据不支持、部分有依据、无证据在第 1 轮,证据不足在第 2 轮,有依据、未裁判不进',
['contradicted', 'unsupported', 'partial', 'none', 'suspect', 'insufficient', 'supported', 'unjudged'].map((c) => ev.alertTier(c)).join(''),
'11111200')
ok('整句铺浅底的是证据相矛盾和旧的证据不支持', ev.conflicting('contradicted') && ev.conflicting('unsupported')
&& !ev.conflicting('insufficient') && !ev.conflicting('partial'))
eq('证据不足缺什么:只认 insufficient 的 missing', [ev.verdictMissing({ status: 'insufficient', missing: ' SQL ' }),
ev.verdictMissing({ status: 'contradicted', missing: 'SQL' }), ev.verdictMissing({ status: 'insufficient' })].join('|'), 'SQL||')
const k = (c) => (c ? `${c.total}:${c.supported}/${c.partial}/${c.contradicted}/${c.unsupported}/${c.insufficient}/${c.unjudged}/${c.uncited}` : 'null')
eq('文档计数:证据相矛盾 2、证据不足 1,不是结论句不算', k(ev.claimCounts(fv.formal.doc)), '6:1/1/2/0/1/1/0')
eq('……叠上封存后追加的判定(旧取值 unsupported 照样计入)', k(ev.claimCounts(fv.explore.doc,
ev.graphVerdicts(fv.explore.graph, 'write'))), '7:0/0/0/1/1/5/0')
eq('stats / counts → 计数(report.checked、报告节点的章):旧数据的 unsupported 照样计入,不是结论句不算',
k(ev.claimCountsOf({ supported: 1, partial: 1, contradicted: 2, insufficient: 1, unsupported: 1, not_a_claim: 3, unjudged: 1,
numbers: 9 })), '7:1/1/2/1/1/1/0')
eq('……缺键、不是数的当 0', k(ev.claimCountsOf({ supported: 2, unsupported: 'x' })), '2:2/0/0/0/0/0/0')
eq('计数的说法:证据相矛盾、证据不支持、证据不足各写各的', t.claimTally(ev.claimCountsOf({ supported: 1, contradicted: 2, unsupported: 1,
insufficient: 1, unjudged: 1 })), '结论 6 句(有依据 1 · 证据相矛盾 2 · 证据不支持 1 · 证据不足 1 · 未裁判 1)')
eq('……旧的计数对象(没有新键)照旧能说', t.claimTally({ total: 2, supported: 1, partial: 0, unsupported: 1, unjudged: 0, uncited: 0 }),
'结论 2 句(有依据 1 · 证据不支持 1)')
eq('有问题的句数(横幅、证据条的「定位下一处」):证据相矛盾、旧的证据不支持、部分有依据、证据不足', ev.claimProblems(ev.claimCountsOf(
{ supported: 5, contradicted: 1, unsupported: 1, partial: 1, insufficient: 1, unjudged: 3 })), 4)
const unit = fv.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fv.formal.units.insuff)
eq('读屏名:证据不足接着说缺什么', ev.claimLabel(unit, unit.verdict),
`结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不足,缺少:${fv.missing}`)
const sealed = fv.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fv.formal.units.contra).verdict
const f1 = ev.judgeFlags(sealed, { doc: fv.formal.doc.judge })
ok('模型提醒:节点里当场判的看报告文档的 judge(same_model 真、priced 假)', f1.sameModel && f1.unpriced && f1.writer === fv.model, JSON.stringify(f1))
const late = { status: 'insufficient', post_seal: true, judge: 'm' }
const f2 = ev.judgeFlags(late, { doc: { same_model: true, priced: false }, meta: { same_model: false, priced: true } })
ok('……封存后追加的不看报告文档,看证据图里最近一次按需裁判(judge_meta)', !f2.sameModel && !f2.unpriced, JSON.stringify(f2))
const f3 = ev.judgeFlags(late, { reply: { same_model: true, priced: false }, meta: { same_model: false, priced: true } })
ok('……这一次的答复盖过证据图', f3.sameModel && f3.unpriced, JSON.stringify(f3))
const f4 = ev.judgeFlags({ ...late, same_model: false }, { reply: { same_model: true, priced: false } })
ok('……判定自己带着的盖过答复', !f4.sameModel && f4.unpriced, JSON.stringify(f4))
const f5 = ev.judgeFlags({ status: 'unjudged', reason: 'max_claims' }, { doc: { same_model: true, priced: false } })
ok('……没判的句子不提醒;priced 为 null(不知道)也不提醒', !f5.sameModel && !f5.unpriced
&& !ev.judgeFlags(sealed, { doc: { priced: null } }).unpriced, JSON.stringify(f5))
const rows = ev.auditFromApi(fv.formal.audit)
const claim = (issue) => rows.find((r) => r.kind === 'claim' && fv.formal.audit.groups.flatMap((g) => g.rows)
.some((x) => x.issue === issue && x.unit === r.unit))
const c1 = claim('contradicted_claim')
const c2 = claim('insufficient_claim')
ok('审计行:issue 为 contradicted_claim 的结论句认成证据相矛盾,insufficient_claim 认成证据不足,带着是哪一句',
c1?.state === 'contradicted' && c2?.state === 'insufficient' && c2.unit === fv.formal.units.insuff && c1.group === 'none',
JSON.stringify([c1, c2].map((r) => r && { state: r.state, unit: r.unit, group: r.group })))
ok('……旧取值 unsupported_claim 认成证据不支持;没挂依据的结论句照旧是无证据',
ev.auditFromApi({ groups: [{ key: 'none', rows: [{ kind: 'claim', issue: 'unsupported_claim', unit: 'u2', text: 'x', group: 'none' },
{ kind: 'claim', issue: 'uncited_claim', unit: 'u7', text: 'y', group: 'none', state: 'none' }] }] }).map((r) => r.state).join(',')
=== 'unsupported,none')
const doc = fv.formal.doc
const units = Object.fromEntries(doc.blocks.flatMap((b) => b.units).map((u) => [u.id, u]))
const b = ev.claimBasis(units[fv.formal.units.method], doc)
ok('讲方法的句子(只挂了表名、字段名、整份查询):列表、字段、要列 SQL 的查询(直接挂的在前,再补表名出现过的)',
!!b && b.tables.map((x) => `${x.alias}=${x.name}@${x.seg}`).join() === `t:orders=orders@${fv.formal.segs.orders}`
&& b.columns.map((x) => x.name).join() === 'orders.status,orders.created_at' && b.queries.join() === 'Q1,Q2' && b.more === 0, JSON.stringify(b))
eq('……只挂了整份查询的句子也算', ev.claimBasis(units[fv.formal.units.insuff], doc)?.queries.join(), 'Q1')
eq('……挂了数字(单元格)的句子不算', ev.claimBasis(units[fv.formal.units.ok], doc), null)
eq('……挂了运行输入的句子不算', ev.claimBasis(units[fv.formal.units.not], doc), null)
eq('……一个依据都没挂的不算', ev.claimBasis(units[fv.formal.units.limit], doc), null)
const many = { catalog: { 't:a': { kind: 'table', name: 'a', queries: ['Q1', 'Q2', 'Q3', 'Q4', 'Q5'] },
...Object.fromEntries(['Q1', 'Q2', 'Q3', 'Q4', 'Q5'].map((q) => [q, { kind: 'query' }])) }, blocks: [] }
const bm = ev.claimBasis({ cites: ['t:a'], segments: [] }, many)
ok('……查询多于 3 次:只列前 3 次的 SQL,其余写个数', bm?.queries.join() === 'Q1,Q2,Q3' && bm.more === 2, JSON.stringify(bm))
eq('查询快照里的 SQL:快照本身、工具调用快照的 args / result 都认', [ev.sqlOf({ sql: 'A' }), ev.sqlOf({ tool: 'x', args: { sql: 'B' }, result: {} }),
ev.sqlOf({ result: { sql: 'C' } }), ev.sqlOf({ rows: [] }), ev.sqlOf(null)].join('|'), 'A|B|C||')
return out
})) check(name, ok, detail)
})
await section('lib/actor:本机署名', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const a = window.__ui.lib.actor