可点击证据第四期:结论句由模型裁判;只引单元格的问数据图能发成受管

一、结论句裁判

- 报告节点新增 claims: judge:写完、核对完之后,把结论句和它引用的证据摘录交给裁判模型,
  判为支持 / 部分支持 / 不支持 / 不是结论,附一句理由;判定只标注,不改写正文
- 送裁判的句子按固定规则预筛:带引用的、含方向词或因果词的送去判,短句、过渡句不送
- 证据摘录:指标写名称、值和原式;查询给 SQL 前 300 字和被引用的行,被引用的列一定给到;遮罩列一律不给;
  规则里写明「数字已经由系统核对过,不要复算」
- 一份报告合并成一次调用,超出句数就分批;结构化输出失败时退回 JSON 解析,再失败记为未裁判
- 裁判模型的优先级:节点上写的 > 设置里的「证据裁判模型」> Copilot 的模型;思考关闭;
  和写作模型相同时给警告(等于自己审自己)
- rewrite_once(默认关):把不支持的句子和理由交回写作者只改这几句,再裁一次;
  改写稿冒出原稿没有的问题就不采用,保留原稿和原来的判定

二、成本上限

- 每份报告的句数、金额、时长,探索运行每次点击的金额,全局每日金额,每一项都可以自定义,也都可以设成不限;
  默认 40 句、$0.05、30 秒,每次点击 $0.01,每天 $2
- 调用前先估算,超出上限按优先级截断:先判带引用的,再判含方向词、因果词的,其余记为未裁判并写明「已到上限」;
  不报错,也不让报告失败
- 每日花费按本地日期计数,持久化在设置表里;重跑同一次运行不重复计费;价格目录里没有的模型只计次数,并写明估不出金额

三、正式运行与探索运行

- 正式运行在报告节点内裁判,判定写进报告文档、随文档封存;不支持的按 on_unsupported(degrade / withhold)降档或不予出具,
  裁判没跑成记缺口,不能判完整出具
- 探索运行按需裁判:POST /api/runs/{id}/evidence/judge,点开哪句判哪句;判定作为 evidence.judged 事件追加在封存之后,
  标「封存后追加」,封存核对仍然一致;只读封存范围内的工件;同一句不重复判;运行在裁判期间被接着跑时不记录
- 正式运行调这个接口返回 409,写明「正式运行的裁判在节点内完成」

四、治理与自动修复

- G3 放开 judge:受管级别写 judge 必须写预算(judge.max_cost_usd,或显式写 null 表示不限)
- 发布前自动修复:缺预算时给选项(默认金额 / 不限),不替人选;
  从 require_citation 改成 judge 不算降低要求,judge 改回 off、withhold 放宽成 degrade 算降低要求
- Copilot 手册:受管模板可以写 judge 并带预算;问数据按需裁判,不替人打开结论句策略

五、界面

- 结论句句末放徽标:支持 ◆、部分支持 ◇、不支持 ! 整句浅底、未裁判 ?;不用表示「已核对」的绿色;n / N 也跳到不支持的句子
- 证据面板里「模型的解释」:带「模型判断 · 模型名 · 非确定」,封存后追加的另外标出;
  「请模型判断这句」只在探索运行、还没判过时出现,触顶时写明已到上限和怎么调
- 出具横幅多一段结论句计数;设置页新增「证据裁判」一组(裁判模型、各项上限、今日花费),设成不限时写明费用受什么约束;
  报告节点可以配 judge;运行轨迹里裁判相关的事件都有中文标题

六、门禁修正

- 引用模式下,图里没有口径卡(只引查询单元格)的,出具契约不再要求 metrics_from,受管门禁也不再要求 required:
  strict 加 cells 已经保证每个数都点得开、对不上就不予出具;有口径卡的照旧要求。
  以前问数据的图怎么都发不成受管,Copilot 补上契约也会被判「冒出新的问题」作废
