mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:51:58 +00:00
期 4 复核修正(第 1 轮,前端):核查缺口分「核查未通过」「核查未完成」两组并补上证据页、印章的检查,写目录预告推断时算上被挡下的同项指标,反方审稿概况的说法与排布改准
- 出具缺口:双盲复算的「指标无法复算」是没核成,不再和两路不一致、结论被推翻一起叫「核查未通过」——splitIssuanceGaps 分成 failed「核查未通过」、unfinished「核查未完成」两组,时间线那一行、证据页、成果节点印章的悬停说明都按先未通过、再未完成、最后校验不完整排。check-decode 按服务端 GAP_* 和 _refuted_gap 的原话加出具契约的前缀拼出缺口再分组,服务端改了说法先变红;新预览用例 verify-gaps 画真的 EvidencePane 和成果节点卡片,check-verify 断言两组各列各的、横幅的「校验未全部完成」里没有它们、印章说明的先后,以及 390px 深浅色不溢出、可读(以前去掉这两处的实现所有检查照样全绿) - 时间线:无法复算那一行只说「已记为缺口」,不再一律说「按缺口计入出具」(探索运行不出具,接在侧枝上的缺口进不了出具);dual_check_catalog 带值的写法补上检查,和审批卡「会记为」用同一个函数、一字不差 - 双盲复算审批卡:工件里没有合并预告时(老工件、组合超上限、还没取到),被目录挡下、带着写到哪的同项指标也算进来——业务日期、去重规则说法不同就说都不记(服务端先归拢、轮不到对目录),有效记录条件同项上有被挡下的只说可能记、也可能不记;推出来的条目标 data-inferred,结尾换成「实际写入以时间线上的记录为准」。check-verify 用新夹具 time_rejected 断言销售额不再说「会记为」 - 反方审稿审批卡:「没能」改「未能」,提示不再说「没有真正查询数据」(触顶的查过),原因补上报告未引用数据库查询,时间线的下一步不再建议调高上限(这一条不含触顶的);「N 句未能反查」挪到降档说明后面,不再插在「出具会降档」标记和它的说明中间,句数不超过「无法判断」;不在出具路径上的那句写明是反方审稿节点(审批卡上的「本节点」读着像审批节点)。ChallengeSummary 收 runClass,给了就照它、不另取;check-challenge 的夹具改成和服务端约定一致(未能反查 2 句、无法判断 3 句),另断言给了运行类别时另取失败也照样写降档、不发请求
This commit is contained in:
@@ -898,12 +898,14 @@ function NodeCardImpl({ id, data, selected, isConnectable }: NodeProps<FlowNode>
|
||||
const tier = view.at != null && issuedAt != null && view.at < issuedAt ? undefined : issuance?.tier
|
||||
const tierClass = tier === 'formal' ? 'is-formal' : tier === 'degraded' ? 'is-degraded' : tier === 'withheld' ? 'is-withheld' : ''
|
||||
const detail = issuanceDetail?.tier ? issuanceDetail : undefined
|
||||
// 双盲复算、反方审稿报上来的缺口是核查查出来的问题(两路不一致、结论被推翻),和「校验没跑全」分开说
|
||||
const { verify: verifyGaps, other: gaps } = splitIssuanceGaps(Array.isArray(detail?.gaps) ? detail.gaps.map(String) : [])
|
||||
// 双盲复算、反方审稿报上来的缺口和「校验没跑全」分开说,再分查出了问题(两路不一致、结论被推翻)和没核成(无法复算)
|
||||
const { failed: verifyFailed, unfinished: verifyUnfinished, other: gaps } =
|
||||
splitIssuanceGaps(Array.isArray(detail?.gaps) ? detail.gaps.map(String) : [])
|
||||
const stampTitle = tier ? [
|
||||
`出具判定:${issuanceLabel(tier)}`,
|
||||
detail ? `可追溯 ${detail.matched_numbers ?? 0} 个数字 / 核对 ${detail.metrics_checked ?? 0} 个指标` : '',
|
||||
verifyGaps.length ? `${DUAL_CHECK_TEXT.verifyGaps.head}:${verifyGaps.join(';')}` : '',
|
||||
verifyFailed.length ? `${DUAL_CHECK_TEXT.verifyGaps.failed}:${verifyFailed.join(';')}` : '',
|
||||
verifyUnfinished.length ? `${DUAL_CHECK_TEXT.verifyGaps.unfinished}:${verifyUnfinished.join(';')}` : '',
|
||||
// 降档常常不是数字对不上,而是校验根本没跑全(叙述模板渲染为空、指标集为空):
|
||||
// 不写出来,悬停在一个「降档出具」上看不到任何理由
|
||||
gaps.length ? `校验未全部完成:${gaps.join(';')}` : '',
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"dup": "run-chal-dup",
|
||||
"norecord": "run-chal-norecord",
|
||||
"unreviewed": "run-chal-unreviewed",
|
||||
"offpath": "run-chal-offpath"
|
||||
"offpath": "run-chal-offpath",
|
||||
"over": "run-chal-over",
|
||||
"given": "run-chal-given"
|
||||
},
|
||||
"report": {
|
||||
"node_id": "write",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ReactFlow, ReactFlowProvider } from '@xyflow/react'
|
||||
import { Inspector } from '../../canvas/Inspector'
|
||||
import { NodeCard } from '../../canvas/NodeCard'
|
||||
import { ApprovalCard } from '../../run/RunPanel'
|
||||
import { ChallengeSummary } from '../../run/ChallengeSummary'
|
||||
import { useCatalog } from '../../store/catalog'
|
||||
import { useStudio } from '../../store/studio'
|
||||
import type { Approval, ApprovalChallenge, ApprovalReport, Provider, Workflow } from '../../types'
|
||||
@@ -22,9 +23,16 @@ import fx from './challenge.json'
|
||||
* - ?syn=challenge-approval-dense:同第一个,摆在 360px 的右栏里
|
||||
* - ?syn=challenge-approval-clear / -clear-flag:正式运行,没有被推翻的结论(降档 / 只标注),两种都不写降档、不写只标注
|
||||
* - ?syn=challenge-approval-explore-flag:探索运行配只标注,同样不说任何和出具有关的话
|
||||
* - ?syn=challenge-approval-runfail:运行类别取不到(检查脚本先答 500),不说任何和出具有关的话
|
||||
* - ?syn=challenge-approval-unreviewed:正式运行,四句里有两句没能反查(unreviewed),被推翻的会降档(degrades)
|
||||
* - ?syn=challenge-approval-runfail:老服务端的审批(不带 run_class),运行类别也取不到(检查脚本先答 500):不说任何
|
||||
* 和出具有关的话。审批带着运行类别时照它说、不另取,见 challenge-summary-given
|
||||
* - ?syn=challenge-approval-unreviewed:正式运行,四句里有两句未能反查(unreviewed:被削弱、经受住的那两句换成数据源
|
||||
* 已停用、记为无法判断,计数跟着改成无法判断 3 句;工件由检查脚本按同样的改法合成),被推翻的会降档(degrades)
|
||||
* - ?syn=challenge-approval-unreviewed-over:服务端给的 unreviewed(5)比「无法判断」的句数(1)还多(不该出现):
|
||||
* 卡上按「无法判断」的句数说,两个数不打架
|
||||
* - ?syn=challenge-approval-offpath:正式运行,按降档处置,但反方审稿不在带出具契约的成果节点上游(degrades 为假)
|
||||
* - ?syn=challenge-summary-given:反方审稿那一块单独画,审批自带的运行类别(正式运行)由调用方传进来;检查脚本让这次运行的
|
||||
* GET 失败,卡上照样写「出具会降档」,也不另发这次请求。审批卡(RunPanel 的 ApprovalCard)把 approval.run_class
|
||||
* 传给它之后,卡上走的就是这条路
|
||||
* - ?syn=challenge-approval-dup:同 many,工件里补出来的一句和条目里的一句原文相同、unit 不同(工件由检查脚本合成)
|
||||
* - ?syn=challenge-approval-norecord:同 many,审稿记录没存上(artifact 为 null)
|
||||
*
|
||||
@@ -39,12 +47,13 @@ import fx from './challenge.json'
|
||||
const report = fx.report as unknown as ApprovalReport
|
||||
const challenge = fx.challenge as unknown as ApprovalChallenge
|
||||
|
||||
function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', reviewed: ApprovalReport): Approval {
|
||||
/** runClass 为 null:老服务端的审批,不带运行类别 */
|
||||
function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory' | null, reviewed: ApprovalReport): Approval {
|
||||
return {
|
||||
id, run_id: runId, node_id: 'review', mode: 'approve', title: '本月月报可以发出吗?', status: 'pending', response: {},
|
||||
payload: { kind: 'human_node', message: '请审核本月月报。', reports: [reviewed] },
|
||||
created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''),
|
||||
workflow_name: '月报', node_label: '主管审批', run_class: runClass,
|
||||
workflow_name: '月报', node_label: '主管审批', ...(runClass ? { run_class: runClass } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +77,18 @@ const clear: Partial<ApprovalChallenge> = {
|
||||
counts: { stands: 1, weakened: 1, refuted: 0, inconclusive: 1 },
|
||||
items: challenge.items.filter((it) => it.verdict !== 'refuted'),
|
||||
}
|
||||
/**
|
||||
* 有两句未能反查:被削弱(u5)、经受住(u8)的那两句换成数据源已停用、没查成,记为无法判断(服务端未能反查的每一句都
|
||||
* 记为无法判断,unreviewed 不会多于 inconclusive)。原来无法判断的 u11 是查过了、数据不够,不算未能反查
|
||||
*/
|
||||
const UNREVIEWED_UNITS = ['u5', 'u8']
|
||||
const unreviewed: Partial<Challenge> = {
|
||||
counts: { stands: 0, weakened: 0, refuted: 1, inconclusive: 3 },
|
||||
items: challenge.items.map((it) => (UNREVIEWED_UNITS.includes(it.unit) ? { ...it, verdict: 'inconclusive' } : it)),
|
||||
artifact: 'f'.repeat(64),
|
||||
unreviewed: 2,
|
||||
degrades: true,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 检查器
|
||||
|
||||
@@ -201,7 +222,7 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
approval={approvalOf('ap-chal-10', RUNS.exploreFlag, 'exploratory', withChallenge({ on_refuted: 'flag' }))} />
|
||||
),
|
||||
'challenge-approval-runfail': () => (
|
||||
<Card name="challenge-approval-runfail" approval={approvalOf('ap-chal-11', RUNS.runfail, 'formal', withChallenge({}))} />
|
||||
<Card name="challenge-approval-runfail" approval={approvalOf('ap-chal-11', RUNS.runfail, null, withChallenge({}))} />
|
||||
),
|
||||
'challenge-approval-dup': () => (
|
||||
<Card name="challenge-approval-dup"
|
||||
@@ -213,7 +234,16 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
),
|
||||
'challenge-approval-unreviewed': () => (
|
||||
<Card name="challenge-approval-unreviewed"
|
||||
approval={approvalOf('ap-chal-14', RUNS.unreviewed, 'formal', withChallenge({ unreviewed: 2, degrades: true }))} />
|
||||
approval={approvalOf('ap-chal-14', RUNS.unreviewed, 'formal', withChallenge(unreviewed))} />
|
||||
),
|
||||
'challenge-approval-unreviewed-over': () => (
|
||||
<Card name="challenge-approval-unreviewed-over"
|
||||
approval={approvalOf('ap-chal-16', RUNS.over, 'formal', withChallenge({ unreviewed: 5, degrades: true }))} />
|
||||
),
|
||||
'challenge-summary-given': () => (
|
||||
<div className="mx-auto max-w-3xl p-4" data-fixture="challenge-summary-given">
|
||||
<ChallengeSummary reports={[withChallenge({ degrades: true })]} runId={RUNS.given} runClass="formal" />
|
||||
</div>
|
||||
),
|
||||
'challenge-approval-offpath': () => (
|
||||
<Card name="challenge-approval-offpath"
|
||||
|
||||
@@ -4,9 +4,10 @@ import { ReactFlow, ReactFlowProvider } from '@xyflow/react'
|
||||
import { ApprovalCard } from '../../run/RunPanel'
|
||||
import { Inspector } from '../../canvas/Inspector'
|
||||
import { NodeCard } from '../../canvas/NodeCard'
|
||||
import { EvidencePane } from '../../pages/runs/EvidencePane'
|
||||
import { toFlow, useStudio } from '../../store/studio'
|
||||
import { useCatalog } from '../../store/catalog'
|
||||
import type { Approval, GraphSpec, Provider } from '../../types'
|
||||
import type { Approval, GraphSpec, Provider, Run, RunEvent } from '../../types'
|
||||
import data from './dual_check.json'
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,10 @@ import data from './dual_check.json'
|
||||
* - merge=<名字>:两个指标的预告换成 merges 里那一种组合按它的目录算的(catalog;和 variant、peer 一起用,换上的 SQL
|
||||
* 照它的 gmv、refund_rate 写)。合起来的预告(joint)在完整对照里,由检查脚本按同一种组合伪造工件
|
||||
* - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」
|
||||
*
|
||||
* ?syn=verify-gaps:出具缺口里有双盲复算、反方审稿报上来的几条(两路结果不一致、结论被推翻、指标无法复算)和一条
|
||||
* 校验没做完的。上面是运行详情的证据页(真的 EvidencePane:证据图、审计接口由检查脚本伪造),下面是同一次运行的成果
|
||||
* 节点卡片(出具印章的悬停说明):两处都把核查的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下
|
||||
*/
|
||||
const approval = (patch: Partial<Approval> = {}): Approval => ({
|
||||
...(data.approval as unknown as Approval),
|
||||
@@ -103,6 +108,73 @@ function InspectorCase() {
|
||||
)
|
||||
}
|
||||
|
||||
/** verify-gaps 用例的运行和出具明细(库、指标、地名都是虚构的) */
|
||||
const VERIFY_GAPS = {
|
||||
run: 'run-verify-gaps',
|
||||
gaps: {
|
||||
failed: ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'],
|
||||
unfinished: ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算'],
|
||||
other: ['叙述模板渲染为空(路径可能有误)'],
|
||||
},
|
||||
}
|
||||
const GAPS = VERIFY_GAPS.gaps
|
||||
const GAPS_ISSUANCE = {
|
||||
tier: 'degraded', matched_numbers: 3, metrics_checked: 2, missing_required: [], missing_expected: [], unmatched_numbers: [],
|
||||
// 缺口的先后照服务端(出具契约按上游节点的先后收):核查的和别的混在一起,分组靠前缀认
|
||||
gaps: [GAPS.failed[0], GAPS.other[0], GAPS.unfinished[0], GAPS.failed[1]],
|
||||
}
|
||||
const GAPS_RUN: Run = {
|
||||
id: VERIFY_GAPS.run, workflow_id: null, workflow_name: '周报(预览)', status: 'succeeded', input: {},
|
||||
output: { _issuance: GAPS_ISSUANCE }, error: null, usage: {} as Run['usage'], run_class: 'formal',
|
||||
}
|
||||
const GAPS_GRAPH = {
|
||||
nodes: [
|
||||
{ id: 'dc', type: 'dual_check', position: { x: 0, y: 40 }, data: { label: '复核周报口径', config: {} } },
|
||||
{ id: 'done', type: 'output', position: { x: 300, y: 40 }, data: { label: '成果', config: {} } },
|
||||
],
|
||||
edges: [{ id: 'g1', source: 'dc', target: 'done' }],
|
||||
} as unknown as GraphSpec
|
||||
|
||||
/**
|
||||
* 证据页和成果节点卡片:图放进 studio store,再按一次运行的事件灌进去(出具事件带着完整的明细,卡片的印章按它画、
|
||||
* 悬停说明按它写)。只在这个用例渲染时改 store
|
||||
*/
|
||||
function VerifyGapsCase() {
|
||||
useState(() => {
|
||||
const { nodes, edges } = toFlow(GAPS_GRAPH)
|
||||
const st = useStudio.getState()
|
||||
st.clearRun()
|
||||
useStudio.setState({ nodes, edges, selectedId: null, run: { ...GAPS_RUN, status: 'running', output: {} }, runSnapshot: null })
|
||||
const at = Date.now() / 1000 - 5
|
||||
const events: RunEvent[] = [
|
||||
{ seq: 1, type: 'run.started', node_id: null, ts: at, data: { nodes: 2 } },
|
||||
{ seq: 2, type: 'node.started', node_id: 'dc', ts: at + 0.1, data: {} },
|
||||
{ seq: 3, type: 'node.finished', node_id: 'dc', ts: at + 1, data: { duration_ms: 900 } },
|
||||
{ seq: 4, type: 'node.started', node_id: 'done', ts: at + 1.1, data: {} },
|
||||
{ seq: 5, type: 'issuance', node_id: 'done', ts: at + 1.2, data: { ...GAPS_ISSUANCE, unmatched: 0 } },
|
||||
{ seq: 6, type: 'node.finished', node_id: 'done', ts: at + 1.3, data: { duration_ms: 200 } },
|
||||
{ seq: 7, type: 'run.finished', node_id: null, ts: at + 1.4, data: { output: { _issuance: GAPS_ISSUANCE }, duration_ms: 1400 } },
|
||||
]
|
||||
for (const e of events) useStudio.getState().applyEvent(e)
|
||||
return true
|
||||
})
|
||||
const nodes = useStudio((s) => s.nodes)
|
||||
const edges = useStudio((s) => s.edges)
|
||||
return (
|
||||
<div className="flex min-h-screen max-w-full flex-col" data-fixture="verify-gaps">
|
||||
<div className="h-[520px] min-h-0 border-b" data-verify-gaps-evidence="">
|
||||
<EvidencePane run={GAPS_RUN} output={GAPS_RUN.output} labelOf={() => undefined} refreshKey="" />
|
||||
</div>
|
||||
<div className="h-64 min-h-0 min-w-0" data-verify-gaps-canvas="">
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView nodesDraggable={false} nodesConnectable={false}
|
||||
proOptions={{ hideAttribution: true }} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const cases: Record<string, () => JSX.Element> = {
|
||||
'dual-check-approval': () => (
|
||||
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval"><Card value={approvalOf(location.search)} /></div>
|
||||
@@ -123,4 +195,5 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
</div>
|
||||
),
|
||||
'dual-check-inspector': () => <InspectorCase />,
|
||||
'verify-gaps': () => <VerifyGapsCase />,
|
||||
}
|
||||
|
||||
+46
-18
@@ -3336,8 +3336,8 @@ export const WHATIF_TEXT = {
|
||||
|
||||
/**
|
||||
* 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算的日志。核查的上限叫法
|
||||
* (limitLabel)和出具缺口里「核查未通过」那一组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的时间线说法在
|
||||
* CHALLENGE_TEXT.log。
|
||||
* (limitLabel)和出具缺口里「核查未通过」「核查未完成」那两组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的
|
||||
* 时间线说法在 CHALLENGE_TEXT.log。
|
||||
*
|
||||
* 两边的叫法固定为「原流程」「盲算」(服务端 explain_sql_diff 的 labels 也是这两个词),审核人的三个选择固定为
|
||||
* 「采用原流程的结果」「采用盲算的结果」「两边都不对」。选「采用盲算的结果」不会替换报告里的数,只记缺口,
|
||||
@@ -3492,8 +3492,9 @@ export const DUAL_CHECK_TEXT = {
|
||||
* 同一次裁决里几个指标按当前的选择落到同一张表同一项上时,服务端写目录前把它们归拢(dual_check_facts._merged):
|
||||
* 说法相同的写一次,有效记录条件用 AND 合成一句,业务日期、去重规则说法不同的都不写,驳回过的拿出来不写。对照工件
|
||||
* 里有服务端把每种组合算好的结果(catalog_joint)时照它说:会写的是合起来的那个值,并写明和哪几个指标一起记
|
||||
* (together);没有时(老服务端、组合太多、工件还没取到)按其余指标当前的选择有条件地说(conflict、joined),
|
||||
* tail 里说明预告是按当前的选择算的
|
||||
* (together);没有时(老服务端、组合太多、工件还没取到)按其余指标当前的选择推(conflict、joined、pending),
|
||||
* 被目录挡下的同项指标也算进来,推不准的(pending)只说可能,结尾换成 tailInferred:推出来的不是服务端算好的结果,
|
||||
* 不能和 tail 一样只在「审批期间目录有改动」时才不准
|
||||
*/
|
||||
catalogPreview: {
|
||||
head: '记入数据目录:',
|
||||
@@ -3512,10 +3513,22 @@ export const DUAL_CHECK_TEXT = {
|
||||
conflictSame: (choice: string, metrics: string) => `${choice}时与${metrics}的选择说法不同,都不记`,
|
||||
/** 有效记录条件接在 write / writeSame 后面:和这些指标当前采用的条件合成一句写 */
|
||||
joined: (metrics: string) => `,与${metrics}采用的条件用 AND 合成一句`,
|
||||
/**
|
||||
* 没有算好的合并结果、同一项上又有被目录挡下的指标(有效记录条件):挡下的原因卡上认不出(驳回过的会被拿掉,
|
||||
* 别的原因会连这一边一起挡下),写不写说不准,只说可能。value 同 write
|
||||
*/
|
||||
pending: (choice: string, where: string, value: string, metrics: string) =>
|
||||
`${choice}时,${where}可能记为「${value}」,也可能不记:要与${metrics}的选择合起来才能确定`,
|
||||
/** 同 writeSame:两边写的是同一项时,后一边省掉「哪张表的哪一项」 */
|
||||
pendingSame: (choice: string, value: string, metrics: string) =>
|
||||
`${choice}时可能记为「${value}」,也可能不记:要与${metrics}的选择合起来才能确定`,
|
||||
/** 「退款率」、「订单数」;多于 3 个只列前 3 个 */
|
||||
metricNames: (names: string[]) => names.slice(0, 3).map((n) => `「${n}」`).join('、')
|
||||
+ (names.length > 3 ? `等 ${names.length} 个指标` : ''),
|
||||
tail: '写入的项记为人工确认的事实。以上按发起审批时的数据目录和各指标当前的选择预告,审批期间数据目录有改动时,以时间线上的记录为准',
|
||||
/** 有一条是按其余指标当前的选择推出来的(没有算好的合并结果):推断不一定准,实际写入一律以时间线为准 */
|
||||
tailInferred: '写入的项记为人工确认的事实。几个指标落到同一项上时,以上是按各指标当前的选择推断的结果,'
|
||||
+ '实际写入以时间线上的记录为准',
|
||||
},
|
||||
choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>,
|
||||
choiceGroup: (name: string) => `「${name}」采用哪一边的结果`,
|
||||
@@ -3589,17 +3602,23 @@ export const DUAL_CHECK_TEXT = {
|
||||
/** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */
|
||||
limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`),
|
||||
limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限',
|
||||
/** dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列) */
|
||||
unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,按缺口计入出具`
|
||||
: '双盲复算有指标无法复算,按缺口计入出具'),
|
||||
/**
|
||||
* dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列)。只说记为缺口,
|
||||
* 不说出具:探索运行不出具,节点接在侧枝上时缺口也进不了出具,降不降档由出具那一行说(同 gaps)
|
||||
*/
|
||||
unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,已记为缺口`
|
||||
: '双盲复算有指标无法复算,已记为缺口'),
|
||||
uncheckedNext: '无法复算的原因见展开内容。来自合并查询、遮罩列的输入无法单独复算;盲算触顶、出错的,可调高「设置 → 偏好设置 → 核查」中的上限后重新运行',
|
||||
},
|
||||
/**
|
||||
* 出具缺口里双盲复算、反方审稿报上来的那几条(缺口以「双盲复算「节点名」:」「反方审稿:」开头):时间线、成果卡片、
|
||||
* 证据页把它们另起一组,不和「校验没跑全」混在一起——它们是核查查出来的问题,不是校验没做完
|
||||
* 证据页把它们另起,不和「校验没跑全」混在一起,再按是查出了问题还是没核成分两组(run/decode.ts 的 splitIssuanceGaps):
|
||||
* failed 是核查查出来的问题(两路结果不一致、结论被推翻),unfinished 是没核成的(指标无法复算:合并查询、遮罩列的
|
||||
* 输入做不了复算,盲算触顶、出错)——没核过的数不能说成「未通过」,读的人会以为这个数被查出了错
|
||||
*/
|
||||
verifyGaps: {
|
||||
head: '核查未通过',
|
||||
failed: '核查未通过',
|
||||
unfinished: '核查未完成',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3689,11 +3708,17 @@ export const CHALLENGE_TEXT = {
|
||||
degrade: '出具会降档',
|
||||
degradeHint: '被推翻的结论计入出具缺口,本次正式运行将降档出具',
|
||||
flagNote: '按节点设置,被推翻的结论只做标注,不影响出具档位',
|
||||
/** 按降档处置,但节点不在带出具契约的成果节点上游(服务端的 degrades 为假):缺口进不了出具 */
|
||||
degradeOff: '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位',
|
||||
/** 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了等,这几句根本没查成,只是记为无法判断 */
|
||||
unreviewed: (n: number) => `${formatNumber(n)} 句没能反查,已记为无法判断`,
|
||||
unreviewedHint: '核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线',
|
||||
/**
|
||||
* 按降档处置,但反方审稿节点不在带出具契约的成果节点上游(服务端的 degrades 为假):缺口进不了出具。这句画在人工审批
|
||||
* 的卡上(画布上也会在审批节点旁弹出),主语写明是反方审稿节点,不说「本节点」——那里的本节点是审批节点
|
||||
*/
|
||||
degradeOff: '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位',
|
||||
/**
|
||||
* 服务端的 unreviewed:反查没有完成的句数(核查模型不可用、触顶没给出结论、数据源已不存在或已停用、报告没有引用
|
||||
* 数据库查询……),都记为无法判断。触顶的可能已经查过几次,不能说成「没有查询数据」;不会多于「无法判断」的句数
|
||||
*/
|
||||
unreviewed: (n: number) => `${formatNumber(n)} 句未能反查,已记为无法判断`,
|
||||
unreviewedHint: '反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,或报告未引用数据库查询,原因见运行时间线',
|
||||
/** 审批里没带工件(服务端保存审稿记录失败):每句都看不了理由和查询,整块说一次 */
|
||||
noRecord: '反方审稿记录未保存,无法查看理由和查询',
|
||||
more: (n: number) => `另有 ${formatNumber(n)} 句未列出`,
|
||||
@@ -3734,10 +3759,13 @@ export const CHALLENGE_TEXT = {
|
||||
refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论',
|
||||
limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`),
|
||||
limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限',
|
||||
/** challenge_unreviewed:有句子没能反查(核查模型不可用、数据源不在了……),记为无法判断 */
|
||||
unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论没能反查,已记为无法判断`
|
||||
: '反方审稿有结论没能反查,已记为无法判断'),
|
||||
unreviewedNext: '这几句没有真正查询数据。请检查核查模型和数据源是否可用,或在「设置 → 偏好设置 → 核查」中调高上限后重新运行',
|
||||
/**
|
||||
* challenge_unreviewed:有句子未能反查(核查模型不可用、整理证据出错、报告没有引用数据库查询、数据源不在了、一条
|
||||
* 查询都没执行成功),记为无法判断。触顶的不在这里(由 challenge_limit 说),所以建议里不提调高上限
|
||||
*/
|
||||
unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论未能反查,已记为无法判断`
|
||||
: '反方审稿有结论未能反查,已记为无法判断'),
|
||||
unreviewedNext: '原因见展开内容。请检查核查模型和数据源是否可用,修正后重新运行;报告未引用数据库查询的结论无法反查',
|
||||
/** challenge_sources_lost:反查了,但该查的数据源有停用了的,判断不含它们的数据 */
|
||||
sourcesLost: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论只查了部分数据源` : '反方审稿有结论只查了部分数据源'),
|
||||
sourcesLostSub: (names: string[]) => `未能查询:${names.map((x) => `「${x}」`).join('、')}(已不存在或已停用),判断不含其中的数据`,
|
||||
|
||||
@@ -312,24 +312,31 @@ function useAudit(runId: string, key: string): AuditState {
|
||||
}
|
||||
|
||||
/**
|
||||
* 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的一组「核查未通过」。那几条是核查查出来的问题(两路结果不一致、
|
||||
* 结论被推翻、指标无法复算),横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来,
|
||||
* 紧跟在它下面单列,颜色和横幅里的缺口一样
|
||||
* 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」
|
||||
* (指标无法复算)。横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来,紧跟在它下面单列,
|
||||
* 颜色和横幅里的缺口一样(分组见 run/decode.ts 的 splitIssuanceGaps)。导出给预览用例(dev/fixtures/dual_check.tsx)
|
||||
*/
|
||||
function IssuanceWithChecks({ issuance, runClass }: { issuance: Record<string, any>; runClass?: string }) {
|
||||
const { verify, other } = useMemo(
|
||||
export function IssuanceWithChecks({ issuance, runClass }: { issuance: Record<string, any>; runClass?: string }) {
|
||||
const { failed, unfinished, other } = useMemo(
|
||||
() => splitIssuanceGaps(Array.isArray(issuance.gaps) ? issuance.gaps.map(String) : []), [issuance])
|
||||
const shown = useMemo(() => (verify.length ? { ...issuance, gaps: other } : issuance), [issuance, verify, other])
|
||||
const verify = failed.length + unfinished.length > 0
|
||||
const shown = useMemo(() => (verify ? { ...issuance, gaps: other } : issuance), [issuance, verify, other])
|
||||
const group = (kind: 'failed' | 'unfinished', gaps: string[]) => gaps.length > 0 && (
|
||||
<div data-evidence-verify-group={kind}>
|
||||
<div className="font-medium">{DUAL_CHECK_TEXT.verifyGaps[kind]}</div>
|
||||
<ul className="mt-0.5 space-y-0.5 pl-2 [overflow-wrap:anywhere]">
|
||||
{gaps.map((g) => <li key={g}>· {g}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="space-y-1.5" data-evidence-issuance="">
|
||||
<IssuanceBanner issuance={shown} runClass={runClass} />
|
||||
{verify.length > 0 && (
|
||||
<div className="rounded-lg border px-2 py-1.5 text-2xs leading-relaxed"
|
||||
{verify && (
|
||||
<div className="space-y-1 rounded-lg border px-2 py-1.5 text-2xs leading-relaxed"
|
||||
style={{ borderColor: 'var(--st-waiting)', color: 'var(--st-waiting)' }} data-evidence-verify-gaps="">
|
||||
<div className="font-medium">{DUAL_CHECK_TEXT.verifyGaps.head}</div>
|
||||
<ul className="mt-0.5 space-y-0.5 pl-2 [overflow-wrap:anywhere]">
|
||||
{verify.map((g) => <li key={g}>· {g}</li>)}
|
||||
</ul>
|
||||
{group('failed', failed)}
|
||||
{group('unfinished', unfinished)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,40 +14,50 @@ export interface ChallengeSummaryProps {
|
||||
/** 这张审批卡审的报告(payload.reports,和 ApprovalReports 拿到的是同一份) */
|
||||
reports: ApprovalReport[]
|
||||
runId: string
|
||||
/**
|
||||
* 这次运行的类别(审批自带的 approval.run_class,同 ApprovalReports 的 runClass)。给了就照它说、不再另取;没给
|
||||
* (老服务端的审批没有这一项)才按运行 id 取一次
|
||||
*/
|
||||
runClass?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),有句子根本没反查成时
|
||||
* 另起一行说「N 句没能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行
|
||||
* 过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。
|
||||
* 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),出具相关的那一句,
|
||||
* 有句子反查没完成时再说一行「N 句未能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方
|
||||
* 的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。
|
||||
*
|
||||
* 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。
|
||||
* 「出具会降档」只在正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 degrades 就照它(按降档处置、又接
|
||||
* 在带出具契约的成果节点上游才为真),老服务端没给时按「没写只标注」算。只标注的写一句不影响出具,按降档处置却不在出具
|
||||
* 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别不由审批卡传进来,按运行 id 从证据 store
|
||||
* 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新拿到焦点时再取)。
|
||||
* 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别照审批卡给的(runClass,审批自带的那一项,
|
||||
* 卡顶写的也是它);没给时按运行 id 从证据 store 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新
|
||||
* 拿到焦点时再取)。
|
||||
*
|
||||
* 审的报告都没有经过反方审稿时什么都不画,也不发任何请求,卡片和以前一样。
|
||||
*/
|
||||
/**
|
||||
* 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 没能反查的句数,degrades 被推翻的会不会让出具降档。
|
||||
* 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 未能反查的句数,degrades 被推翻的会不会让出具降档。
|
||||
* 老服务端都不给
|
||||
*/
|
||||
type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean }
|
||||
type RunClass = 'formal' | 'exploratory'
|
||||
const runClassOf = (v: unknown): RunClass | null => (v === 'formal' || v === 'exploratory' ? v : null)
|
||||
|
||||
export function ChallengeSummary({ reports, runId }: ChallengeSummaryProps) {
|
||||
export function ChallengeSummary({ reports, runId, runClass }: ChallengeSummaryProps) {
|
||||
const reviewed = useMemo(() => reports.flatMap((r) => {
|
||||
const challenge = challengeOf(r)
|
||||
return challenge ? [{ report: r, challenge }] : []
|
||||
}), [reports])
|
||||
if (!reviewed.length) return null
|
||||
return <ChallengeList reviewed={reviewed} runId={runId} />
|
||||
return <ChallengeList reviewed={reviewed} runId={runId} given={runClassOf(runClass)} />
|
||||
}
|
||||
|
||||
function ChallengeList({ reviewed, runId }: {
|
||||
reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string
|
||||
function ChallengeList({ reviewed, runId, given }: {
|
||||
reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string; given: RunClass | null
|
||||
}) {
|
||||
const run = useRunClass(runId)
|
||||
// 审批卡给了运行类别就照它,不另取:另取的那一次失败了,正式运行里的「出具会降档」会无端消失,而卡顶照样写着正式运行
|
||||
const fetched = useRunClass(given ? '' : runId)
|
||||
const run = given ?? fetched
|
||||
return (
|
||||
<>
|
||||
{reviewed.map(({ report, challenge }) => (
|
||||
@@ -104,11 +114,11 @@ function challengeOf(report: ApprovalReport): Challenge | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次运行的类别:取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具有关的话都不说(卡顶照审批自带的运行
|
||||
* 类别写着,这里再猜一个只会和它对不上)。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发),
|
||||
* 取到了正式运行,降档的提示随之出现
|
||||
* 这次运行的类别(审批卡没给时才取,runId 为空就不取):取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具
|
||||
* 有关的话都不说。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发),取到了正式运行,降档的提示
|
||||
* 随之出现
|
||||
*/
|
||||
function useRunClass(runId: string): 'formal' | 'exploratory' | null {
|
||||
function useRunClass(runId: string): RunClass | null {
|
||||
const slot = useEvidence((s) => (runId ? s.runClasses[runId] : undefined))
|
||||
const load = useEvidence((s) => s.loadRunClass)
|
||||
useEffect(() => { if (runId) void load(runId) }, [runId, load])
|
||||
@@ -119,7 +129,7 @@ function useRunClass(runId: string): 'formal' | 'exploratory' | null {
|
||||
window.addEventListener('focus', again)
|
||||
return () => window.removeEventListener('focus', again)
|
||||
}, [status, runId, load])
|
||||
return status === 'ok' && (slot?.data === 'formal' || slot?.data === 'exploratory') ? slot.data : null
|
||||
return status === 'ok' ? runClassOf(slot?.data) : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 取工件
|
||||
@@ -212,7 +222,7 @@ const focusLost = () => !document.activeElement || document.activeElement === do
|
||||
// ---------------------------------------------------------------- 卡片
|
||||
|
||||
function ChallengeBlock({ report, challenge, run }: {
|
||||
report: ApprovalReport; challenge: Challenge; run: 'formal' | 'exploratory' | null
|
||||
report: ApprovalReport; challenge: Challenge; run: RunClass | null
|
||||
}) {
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set())
|
||||
const [all, setAll] = useState(false)
|
||||
@@ -226,7 +236,8 @@ function ChallengeBlock({ report, challenge, run }: {
|
||||
const flagged = challenge.on_refuted === 'flag'
|
||||
// 被推翻的真会让出具降档:服务端给了就照它,老服务端没给时按「不是只标注」算
|
||||
const degrades = challenge.degrades ?? !flagged
|
||||
const unreviewed = challenge.unreviewed ?? 0
|
||||
// 未能反查的每一句都记为无法判断(服务端的约定),不会多于概况里的「无法判断」:对不上时按概况的数说,两个数不打架
|
||||
const unreviewed = Math.min(challenge.unreviewed ?? 0, counts.inconclusive)
|
||||
// 条目之外的几句(条目最多 10 条):要看全部时从工件里补上,排在后面,顺序照工件
|
||||
const extra = all && detail.data ? detail.data.claims.filter((c) => !items.some((it) => sameClaim(c, it))) : []
|
||||
const rows: ChallengeItem[] = [...items, ...extra]
|
||||
@@ -283,14 +294,8 @@ function ChallengeBlock({ report, challenge, run }: {
|
||||
) : (
|
||||
<div className="text-2xs text-dim" data-challenge-empty="">{CHALLENGE_TEXT.none}</div>
|
||||
)}
|
||||
{/* 根本没反查成的几句:「无法判断」里分不出查了数据不够和一条都没查,单独说一次 */}
|
||||
{unreviewed > 0 && (
|
||||
<p className="mt-0.5 text-2xs leading-relaxed" data-challenge-unreviewed={unreviewed}>
|
||||
<span className="font-medium" style={{ color: 'var(--st-waiting)' }}>{CHALLENGE_TEXT.unreviewed(unreviewed)}</span>
|
||||
<span className="text-dim">。{CHALLENGE_TEXT.unreviewedHint}</span>
|
||||
</p>
|
||||
)}
|
||||
{/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行) */}
|
||||
{/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行)。
|
||||
紧跟在概况那一行(「出具会降档」的标记)下面,中间不插别的话 */}
|
||||
{refuted && !flagged && degrades && (
|
||||
<div className="mt-0.5 text-2xs leading-relaxed text-dim" data-challenge-degrade-note="">{CHALLENGE_TEXT.degradeHint}</div>
|
||||
)}
|
||||
@@ -300,6 +305,13 @@ function ChallengeBlock({ report, challenge, run }: {
|
||||
{refuted && flagged && (
|
||||
<div className="mt-0.5 text-2xs leading-relaxed text-dim" data-challenge-flag="">{CHALLENGE_TEXT.flagNote}</div>
|
||||
)}
|
||||
{/* 反查没完成的几句:「无法判断」里分不出查了数据不够和反查没做完,单独说一次 */}
|
||||
{unreviewed > 0 && (
|
||||
<p className="mt-0.5 text-2xs leading-relaxed" data-challenge-unreviewed={unreviewed}>
|
||||
<span className="font-medium" style={{ color: 'var(--st-waiting)' }}>{CHALLENGE_TEXT.unreviewed(unreviewed)}</span>
|
||||
<span className="text-dim">。{CHALLENGE_TEXT.unreviewedHint}</span>
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-0.5" data-challenge-items="">
|
||||
{rows.map((item, i) => {
|
||||
|
||||
@@ -213,9 +213,15 @@ interface Clause {
|
||||
joint: boolean
|
||||
/** 合并结果里和别的指标一起记入 */
|
||||
together: boolean
|
||||
/** 没有算好的合并结果、按其余指标当前的选择推的:说法不同都不记(conflict)、有效记录条件合写(joined) */
|
||||
/**
|
||||
* 没有算好的合并结果、按其余指标当前的选择推的:说法不同都不记(conflict)、有效记录条件合写(joined)、同一项上有被
|
||||
* 目录挡下的指标,写不写说不准、只说可能(pending)。inferred:这一条是推出来的(以上几种,以及推的路上照单个指标的
|
||||
* 预告说的),结尾换成「以时间线上的记录为准」的一般说法
|
||||
*/
|
||||
conflict: boolean
|
||||
joined: boolean
|
||||
pending: boolean
|
||||
inferred: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,12 +230,20 @@ interface Clause {
|
||||
* 那一边认出了写到哪的;被目录挡下的也算——服务端照样并进来):
|
||||
* - 只有它自己:照它自己的预告说,会写的说会记为什么,不写的说原因;
|
||||
* - 还有别的:照对照工件里服务端为这几个成员算好的那一条(catalog_joint)说,会写的是合起来的值、写明和谁一起记,
|
||||
* 不写的照原因说;工件里没有(老服务端、组合太多、工件还没取到)时按旧办法推:会写的一边,业务日期、去重规则说法
|
||||
* 不同说都不记,有效记录条件说合成一句,结尾说以时间线上的记录为准
|
||||
* 不写的照原因说;工件里没有(老服务端、组合太多、工件还没取到)时照服务端归拢的先后推(inferred),被目录挡下的
|
||||
* 同项指标也算进来——服务端先归拢、后对目录:
|
||||
* - 业务日期、去重规则:有一个说法不同(不论它会不会被目录挡下),归拢就失败,都不记;说法都一样时结果和单个指标
|
||||
* 一样(同一个说法对同一份目录),照自己的预告说;
|
||||
* - 有效记录条件:自己被挡下的,合起来也写不进去(驳回过的会被拿掉,别的原因连合起来的整句一起挡下),照自己的原因说;
|
||||
* 同项上有别的指标被挡下时说不准(挡下它的若是驳回,拿掉它照写;若是别的原因,这一边也一起挡下),只说可能;
|
||||
* 都会写、条件不同的说合成一句
|
||||
* 推出来的不是服务端算好的结果,结尾换成以时间线上的记录为准的一般说法
|
||||
*/
|
||||
function clausesOf(view: MetricView, views: MetricView[], choices: Record<string, DualCheckChoice>, joint: JointEntry[]): Clause[] {
|
||||
const P = T.catalogPreview
|
||||
const blank = { write: false, where: false, reason: false, joint: false, together: false, conflict: false, joined: false }
|
||||
const blank = {
|
||||
write: false, where: false, reason: false, joint: false, together: false, conflict: false, joined: false, pending: false, inferred: false,
|
||||
}
|
||||
const skip = (side: DualCheckChoice): Clause => ({ side, text: P.skip(T.choices[side]), ...blank })
|
||||
const out: Clause[] = []
|
||||
for (const side of ['a', 'b'] as const) {
|
||||
@@ -266,19 +280,28 @@ function clausesOf(view: MetricView, views: MetricView[], choices: Record<string
|
||||
with_.length ? P.together(P.metricNames(with_)) : ''))
|
||||
continue
|
||||
}
|
||||
if (!mine.write) { out.push(blocked(mine.reason)); continue }
|
||||
// 没有算好的合并结果:照服务端归拢的规矩推。只看会写的那几个(挡下的说不准会怎样)
|
||||
const differ = [...new Set(peers.filter((o) => {
|
||||
const theirs = o.preview?.[choices[o.id] as 'a' | 'b']
|
||||
return theirs?.write && !sameFact(mine.target!.item, theirs.raw, mine.raw)
|
||||
}).map((o) => o.name))]
|
||||
if (differ.length && mine.target!.item !== 'valid_filter') {
|
||||
// 没有算好的合并结果:照服务端归拢的先后推。同项上的指标不论会写还是被挡下都带着本来要写的说法(raw),一起算
|
||||
const item = mine.target!.item
|
||||
const theirs = peers.map((o) => ({ name: o.name, side: o.preview![choices[o.id] as 'a' | 'b']! }))
|
||||
// 认不出本来要写什么(没带 raw)的不算说法不同:说不准,落到下面的「可能」里
|
||||
const differ = [...new Set(theirs.filter((t) => t.side.raw != null && !sameFact(item, t.side.raw, mine.raw)).map((t) => t.name))]
|
||||
const held = [...new Set(theirs.filter((t) => !t.side.write).map((t) => t.name))]
|
||||
const inferred = { inferred: true }
|
||||
if (differ.length && item !== 'valid_filter') {
|
||||
// 业务日期、去重规则说法不同:归拢这一步就不写了,轮不到对目录,挡下的那个也一样
|
||||
const names = P.metricNames(differ)
|
||||
out.push({ side, ...blank, conflict: true, where: !short,
|
||||
out.push({ side, ...blank, ...inferred, conflict: true, where: !short,
|
||||
text: short ? P.conflictSame(T.choices.b, names) : P.conflict(T.choices[side], mine.target!.where, names) })
|
||||
continue
|
||||
}
|
||||
out.push(differ.length ? writing(mine.value, { joined: true }, P.joined(P.metricNames(differ))) : writing(mine.value))
|
||||
if (!mine.write) { out.push({ ...blocked(mine.reason), ...inferred }); continue }
|
||||
if (held.length) {
|
||||
const names = P.metricNames(held)
|
||||
out.push({ side, ...blank, ...inferred, pending: true, where: !short,
|
||||
text: short ? P.pendingSame(T.choices[side], mine.value, names) : P.pending(T.choices[side], mine.target!.where, mine.value, names) })
|
||||
continue
|
||||
}
|
||||
out.push(differ.length ? writing(mine.value, { ...inferred, joined: true }, P.joined(P.metricNames(differ))) : writing(mine.value, inferred))
|
||||
}
|
||||
out.push(skip('neither'))
|
||||
return out
|
||||
@@ -607,10 +630,12 @@ function MetricBlock({ view, name, choice, clauses, onChoose }: {
|
||||
|
||||
/**
|
||||
* 服务端预告的「记入数据目录」:三个选择逐条一句(clausesOf),审核人当前选的那一条加重——选之前三条都看得到,选了之后
|
||||
* 一眼看出这一选会往目录里写什么、不写的为什么。不写的原因是服务端的原话,往往不短,逐条分行写,不挤在一句里
|
||||
* 一眼看出这一选会往目录里写什么、不写的为什么。不写的原因是服务端的原话,往往不短,逐条分行写,不挤在一句里。有一条
|
||||
* 是推出来的(没有算好的合并结果)时,结尾换成「实际写入以时间线上的记录为准」的一般说法
|
||||
*/
|
||||
function CatalogPreviewNote({ clauses, choice }: { clauses: Clause[]; choice?: DualCheckChoice }) {
|
||||
const P = T.catalogPreview
|
||||
const inferred = clauses.some((c) => c.inferred)
|
||||
return (
|
||||
<div className="mt-1.5 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-catalog="preview">
|
||||
<div>{P.head}</div>
|
||||
@@ -619,12 +644,13 @@ function CatalogPreviewNote({ clauses, choice }: { clauses: Clause[]; choice?: D
|
||||
<li key={c.side} className={clsx(choice === c.side && 'font-medium')} style={choice === c.side ? { color: 'var(--text)' } : undefined}
|
||||
data-dual-catalog-side={c.side} data-write={c.write ? '' : undefined} data-current={choice === c.side ? '' : undefined}
|
||||
data-reason={c.reason ? '' : undefined} data-joint={c.joint ? '' : undefined} data-together={c.together ? '' : undefined}
|
||||
data-conflict={c.conflict ? '' : undefined} data-joined={c.joined ? '' : undefined}>
|
||||
data-conflict={c.conflict ? '' : undefined} data-joined={c.joined ? '' : undefined}
|
||||
data-pending={c.pending ? '' : undefined} data-inferred={c.inferred ? '' : undefined}>
|
||||
{c.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-0.5" data-dual-catalog-tail="">{P.tail}</p>
|
||||
<p className="mt-0.5" data-dual-catalog-tail={inferred ? 'inferred' : 'exact'}>{inferred ? P.tailInferred : P.tail}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+21
-10
@@ -718,7 +718,7 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason:
|
||||
/**
|
||||
* 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的
|
||||
* (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。
|
||||
* 无法复算(dual_check_unchecked)、没能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是
|
||||
* 无法复算(dual_check_unchecked)、未能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是
|
||||
* 期 4 补上的:以前认不得,只能照原话显示
|
||||
*/
|
||||
const VERIFY_LOG_CODES = new Set([
|
||||
@@ -734,13 +734,23 @@ const CL = CHALLENGE_TEXT.log
|
||||
/**
|
||||
* 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀
|
||||
* (nodes/io.py 的 _upstream_issuance_gaps,节点名就是类型叫法时只写「节点类型:」),按这两种节点类型的叫法认。
|
||||
* 它们是核查查出来的问题(两路结果不一致、结论被推翻、指标无法复算),时间线、成果卡片、证据页另起一组「核查未通过」,
|
||||
* 不和「校验没跑全」的缺口混在一起。其余的照旧
|
||||
* 时间线、成果卡片、证据页把它们另起,不和「校验没跑全」的缺口混在一起,再分两组(叫法在 DUAL_CHECK_TEXT.verifyGaps):
|
||||
* - failed「核查未通过」:核查查出来的问题——两路结果不一致(审核人选了盲算、两边都不对、没作出选择),结论被推翻;
|
||||
* - unfinished「核查未完成」:没核成的——指标无法复算(nodes/dual_check.py 的 GAP_UNCHECKED「指标「X」无法复算:原因」)。
|
||||
* 合并查询、遮罩列的输入做不了复算,盲算触顶、出错的也没核过,说成「未通过」读的人会以为这个数被查出了错。
|
||||
* 其余的照旧
|
||||
*/
|
||||
const VERIFY_GAP_KINDS = [NODE_TYPE_LABEL.dual_check, NODE_TYPE_LABEL.challenge]
|
||||
export function splitIssuanceGaps(gaps: readonly string[]): { verify: string[]; other: string[] } {
|
||||
const isVerify = (g: string) => VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`))
|
||||
return { verify: gaps.filter(isVerify), other: gaps.filter((g) => !isVerify(g)) }
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
/** 双盲复算报的「无法复算」:前缀(节点名可有可无)之后紧跟 GAP_UNCHECKED 的开头 */
|
||||
const UNCHECKED_GAP = new RegExp(`^${escapeRe(NODE_TYPE_LABEL.dual_check)}(?:「.*?」)?:指标「.+?」无法复算:`)
|
||||
export function splitIssuanceGaps(gaps: readonly string[]): { failed: string[]; unfinished: string[]; other: string[] } {
|
||||
const out = { failed: [] as string[], unfinished: [] as string[], other: [] as string[] }
|
||||
for (const g of gaps) {
|
||||
const verify = VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`))
|
||||
;(!verify ? out.other : UNCHECKED_GAP.test(g) ? out.unfinished : out.failed).push(g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
/** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */
|
||||
@@ -1140,7 +1150,7 @@ function explainLog(code: string | undefined, message: string, d: Record<string,
|
||||
}
|
||||
}
|
||||
case 'challenge_unreviewed': {
|
||||
// claims_skipped:没能反查的句数;reason:第一句没能反查的原因(一句中文)
|
||||
// claims_skipped:未能反查的句数;reason:第一句未能反查的原因(一句中文)
|
||||
return {
|
||||
title: CL.unreviewed(countOf(d.claims_skipped) || undefined),
|
||||
sub: cjkText(d.reason),
|
||||
@@ -2103,9 +2113,10 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
|
||||
if (num(d.unmatched)) gaps.push(`${d.unmatched} 个数字无法追溯到指标集`)
|
||||
// 校验本身没跑全(叙述渲染为空、指标集为空)也会降档。不写出来的话,
|
||||
// 时间线上就是一个光秃秃的「降档出具」,看起来和"全部通过"只差一个字。
|
||||
// 双盲复算、反方审稿报上来的缺口是核查查出来的问题,另起一组,不归在「校验不完整」下
|
||||
const { verify, other: notRun } = splitIssuanceGaps(Array.isArray(d.gaps) ? d.gaps.map(String) : [])
|
||||
if (verify.length) gaps.push(`${DC.verifyGaps.head}:${verify.join(';')}`)
|
||||
// 双盲复算、反方审稿报上来的缺口另起,按查出了问题、没核成分成两组,不归在「校验不完整」下
|
||||
const { failed, unfinished, other: notRun } = splitIssuanceGaps(Array.isArray(d.gaps) ? d.gaps.map(String) : [])
|
||||
if (failed.length) gaps.push(`${DC.verifyGaps.failed}:${failed.join(';')}`)
|
||||
if (unfinished.length) gaps.push(`${DC.verifyGaps.unfinished}:${unfinished.join(';')}`)
|
||||
if (notRun.length) gaps.push(`校验不完整:${notRun.join(';')}`)
|
||||
const checked = [
|
||||
num(d.metrics_checked) != null ? `核对 ${d.metrics_checked} 个指标` : '',
|
||||
|
||||
+72
-19
@@ -3,8 +3,10 @@
|
||||
//
|
||||
// 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、
|
||||
// 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时说起出具(取不到就不知道是不是正式
|
||||
// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、
|
||||
// 根本没反查成的几句混在「无法判断」里看不出来、没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、
|
||||
// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、审批带着运行类别时还另取一次(取失败了降档提示就没了)、
|
||||
// 反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、说明里用「本节点」(画在审批卡上,读着像审批节点)、
|
||||
// 反查没完成的几句混在「无法判断」里看不出来(或者比「无法判断」还多、插在降档标记和它的说明中间)、
|
||||
// 没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、
|
||||
// 裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、工件取不到时卡死在加载中、
|
||||
// 审稿记录没存上时每句还能点开、条目只列前十句时看不到其余的(或者补的时候吞掉原文相同的另一句、键盘焦点掉到页面上);
|
||||
// 检查器里的字段写不进节点配置(或者写进去的不是约定的形状:跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了
|
||||
@@ -41,17 +43,31 @@ const DUP_CONTENT = {
|
||||
claims: fx.artifacts[MANY].claims.map((c) => (c.unit === 'u30' ? { ...c, text: fx.many.items.find((it) => it.unit === 'u20').text } : c)),
|
||||
}
|
||||
const RUNS = fx.runs
|
||||
// RUNS.given 不在这里:那次运行的 GET 一律答 404,审批带着的运行类别得照样管用
|
||||
const RUN_CLASS = {
|
||||
[RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal',
|
||||
[RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal',
|
||||
[RUNS.exploreFlag]: 'exploratory', [RUNS.runfail]: 'formal', [RUNS.dup]: 'formal', [RUNS.norecord]: 'formal',
|
||||
[RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal',
|
||||
[RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', [RUNS.over]: 'formal',
|
||||
}
|
||||
// challenge-approval-unreviewed 的工件:同第一个用例,被削弱(u5)、经受住(u8)的两句换成数据源已停用、没查成
|
||||
// (服务端 unreached 的写法:记为无法判断,理由是系统说的,没有理由文档和查询)
|
||||
const UNREVIEWED_ART = 'f'.repeat(64)
|
||||
const UNREVIEWED_UNITS = ['u5', 'u8']
|
||||
const UNREVIEWED_CONTENT = {
|
||||
...fx.artifacts[ART],
|
||||
claims: fx.artifacts[ART].claims.map((c) => (UNREVIEWED_UNITS.includes(c.unit) ? {
|
||||
...c, verdict: 'inconclusive', rationale_doc: null, queries: [],
|
||||
rationale: '这句引用的数据源「北仓库」已不存在或已停用,没有可以反查的数据源,这句未反查',
|
||||
} : c)),
|
||||
}
|
||||
const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' }
|
||||
const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位'
|
||||
const DEGRADE_NOTE = '被推翻的结论计入出具缺口,本次正式运行将降档出具'
|
||||
const DEGRADE_OFF = '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位'
|
||||
const UNREVIEWED = (n) => `${n} 句没能反查,已记为无法判断。核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线`
|
||||
// 主语写明是反方审稿节点:这句画在人工审批的卡上,「本节点」读着像审批节点
|
||||
const DEGRADE_OFF = '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位'
|
||||
const UNREVIEWED = (n) => `${n} 句未能反查,已记为无法判断。反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,`
|
||||
+ '或报告未引用数据库查询,原因见运行时间线'
|
||||
const FAILED = '无法获取反方审稿的详细内容'
|
||||
// F3a 画布校验(schema._check_challenge)在同样情形下的说法:检查器按 field 挂在字段下面
|
||||
const SERVER_MANY = '「反方审稿」的上游有多个「报告撰写」节点(「写月报」、「写周报」),请在「要审的报告」中选择要审的那一份'
|
||||
@@ -152,6 +168,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
hits.artifacts.push(id)
|
||||
if (id === BROKEN && !state.broken) return route.fulfill({ json: { id, content: fx.artifacts[ART] } })
|
||||
if (id === DUP) return route.fulfill({ json: { id, content: DUP_CONTENT } })
|
||||
if (id === UNREVIEWED_ART) return route.fulfill({ json: { id, content: UNREVIEWED_CONTENT } })
|
||||
const content = fx.artifacts[id]
|
||||
return content ? route.fulfill({ json: { id, content } })
|
||||
: route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } })
|
||||
@@ -354,9 +371,9 @@ await section('正式运行、没有被推翻的结论:不写降档', async ()
|
||||
})
|
||||
|
||||
await section('运行类别取不到:不说任何和出具有关的话,重新拿到焦点时再取', async () => {
|
||||
// 取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为正式运行……会降档」外加重试,卡顶却按审批自带的
|
||||
// 运行类别写着正式运行,两处说法打架。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行
|
||||
// 降档提示才出现
|
||||
// 老服务端的审批不带运行类别,只能按运行 id 取。取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为
|
||||
// 正式运行……会降档」外加重试。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行降档
|
||||
// 提示才出现。审批带着运行类别时照它说、不另取,见下一节
|
||||
const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail')
|
||||
const block = page.locator('[data-challenge="write"]')
|
||||
await block.waitFor()
|
||||
@@ -386,31 +403,67 @@ await section('运行类别取不到:不说任何和出具有关的话,重
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('有句子根本没反查成:单独说一行,不混在「无法判断」里', async () => {
|
||||
// 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了……这几句记为无法判断,可一条查询都没执行。「无法判断 2 句」
|
||||
// 分不出是查了、数据不够,还是根本没查,审批卡单独说
|
||||
await section('审批带着运行类别:照它说出具,不另取(另取失败也不影响)', async () => {
|
||||
// 审批自带 approval.run_class(卡顶写的就是它)。以前反方审稿那一块只拿运行 id 另取一次,这一次失败了,正式运行里
|
||||
// 「出具会降档」整块消失,卡顶却照样写着正式运行。用例把反方审稿那一块单独画,运行类别由调用方传进来,这次运行的
|
||||
// GET 一律答 404
|
||||
const { ctx, page, hits, errors } = await open('challenge-summary-given')
|
||||
const block = page.locator('[data-challenge="write"]')
|
||||
await block.waitFor()
|
||||
await page.waitForTimeout(500)
|
||||
check('给了运行类别(正式运行):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档'
|
||||
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block))
|
||||
check('……不另取运行类别', !hits.runs.includes(RUNS.given), hits.runs.join('、'))
|
||||
check('……页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => {
|
||||
// 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断
|
||||
// 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-unreviewed')
|
||||
const block = page.locator('[data-challenge="write"]')
|
||||
await block.waitFor()
|
||||
await runClassSettled(page, hits, RUNS.unreviewed)
|
||||
const line = block.locator('[data-challenge-unreviewed]')
|
||||
check('写明几句没能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2)
|
||||
check('写明几句未能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2)
|
||||
&& (await line.getAttribute('data-challenge-unreviewed')) === '2', await line.count() ? await text(line) : '没有')
|
||||
check('……概况照服务端的计数写,不另算', (await text(block.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句')
|
||||
const inconclusive = await text(block.locator('[data-challenge-count="inconclusive"]'))
|
||||
check('……概况照服务端的计数写(无法判断 3 句:两句未能反查、一句查过了数据不够),未能反查的不多于无法判断',
|
||||
inconclusive === '无法判断 3 句' && 2 <= Number(inconclusive.match(/\d+/)?.[0]), inconclusive)
|
||||
check('……用「未能」,不说「没能」「真正」', !/没能|真正/.test(await text(block)))
|
||||
// degrades 为真、正式运行、有被推翻的:照常写降档
|
||||
check('服务端说会降档(degrades 为真):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档'
|
||||
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && await page.locator('[data-challenge-degrade-off]').count() === 0)
|
||||
check('没能反查:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
// 降档的说明紧跟在概况那一行(「出具会降档」的标记)下面,未能反查的那一行在它后面
|
||||
const order = await block.evaluate((el) => [...el.children].map((c) => (c.hasAttribute('data-challenge-counts') ? 'counts'
|
||||
: c.hasAttribute('data-challenge-degrade-note') ? 'note' : c.hasAttribute('data-challenge-unreviewed') ? 'unreviewed' : '')).filter(Boolean))
|
||||
check('……降档的说明紧跟在降档标记那一行下面,未能反查的那一行排在后面', order.join(',') === 'counts,note,unreviewed', order.join(','))
|
||||
// 点开一句未能反查的:理由是系统说的原因,没有查询
|
||||
await toggle(page, 'u5').click()
|
||||
await detail(page, 'u5').locator('[data-challenge-rationale-text]').waitFor()
|
||||
check('点开未能反查的一句:理由写明数据源已停用、这句未反查', (await text(detail(page, 'u5').locator('[data-challenge-rationale-text]')))
|
||||
.includes('这句未反查'), await text(detail(page, 'u5')))
|
||||
check('未能反查:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
// 老服务端不给 unreviewed(第一个用例)、给了 0(offpath 用例):都不写这一行
|
||||
for (const [syn, run] of [['challenge-approval', RUNS.formal], ['challenge-approval-offpath', RUNS.offpath]]) {
|
||||
const other = await open(syn)
|
||||
await other.page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(other.page, other.hits, run)
|
||||
check(`${syn}:没有没能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0
|
||||
&& !(await text(other.page.locator('[data-challenge="write"]'))).includes('没能反查'))
|
||||
check(`${syn}:没有未能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0
|
||||
&& !(await text(other.page.locator('[data-challenge="write"]'))).includes('未能反查'))
|
||||
await other.ctx.close()
|
||||
}
|
||||
// 服务端给的 unreviewed 比「无法判断」还多(不该出现):按「无法判断」的句数说,两个数不打架
|
||||
const over = await open('challenge-approval-unreviewed-over')
|
||||
await over.page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(over.page, over.hits, RUNS.over)
|
||||
const capped = over.page.locator('[data-challenge-unreviewed]')
|
||||
check('unreviewed 比「无法判断」多:按「无法判断」的句数说', (await capped.getAttribute('data-challenge-unreviewed')) === '1'
|
||||
&& (await text(capped)).startsWith('1 句未能反查') && (await text(over.page.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句',
|
||||
await text(capped))
|
||||
await over.ctx.close()
|
||||
})
|
||||
|
||||
await section('按降档处置、却不在出具路径上(degrades 为假):不写降档,说清为什么不影响', async () => {
|
||||
@@ -423,8 +476,8 @@ await section('按降档处置、却不在出具路径上(degrades 为假)
|
||||
check('不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0
|
||||
&& !(await text(block)).includes('出具会降档'), await text(block))
|
||||
const off = page.locator('[data-challenge-degrade-off]')
|
||||
check('写明本节点不在带出具契约的成果节点上游、不影响出具档位', await off.count() === 1 && (await text(off)) === DEGRADE_OFF,
|
||||
await off.count() ? await text(off) : '没有')
|
||||
check('写明反方审稿节点不在带出具契约的成果节点上游、不影响出具档位(不说「本节点」)', await off.count() === 1 && (await text(off)) === DEGRADE_OFF
|
||||
&& !(await text(off)).includes('本节点'), await off.count() ? await text(off) : '没有')
|
||||
check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0)
|
||||
check('不在出具路径上:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
@@ -775,7 +828,7 @@ await section('窄屏与深浅色', async () => {
|
||||
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
// 几行说明(降档原因、没能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清
|
||||
// 几行说明(降档原因、未能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const notes = [
|
||||
['challenge-approval', '[data-challenge-degrade-note]'],
|
||||
|
||||
+75
-20
@@ -2181,10 +2181,11 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结
|
||||
check('……缺字段的工件 id 不当成可下钻的工件', !rows.some((s) => s?.artifact != null))
|
||||
})
|
||||
|
||||
await section('核查类日志与出具缺口(期 4):无法复算、没能反查、只查了部分数据源说人话,核查报上来的缺口另起一组', async () => {
|
||||
await section('核查类日志与出具缺口(期 4):无法复算、未能反查、只查了部分数据源说人话,核查报上来的缺口另起两组', async () => {
|
||||
// 期 3 时这三个代码界面认不得,只能照原话显示;出具缺口里双盲复算、反方审稿报上来的几条读成
|
||||
// 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准——
|
||||
// 它们是核查查出来的问题,不是校验没做完
|
||||
// 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准。
|
||||
// 核查报上来的另起,再分两组:查出了问题的(两路结果不一致、结论被推翻)是「核查未通过」,没核成的(指标无法复算)
|
||||
// 是「核查未完成」——没核过的数说成「未通过」,读的人会以为这个数被查出了错
|
||||
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
|
||||
const events = [
|
||||
E(1, 'run.started', null, { total: 3 }),
|
||||
@@ -2202,7 +2203,7 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能
|
||||
E(9, 'node.started', 'done', { node_type: 'output', label: '成果' }),
|
||||
E(10, 'issuance', 'done', { tier: 'degraded', missing_required: [], missing_expected: [], unmatched: 0, metrics_checked: 2, matched_numbers: 3,
|
||||
gaps: ['双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」',
|
||||
'叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致'] }),
|
||||
'叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致', '双盲复算「复核销售额」:指标「订单数」无法复算:输入来自合并查询'] }),
|
||||
E(11, 'node.finished', 'done', { duration_ms: 5 }),
|
||||
E(12, 'run.finished', null, { status: 'succeeded' }),
|
||||
]
|
||||
@@ -2215,12 +2216,16 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能
|
||||
check('……标题里没有内部代码,原话留在展开区', NEW.every((c) => !/dual_check_|challenge_/.test(`${by(c)?.title}${by(c)?.sub ?? ''}${by(c)?.next ?? ''}`))
|
||||
&& by('challenge_unreviewed')?.detail === events[5].data.message)
|
||||
const un = by('dual_check_unchecked')
|
||||
check('dual_check_unchecked:几个指标无法复算、计入缺口,副标题点名带原因(没名字的写 id),提醒色',
|
||||
un?.title === '双盲复算有 2 个指标无法复算,按缺口计入出具' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限'
|
||||
&& un.level === 'warn' && !!un.next?.includes('核查'), `${un?.title} / ${un?.sub}`)
|
||||
// 只说记为缺口,不说「计入出具」:探索运行不出具,节点接在侧枝上时缺口也进不了出具,降不降档由出具那一行说
|
||||
check('dual_check_unchecked:几个指标无法复算、记为缺口(不提出具),副标题点名带原因(没名字的写 id),提醒色',
|
||||
un?.title === '双盲复算有 2 个指标无法复算,已记为缺口' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限'
|
||||
&& un.level === 'warn' && !!un.next?.includes('核查') && !`${un.title}${un.sub}`.includes('出具'), `${un?.title} / ${un?.sub}`)
|
||||
const ur = by('challenge_unreviewed')
|
||||
check('challenge_unreviewed:几句没能反查、记为无法判断,原因放副标题', ur?.title === '反方审稿有 2 句结论没能反查,已记为无法判断'
|
||||
&& ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('没有真正查询数据'), `${ur?.title} / ${ur?.sub}`)
|
||||
// 用「未能」(同服务端原话「未能反查」),不说「没有真正查询数据」(触顶的句子查过);这一条不含触顶的句子
|
||||
// (由 challenge_limit 说),下一步不提调高上限
|
||||
check('challenge_unreviewed:几句未能反查、记为无法判断,原因放副标题,下一步不提上限', ur?.title === '反方审稿有 2 句结论未能反查,已记为无法判断'
|
||||
&& ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('核查模型') && !!ur.next?.includes('数据源')
|
||||
&& !ur.next.includes('上限') && !/没能|真正/.test(`${ur.title}${ur.next}`), `${ur?.title} / ${ur?.sub} / ${ur?.next}`)
|
||||
const lost = by('challenge_sources_lost')
|
||||
check('challenge_sources_lost:几句只查了部分数据源,点名没查成的源、判断不含它们', lost?.title === '反方审稿有 1 句结论只查了部分数据源'
|
||||
&& lost.sub === '未能查询:「北仓库」、「南仓库」(已不存在或已停用),判断不含其中的数据' && lost.level === 'warn' && !!lost.next?.includes('数据源'),
|
||||
@@ -2229,24 +2234,74 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能
|
||||
const issuance = steps.find((s) => s.kind === 'issuance')
|
||||
const title = issuance?.title ?? ''
|
||||
const detail = issuance?.detail ?? ''
|
||||
check('出具那一行:核查报上来的两条归在「核查未通过」下', detail.split('\n').includes(
|
||||
'核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail)
|
||||
check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', detail.split('\n').some((l) => l.startsWith('校验不完整:')
|
||||
&& l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了')), detail)
|
||||
check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', detail.split('\n').some((l) => l.startsWith('校验不完整:')
|
||||
&& l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail)
|
||||
const lines = detail.split('\n')
|
||||
check('……核查那一组排在校验不完整前面,标题以档位开头', title.startsWith('降档出具:核查未通过:')
|
||||
&& lines.findIndex((l) => l.startsWith('核查未通过:')) < lines.findIndex((l) => l.startsWith('校验不完整:')), title)
|
||||
check('出具那一行:核查查出了问题的两条归在「核查未通过」下', lines.includes(
|
||||
'核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail)
|
||||
check('……无法复算的那条归在「核查未完成」下,不在「核查未通过」下', lines.includes(
|
||||
'核查未完成:双盲复算「复核销售额」:指标「订单数」无法复算:输入来自合并查询')
|
||||
&& !lines.some((l) => l.startsWith('核查未通过:') && l.includes('无法复算')), detail)
|
||||
check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', lines.some((l) => l.startsWith('校验不完整:')
|
||||
&& l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了') && !l.includes('无法复算')), detail)
|
||||
check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', lines.some((l) => l.startsWith('校验不完整:')
|
||||
&& l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail)
|
||||
const at = (head) => lines.findIndex((l) => l.startsWith(head))
|
||||
check('……先「核查未通过」、再「核查未完成」、最后「校验不完整」,标题以档位开头', title.startsWith('降档出具:核查未通过:')
|
||||
&& at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验不完整:'), title)
|
||||
check('……前缀和正文不重复节点类型', !/反方审稿:反方审稿|双盲复算「[^」]*」:[^;]*双盲复算/.test(detail), detail)
|
||||
|
||||
// splitIssuanceGaps:成果卡片、证据页按同一个函数分组
|
||||
const split = mod.splitIssuanceGaps(['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」',
|
||||
'缺少必需指标:gmv', '反方审稿报告没写完'])
|
||||
check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算', JSON.stringify(split) === JSON.stringify({
|
||||
verify: ['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」'],
|
||||
'缺少必需指标:gmv', '反方审稿报告没写完', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择',
|
||||
'双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对'])
|
||||
check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算;无法复算的单列一组', JSON.stringify(split) === JSON.stringify({
|
||||
failed: ['反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择',
|
||||
'双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对'],
|
||||
unfinished: ['双盲复算:指标「订单数」无法复算:输入来自合并查询'],
|
||||
other: ['缺少必需指标:gmv', '反方审稿报告没写完'] }), JSON.stringify(split))
|
||||
|
||||
// 和服务端的说法对得上:双盲复算、反方审稿报缺口的原话(nodes/dual_check.py 的 GAP_*、nodes/challenge.py 的
|
||||
// _refuted_gap)加上出具契约的前缀(nodes/io.py 的 _upstream_issuance_gaps:节点名就是类型叫法时只写「类型:」),
|
||||
// 按服务端的写法拼出来再分组。服务端改了说法(比如「无法复算」换了个词),这里先变红
|
||||
const dcPy = readFileSync(`${root}backend/app/engine/nodes/dual_check.py`, 'utf8')
|
||||
const chPy = readFileSync(`${root}backend/app/engine/nodes/challenge.py`, 'utf8')
|
||||
const ioPy = readFileSync(`${root}backend/app/engine/nodes/io.py`, 'utf8')
|
||||
const gapOf = (name) => dcPy.match(new RegExp(`^${name} = "([^"]+)"$`, 'm'))?.[1]
|
||||
const fill = (tpl) => tpl?.replace('{name}', '订单数').replace('{reason}', '输入来自合并查询')
|
||||
const refuted = chPy.match(/return f"(推翻了 \{len\(claims\)\} 句结论:\{_named\(claims\)\})"/)?.[1]
|
||||
check('读出了服务端报缺口的写法和前缀的写法', ['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].every((n) => !!gapOf(n))
|
||||
&& !!refuted && ioPy.includes('prefix = f"{kind}:" if node.title == kind else f"{kind}「{node.title}」:"'),
|
||||
['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].map((n) => `${n}=${gapOf(n)}`).join(' / '))
|
||||
const prefixed = (kind, title, body) => (title === kind ? `${kind}:` : `${kind}「${title}」:`) + body
|
||||
const served = mod.splitIssuanceGaps([
|
||||
...['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED'].map((n) => prefixed('双盲复算', '复核', fill(gapOf(n)))),
|
||||
prefixed('双盲复算', '双盲复算', fill(gapOf('GAP_UNCHECKED'))),
|
||||
prefixed('反方审稿', '反方审稿', refuted?.replace('{len(claims)}', '1').replace('{_named(claims)}', '「东区增长主要来自新客」')),
|
||||
])
|
||||
check('……两路不一致(选了盲算、两边都不对、没作出选择)和被推翻的归「核查未通过」,无法复算的归「核查未完成」',
|
||||
served.failed.length === 4 && served.unfinished.length === 1 && served.unfinished[0].startsWith('双盲复算:指标「订单数」无法复算:')
|
||||
&& served.other.length === 0, JSON.stringify(served))
|
||||
|
||||
// dual_check_catalog 的 written 带了写进去的值(期 4):时间线写「…记为「值」」,值的写法和审批卡上的「会记为「值」」
|
||||
// 同一个函数(catalogPatch 的 patchValueText),两处一字不差
|
||||
const cp = await import(await load('/src/run/catalogPatch.ts'))
|
||||
const { DUAL_CHECK_TEXT: DCT } = await import(await load('/src/lib/terms.ts'))
|
||||
const valued = flatten(mod.decodeRun([
|
||||
E(1, 'node.started', 'dc', { node_type: 'dual_check' }),
|
||||
E(2, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '已写入',
|
||||
written: [{ source: 'shop', table: 'orders', item: 'business_date', value: { column: 'paid_at' } },
|
||||
{ source: 'shop', table: 'refunds', item: 'valid_filter', value: "status <> 'test'" }], skipped: [] }),
|
||||
E(3, 'node.finished', 'dc', {}),
|
||||
])).find((s) => s.code === 'dual_check_catalog')
|
||||
const date = cp.patchValueText('business_date', { column: 'paid_at' })
|
||||
const cond = cp.patchValueText('valid_filter', "status <> 'test'")
|
||||
check('dual_check_catalog 带值:业务日期写列名、有效记录条件写原句,「哪张表的哪一项记为「值」」',
|
||||
valued?.title === '已将 2 项人工确认的事实记入数据目录'
|
||||
&& valued.sub === `「orders」(shop)的业务日期记为「${date}」;「refunds」(shop)的有效记录条件记为「${cond}」`
|
||||
&& date === 'paid_at' && cond === "status <> 'test'", `${valued?.title} / ${valued?.sub}`)
|
||||
const card = DCT.approval.catalogPreview.write('采用盲算的结果', DCT.log.catalogItem('orders', 'shop', '业务日期'), date)
|
||||
check('……值和审批卡上「会记为「值」」的写法一字不差', card.endsWith(`会记为「${date}」`)
|
||||
&& (valued?.sub ?? '').startsWith(`${DCT.log.catalogItem('orders', 'shop', '业务日期')}记为「${date}」`), `${card} / ${valued?.sub}`)
|
||||
|
||||
// 老日志缺字段、字段类型不对:不崩,不出 undefined、NaN、[object Object],不瞎填数
|
||||
const odd = [['dual_check_unchecked', { metrics: 'x' }], ['challenge_unreviewed', { claims_skipped: 'x', reason: 3 }],
|
||||
['challenge_sources_lost', { claims: null, sources: 'x' }]]
|
||||
|
||||
+120
-5
@@ -17,7 +17,11 @@
|
||||
// 节点设置时有条件地说;
|
||||
// - 审批卡(期 4):预告逐条说「会记为……」或「不会记入:原因」,原因是服务端的原话;被目录挡下的一边照样说写到哪;
|
||||
// 几个指标落到同一项上时照完整对照里服务端算好的合并预告(catalog_joint)说,和真写的一字不差(夹具的 merges 是服务端
|
||||
// 真写一遍的结果);盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目标出,老工件照旧纯文本;
|
||||
// 真写一遍的结果);没有合并预告时按其余指标当前的选择推,被目录挡下的同项指标也算进来(说法不同就说都不记,说不准的
|
||||
// 只说可能),结尾换成以时间线上的记录为准;盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目
|
||||
// 标出,老工件照旧纯文本;
|
||||
// - 出具缺口:证据页(真的 EvidencePane)和成果节点印章的悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」
|
||||
// 「核查未完成」两组,不放在「校验未全部完成」下;
|
||||
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;
|
||||
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
|
||||
// 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是
|
||||
@@ -456,16 +460,23 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
|
||||
})
|
||||
|
||||
/**
|
||||
* 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined}。reason:不写、照服务端
|
||||
* 的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入;conflict、joined:没有合并预告时
|
||||
* 按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句)
|
||||
* 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined, pending, inferred}。
|
||||
* reason:不写、照服务端的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入;
|
||||
* conflict、joined、pending:没有合并预告时按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句、同项上有
|
||||
* 被挡下的指标说不准只说可能);inferred:这一条是推出来的
|
||||
*/
|
||||
const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')]
|
||||
.map((s) => [s.dataset.dualCatalogSide, {
|
||||
text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight),
|
||||
reason: s.dataset.reason != null, joint: s.dataset.joint != null, together: s.dataset.together != null,
|
||||
conflict: s.dataset.conflict != null, joined: s.dataset.joined != null,
|
||||
conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, pending: s.dataset.pending != null,
|
||||
inferred: s.dataset.inferred != null,
|
||||
}])))
|
||||
/** 一块预告的结尾:{kind: exact(照服务端算好的说)| inferred(有推出来的), text} */
|
||||
const previewTail = async (loc) => {
|
||||
const tail = loc.locator('[data-dual-catalog-tail]')
|
||||
return { kind: await tail.getAttribute('data-dual-catalog-tail'), text: await text(tail) }
|
||||
}
|
||||
/** 不写的一条:选择的叫法加服务端的原话 */
|
||||
const blockedText = (label, reason) => `${label}时不会记入:${reason}`
|
||||
const CHOICE_LABEL = { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' }
|
||||
@@ -662,6 +673,10 @@ await section('审批卡:几个指标落到同一项上、工件里没有合
|
||||
const note = await text(metric(page, 'gmv').locator('[data-dual-catalog]'))
|
||||
check('……结尾说清预告按各指标当前的选择算,「人工确认」说的是写入的项', note.includes('各指标当前的选择')
|
||||
&& note.includes('写入的项记为人工确认的事实') && !note.includes('不记。记为'), note)
|
||||
const tail = await previewTail(metric(page, 'gmv'))
|
||||
check('……推出来的:结尾不说只有目录被改了才会不一致,说实际写入以时间线上的记录为准', tail.kind === 'inferred'
|
||||
&& tail.text.includes('推断') && tail.text.endsWith('实际写入以时间线上的记录为准') && !tail.text.includes('审批期间数据目录有改动时'),
|
||||
`${tail.kind} / ${tail.text}`)
|
||||
// 退款率改选两边都不对:不再落到同一项上,销售额照常记
|
||||
await choose(page, 'refund_rate', 'neither')
|
||||
g = await previewClauses(metric(page, 'gmv'))
|
||||
@@ -698,6 +713,42 @@ await section('审批卡:几个指标落到同一项上、工件里没有合
|
||||
&& cur.refund_rate?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件会记为「${own.refund_rate}」,与「销售额」采用的条件用 AND 合成一句`
|
||||
&& cur.gmv.write && cur.gmv.joined && cur.refund_rate.write && cur.refund_rate.joined, `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`)
|
||||
|
||||
// 被目录挡下的同项指标也算进来(2026-10 复核):目录里人工驳回过业务日期 created_at(夹具 time_rejected)。销售额采用
|
||||
// 原流程(paid_at,单独看会写),退款率采用盲算(created_at,单独看被驳回挡下,但带着写到哪)。服务端先归拢:说法不同,
|
||||
// 一项都不写,轮不到对目录。以前只看会写的同伴,销售额照样加粗说「会记为 paid_at」
|
||||
const tr = fx.merges.time_rejected
|
||||
check('夹具:退款率那一边被驳回挡下、带着写到哪,销售额那一边单独看会写,服务端一项都不写',
|
||||
tr.catalog.refund_rate.b.write === false && tr.catalog.refund_rate.b.table === 'orders' && tr.catalog.gmv.a.write === true
|
||||
&& tr.written.length === 0, JSON.stringify(tr.catalog))
|
||||
const rj = await preview('dual-check-approval', {
|
||||
query: `merge=time_rejected&peer=${tr.refund_rate}`, artifact: cardWith(tr.gmv, { peer: tr.refund_rate }),
|
||||
})
|
||||
await rj.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
await chooseAll(rj.page, 'time_rejected')
|
||||
cur = await current(rj.page)
|
||||
check('……两边加粗的那一句都说说法不同、都不记,销售额不再说「会记为」', ['gmv', 'refund_rate'].every((id) => cur[id]?.conflict
|
||||
&& !cur[id].write && cur[id].inferred && !cur[id].text.includes('会记为')), JSON.stringify(cur))
|
||||
check('……退款率被挡下的那一句照归拢的结果说(和销售额说法不同),不照它单独的驳回原因说',
|
||||
cur.refund_rate?.text === '采用盲算的结果时与「销售额」的选择说法不同,都不记', cur.refund_rate?.text)
|
||||
const rjTail = await previewTail(metric(rj.page, 'gmv'))
|
||||
check('……结尾换成实际写入以时间线上的记录为准', rjTail.kind === 'inferred' && rjTail.text.endsWith('实际写入以时间线上的记录为准'), rjTail.text)
|
||||
|
||||
// 有效记录条件、同项上有被挡下的指标(夹具 filters_rejected:销售额采纳的条件曾被驳回):挡下它的若是驳回,服务端
|
||||
// 拿掉它、退款率照写;若是别的原因(和已有条件合不起来),退款率也一起挡下。卡上认不出是哪一种,退款率那一句只说可能
|
||||
const fr = fx.merges.filters_rejected
|
||||
const fp = await preview('dual-check-approval', {
|
||||
query: `merge=filters_rejected&peer=${fr.refund_rate}&variant=${fr.gmv}`, artifact: cardWith(fr.gmv, { peer: fr.refund_rate }),
|
||||
})
|
||||
await fp.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
await chooseAll(fp.page, 'filters_rejected')
|
||||
cur = await current(fp.page)
|
||||
check('有效记录条件、同项上有被挡下的:退款率说可能记为自己的条件、也可能不记,不说定了「会记为」', !!cur.refund_rate?.pending
|
||||
&& !cur.refund_rate.write && cur.refund_rate.inferred && cur.refund_rate.text === '采用原流程的结果时,「orders」(shop)的有效记录条件'
|
||||
+ `可能记为「${fr.catalog.refund_rate.a.value}」,也可能不记:要与「销售额」的选择合起来才能确定`, cur.refund_rate?.text)
|
||||
check('……销售额(被驳回挡下)照它的原因说不会记入,和服务端一致(确实没写)', !!cur.gmv?.reason && !cur.gmv.write
|
||||
&& cur.gmv.text === blockedText(CHOICE_LABEL.a, fr.catalog.gmv.a.reason)
|
||||
&& !fr.written.some((w) => w.value === fr.catalog.gmv.a.value), cur.gmv?.text)
|
||||
|
||||
// 窄屏:说法不同的那一句更长,也不撑宽
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const narrow = await open('time_split', { width: 390, height: 844, theme })
|
||||
@@ -765,6 +816,10 @@ await section('审批卡:几个指标落到同一项上时,照完整对照
|
||||
const { page, errors } = await open('time_split')
|
||||
for (const [id, c] of Object.entries(fx.merges.time_split.choices)) await choose(page, id, c)
|
||||
let cur = await current(page)
|
||||
const exact = await previewTail(metric(page, 'gmv'))
|
||||
check('照服务端算好的合并结果说:结尾只说审批期间目录有改动时以时间线为准,不说是推断的', exact.kind === 'exact'
|
||||
&& exact.text.includes('审批期间数据目录有改动时') && !exact.text.includes('推断')
|
||||
&& !Object.values(cur).some((c) => c?.inferred), `${exact.kind} / ${exact.text}`)
|
||||
check('分开选:两边都说不会记入、写明给出了不同的说法', ['gmv', 'refund_rate'].every((id) => cur[id]?.reason
|
||||
&& cur[id].text.includes('给出了不同的说法') && cur[id].text.includes('都未写入数据目录')), JSON.stringify(cur))
|
||||
await choose(page, 'refund_rate', 'a')
|
||||
@@ -1043,6 +1098,66 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅
|
||||
await shot(rail.page, 'approval-rail-dark')
|
||||
})
|
||||
|
||||
await section('出具缺口:证据页、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => {
|
||||
// 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前和「叙述模板渲染为空」
|
||||
// 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)在出具横幅下面另起「核查未通过」(两路不一致、结论被推翻)
|
||||
// 「核查未完成」(无法复算)两组,横幅里只留校验没做完的;成果节点印章的悬停说明同样分组、同样的先后。用例
|
||||
// verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和别的混在一起;证据图、审计接口在这里伪造
|
||||
const RUN = 'run-verify-gaps'
|
||||
const FAILED = ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」']
|
||||
const UNFINISHED = ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算']
|
||||
const OTHER = ['叙述模板渲染为空(路径可能有误)']
|
||||
const extra = async (req, url) => {
|
||||
if (url.pathname === `/api/runs/${RUN}/evidence`) {
|
||||
return { json: { run_id: RUN, mode: 'none', note: '这次运行没有报告文档', seal: { sealed: true, ok: true, manifest_seq: 7 } } }
|
||||
}
|
||||
if (url.pathname === `/api/runs/${RUN}/evidence/audit`) return { status: 404, json: { detail: 'Not Found' } }
|
||||
return null
|
||||
}
|
||||
const items = (els) => els.map((el) => el.textContent.replace(/^·\s*/, '').trim())
|
||||
for (const [width, theme] of [[1280, 'dark'], [390, 'dark'], [390, 'light']]) {
|
||||
const what = `${width}px(${theme})`
|
||||
const { page, errors } = await preview('verify-gaps', { extra, width, height: 900, theme })
|
||||
await page.waitForSelector('[data-evidence-verify-gaps]')
|
||||
const groups = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-verify-group]')].map((g) => ({
|
||||
kind: g.dataset.evidenceVerifyGroup, head: g.firstElementChild?.textContent.trim(),
|
||||
items: [...g.querySelectorAll('li')].map((li) => li.textContent.replace(/^·\s*/, '').trim()),
|
||||
})))
|
||||
check(`${what} 证据页:横幅下面另起两组,先「核查未通过」(两路不一致、被推翻),再「核查未完成」(无法复算)`,
|
||||
JSON.stringify(groups) === JSON.stringify([{ kind: 'failed', head: '核查未通过', items: FAILED },
|
||||
{ kind: 'unfinished', head: '核查未完成', items: UNFINISHED }]), JSON.stringify(groups))
|
||||
const banner = await page.locator('[data-evidence-issuance] [data-issuance-gaps] li').evaluateAll(items)
|
||||
check(`${what} ……横幅的「校验未全部完成」里只剩校验没做完的那条`, JSON.stringify(banner) === JSON.stringify(OTHER)
|
||||
&& (await text(page.locator('[data-evidence-issuance] [data-issuance-gaps]'))).startsWith('校验未全部完成'), JSON.stringify(banner))
|
||||
// 成果节点的印章:悬停说明里核查的两组排在「校验未全部完成」前面,各组只列自己的
|
||||
const stamp = page.locator('.react-flow__node[data-id="done"] .nc-stamp')
|
||||
await stamp.waitFor()
|
||||
const lines = ((await stamp.getAttribute('title')) ?? '').split('\n')
|
||||
const at = (head) => lines.findIndex((l) => l.startsWith(head))
|
||||
check(`${what} 成果节点印章:悬停说明先「核查未通过」、再「核查未完成」、最后「校验未全部完成」,各列各的`,
|
||||
lines[at('核查未通过:')] === `核查未通过:${FAILED.join(';')}` && lines[at('核查未完成:')] === `核查未完成:${UNFINISHED.join(';')}`
|
||||
&& lines[at('校验未全部完成:')] === `校验未全部完成:${OTHER.join(';')}`
|
||||
&& at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验未全部完成:'), lines.join(' / '))
|
||||
const w = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
check(`${what}:证据页不横向溢出`, w <= 0, `${w}px`)
|
||||
const c = await page.evaluate(() => {
|
||||
const box = document.querySelector('[data-evidence-verify-gaps]')
|
||||
const bgOf = (el) => {
|
||||
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
||||
const v = getComputedStyle(n).backgroundColor
|
||||
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') return v
|
||||
}
|
||||
return 'rgba(0, 0, 0, 0)'
|
||||
}
|
||||
return { fg: getComputedStyle(box).color, bg: bgOf(box), base: getComputedStyle(document.body).backgroundColor }
|
||||
})
|
||||
const ratio = contrast(c.fg, c.bg, c.base)
|
||||
check(`${what}:核查那两组的字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`)
|
||||
await shot(page, `verify-gaps-${width}-${theme}`)
|
||||
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
}
|
||||
})
|
||||
|
||||
await section('记录页的工件列表:双盲复算对照、反方审稿记录有名字', async () => {
|
||||
// 工件列表按 pages/runs/model.ts 的 ARTIFACT_KIND_LABEL 写种类:期 3 新加的两种工件没登记时,列表里露出的是键名
|
||||
// dual_check、challenge,说明一栏只有一句「工件」。按页面自己加载的那一份取(同 check-evidence 的工件一节)
|
||||
|
||||
Reference in New Issue
Block a user