This commit is contained in:
ThearchyHelios
2026-09-29 11:10:53 +08:00
parent d071bf119f
commit 9727c21d8d
47 changed files with 12367 additions and 263 deletions
+97
View File
@@ -1313,6 +1313,103 @@ await section('agent 字段按出处核对(可点击证据第二期):抽
&& !odd.some((s) => /undefined|NaN/.test(`${s.title}${s.sub ?? ''}`)), odd.map((s) => s.title).join(' | '))
})
await section('结论句裁判(可点击证据第四期):裁判调用单独成行、几条警告说人话、封存后按需裁判有记录', async () => {
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
// 原话和后端同一个格式:engine/judge.py 的 run_request、engine/nodes/report.py 的改写一次。先核对后端源码还是这么说的
const judgePy = readFileSync(`${root}backend/app/engine/judge.py`, 'utf8')
const reportPy = readFileSync(`${root}backend/app/engine/nodes/report.py`, 'utf8')
check('后端发裁判事件的格式还是夹具里这一种', judgePy.includes('purpose="judge"') && judgePy.includes('code="judge_limit"')
&& judgePy.includes('结论句裁判已到上限({labels}):{n} 句没判,记为未裁判;已判的保留')
&& judgePy.includes('"结论句裁判没跑完:" + ";".join(failed)') && judgePy.includes('code="judge_unpriced"')
&& judgePy.includes('不在价格目录里,按令牌估不出金额:') && reportPy.includes('code="judge_same_model"')
&& reportPy.includes(':等于自己审自己') && reportPy.includes('code="report_rewrite"') && reportPy.includes('句结论证据不支持(')
&& reportPy.includes('已交回写作者只改这几句') && reportPy.includes('改写稿冒出 {len(fresh)} 处原稿没有的问题(')
&& reportPy.includes('没有采用,') && reportPy.includes('保留原稿和原来的判定'),
'改了 judge.py / report.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具')
const LIMIT = '结论句裁判已到上限(这份报告的裁判金额上限 $0.05):3 句没判,记为未裁判;已判的保留'
const FAILED = '结论句裁判没跑完:有 2 句结论没裁判:裁判调用失败(ConnectError: 连不上)'
const UNPRICED = '模型「jm-x」不在价格目录里,按令牌估不出金额:金额上限(每份报告、每次点击、每日)对它不起作用,费用只受句数、时长上限约束'
const SAME = '裁判模型和写作模型都是「wm-1」:等于自己审自己,模型写错的地方它多半也看不出来。到设置里选一个不同的「证据裁判模型」,或者在节点的 judge.model 里指定'
const REWRITE = '裁判认为 2 句结论证据不支持(「增长主要来自新客」「退款多数是部分退款」),已交回写作者只改这几句'
const REJECTED = '改写稿冒出 1 处原稿没有的问题(「12」),没有采用,保留原稿和原来的判定'
const events = [
E(1, 'run.started', null, { total: 1 }),
E(2, 'node.started', 'write', { node_type: 'report', label: '写周报' }),
E(3, 'llm.start', 'write', { model: 'wm-1' }),
E(4, 'llm.end', 'write', { model: 'wm-1', duration_ms: 900 }),
E(5, 'llm.start', 'write', { model: 'jm-x', structured: true, purpose: 'judge', message_count: 2, units: 4 }),
E(6, 'llm.end', 'write', { model: 'jm-x', purpose: 'judge', duration_ms: 2100, input_tokens: 900, output_tokens: 300, units: 4 }),
E(7, 'log', 'write', { level: 'warn', code: 'judge_unpriced', message: UNPRICED }),
E(8, 'log', 'write', { level: 'warn', code: 'judge_same_model', message: SAME }),
E(9, 'log', 'write', { level: 'warn', code: 'judge_limit', limits: ['max_cost_usd'], unjudged: 3, message: LIMIT }),
E(10, 'log', 'write', { level: 'warn', code: 'judge_failed', message: FAILED }),
E(11, 'log', 'write', { level: 'warn', code: 'report_rewrite', message: REWRITE }),
E(12, 'log', 'write', { level: 'warn', code: 'report_rewrite_rejected', message: REJECTED }),
E(13, 'report.checked', 'write', { doc_artifact: 'doc-1', stats: { numbers: 3, numbers_cited: 3, uncited_numbers: 0, unresolved: 0 },
violations: [], repairs: 0, claims: 'judge',
judge: { mode: 'inline', counts: { supported: 1, partial: 1, unsupported: 1, not_a_claim: 1, unjudged: 1 } } }),
E(14, 'node.finished', 'write', { duration_ms: 4000 }),
E(15, 'run.finished', null, { status: 'succeeded' }),
// 探索运行跑完之后有人点开结论句请模型判断:判定追加在封存之后
E(16, 'evidence.judged', 'write', { report: 'write', doc_artifact: 'doc-1', units: ['u2'], model: 'jm-x',
verdicts: { u2: { status: 'unsupported', rationale: '看不出', judge: 'jm-x', post_seal: true } }, limits_hit: [], gaps: [],
post_seal: true }),
]
const all = flatten(mod.decodeRun(events))
check('新事件都有翻译', !all.some((s) => s.title === '一条还没翻译的记录'), all.filter((s) => s.title === '一条还没翻译的记录').map((s) => s.raw?.type).join(','))
const llms = all.filter((s) => s.kind === 'llm')
check('写作照旧叫「思考并作答」,裁判那次单独起名、说判几句', llms.map((s) => s.title).join('|') === '思考并作答|请裁判模型判断 4 句结论',
llms.map((s) => s.title).join('|'))
check('……裁判那次跑完了、带耗时和模型', llms[1]?.status === 'done' && llms[1]?.meta === '2.1 s' && llms[1]?.code === 'judge'
&& llms[1]?.detail === '模型:jm-x', `${llms[1]?.status} ${llms[1]?.meta} ${llms[1]?.code} ${llms[1]?.detail}`)
const by = (code) => all.find((s) => s.code === code)
check('judge_limit:几句没判、哪个上限、怎么调', by('judge_limit')?.title === '结论句裁判已到上限,3 句没判(记为未裁判,已判的保留)'
&& by('judge_limit')?.sub === '这份报告的裁判金额上限 $0.05' && !!by('judge_limit')?.next?.includes('不限'),
`${by('judge_limit')?.title} / ${by('judge_limit')?.sub} / ${by('judge_limit')?.next}`)
check('judge_failed:没跑完、原因放副标题', by('judge_failed')?.title === '结论句裁判没跑完,没判的记为未裁判'
&& by('judge_failed')?.sub === '有 2 句结论没裁判:裁判调用失败(ConnectError: 连不上)', `${by('judge_failed')?.title} / ${by('judge_failed')?.sub}`)
check('judge_unpriced:点名模型、金额上限不起作用', by('judge_unpriced')?.title === '裁判模型「jm-x」估不出金额,金额上限对它不起作用',
by('judge_unpriced')?.title)
check('judge_same_model:自己审自己、怎么改', by('judge_same_model')?.title === '裁判模型和写作模型都是「wm-1」:等于自己审自己'
&& !!by('judge_same_model')?.next?.includes('证据裁判'), `${by('judge_same_model')?.title} / ${by('judge_same_model')?.next}`)
check('report_rewrite:几句不支持、交回改写,句子放副标题', by('report_rewrite')?.title === '裁判认为 2 句结论证据不支持,已交回写作者只改这几句'
&& by('report_rewrite')?.sub === '「增长主要来自新客」「退款多数是部分退款」', `${by('report_rewrite')?.title} / ${by('report_rewrite')?.sub}`)
check('report_rewrite_rejected:冒出几处新问题、没有采用', by('report_rewrite_rejected')?.title === '改写稿冒出 1 处原稿没有的问题,没有采用'
&& by('report_rewrite_rejected')?.sub === '「12」' && by('report_rewrite_rejected')?.next === '保留的是原稿和原来的判定',
`${by('report_rewrite_rejected')?.title} / ${by('report_rewrite_rejected')?.sub}`)
check('几条警告都是提醒(warn),原话留在展开区', ['judge_limit', 'judge_failed', 'judge_unpriced', 'judge_same_model', 'report_rewrite',
'report_rewrite_rejected'].every((c) => by(c)?.level === 'warn' && by(c)?.detail))
const rc = all.find((s) => s.code === 'report_checked')
check('报告核对那一行也数结论句,和出具横幅同一种说法(不是结论句的不算)',
rc?.title === '核对报告:3 个数字都有出处 · 结论 4 句(支持 1 · 部分支持 1 · 不支持 1 · 未裁判 1)', rc?.title)
const ej = by('evidence_judged')
check('封存后按需裁判:单独一行,写明封存后追加、模型判断非确定', ej?.title === '封存后按需裁判了 1 句结论'
&& ej?.sub === '封存后追加 · 模型判断,非确定' && ej?.level === 'info', `${ej?.title} / ${ej?.sub} / ${ej?.level}`)
const failed = flatten(mod.decodeRun([
E(1, 'node.started', 'write', { node_type: 'report' }),
E(2, 'llm.start', 'write', { model: 'jm-x', structured: true, purpose: 'judge', message_count: 2, units: 2 }),
E(3, 'llm.end', 'write', { model: 'jm-x', purpose: 'judge', duration_ms: 30000, units: 2, error: '已到上限(裁判时长上限 30 秒),这句没判' }),
E(4, 'node.finished', 'write', { duration_ms: 31000 }),
E(5, 'evidence.judged', 'write', { verdicts: { u4: { status: 'unjudged', reason: 'max_cost_usd' } }, limits_hit: ['max_cost_usd'] }),
]))
const jx = failed.find((s) => s.kind === 'llm')
check('裁判调用没跑成:那一行说「没跑成」,是提醒不是失败,原因放展开区', jx?.title === '结论句裁判没跑成' && jx?.status === 'done'
&& jx?.level === 'warn' && !!jx?.detail?.includes('裁判时长上限'), `${jx?.title} ${jx?.status} ${jx?.level}`)
check('……报告节点照样成功', failed.find((s) => s.kind === 'node')?.status === 'done')
const late = failed.find((s) => s.code === 'evidence_judged')
check('按需裁判触顶:写明有的到了上限没判(提醒)', late?.title === '封存后按需裁判了 0 句结论,有的到了上限没判' && late?.level === 'warn',
`${late?.title} ${late?.level}`)
const odd = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report' }),
E(2, 'log', 'w', { level: 'warn', code: 'judge_limit', message: '到上限了' }),
E(3, 'log', 'w', { level: 'warn', code: 'report_rewrite' }),
E(4, 'report.checked', 'w', { stats: { numbers: 1, numbers_cited: 1 }, judge: { counts: 'x' } })]))
check('别的说法(缺字段):标题照样说人话,不出 undefined、不瞎填数', odd.find((s) => s.code === 'judge_limit')?.title === '结论句裁判已到上限(记为未裁判,已判的保留)'
&& !odd.some((s) => /undefined|NaN/.test(`${s.title}${s.sub ?? ''}`)), odd.map((s) => s.title).join(' | '))
check('……裁判摘要认不出时报告核对那一行只数数字', odd.find((s) => s.code === 'report_checked')?.title === '核对报告:1 个数字都有出处',
odd.find((s) => s.code === 'report_checked')?.title)
})
await section('术语', async () => {
// 没起名的节点退到类型名,类型名跟全站同一张表
const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }])