证据面板与报告正文接上分析结论(分析能力一轮)。点判定语、具名的数、结果表的格:出处链照服务端的分析几步(判定 → 方法 → 计算 → 输入 → 复算 → 封存)——判定写编号和方法的叫法(method_label)、判定语照原样、判定标准、点名的段、方向、谁做的(Agent 现场 / 分析卡 / 口径卡执行时的可比性检查)、附带说明、Agent 写的假设(另说它不是结论);方法写版本、实际用的阈值(契约的叫法)、规范化后的参数;计算写代入式、具名的数、结果表(被引用的数描出来、那一格高亮);输入每份一节,总数和各段之和的闭合核对照服务端算好的数,输入快照的查询步骤接在后面、点 Q 跳过去;复算一致与否、封存范围内台账记没记过、算不了的写原因;分析结论的数不走口径账本。口径卡指标上多一行「两期可比性」(判定、判定语,展开看那份可比性结论的整条链,随卡封存)。说法违规(判断词未挂分析结论、判断与分析结论不符、因果说法没有依据,按 unit / segment / span)标在正文上:只在判断词下面画提醒色波浪线,那段文字是一个可点的片段,句末挂「说法待核」,n / N 跳得到;点开写种类的叫法(同审计)、服务端的原话、所挂的结论(点一下落到它的判定语)。正文后面是系统写的「分析范围」(现象、拆过、排除、算不了、没拆的候选维度、做过几次差异检验),编号点得开——正文没引用的取新接口 …/evidence/analyses/{alias} 的出处链,关掉焦点回到那个编号。整句面板挂的依据是分析结论时写「派生自 Q1、Q2」(证据图的 derived_from 边,按这份报告认)。记录页:审计表的说法违规行写种类的叫法、点得开;工件页签认得分析结论(哪一种分析,算不了的写没有做成)。夹具 evidence-analysis.json 是真运行组装的(后端测试库里的虚构零售库 shop,模型换成脚本)。check-evidence 加三节(分析结论的出处链、说法违规与分析范围、指标上的两期可比性),check-runs 加一节(审计行、工件页签)

This commit is contained in:
ThearchyHelios
2026-10-21 18:57:22 +08:00
parent 468f670f89
commit 29874166df
15 changed files with 10540 additions and 46 deletions
+8
View File
@@ -856,6 +856,14 @@ export const api = {
get<EvidenceSegmentDetail>( get<EvidenceSegmentDetail>(
`/runs/${encodeURIComponent(runId)}/evidence/segments/${encodeURIComponent(segmentId)}${qs({ report: opts?.report })}`, `/runs/${encodeURIComponent(runId)}/evidence/segments/${encodeURIComponent(segmentId)}${qs({ report: opts?.report })}`,
opts), opts),
/**
* 报告末尾「分析范围」里的一个编号(A1):那份分析结论的出处链,和点开正文里它的判定语同一条。正文没引用的结论也点得开;
* 证据目录里没有这份结论 404。report 同片段接口
*/
analysis: (runId: string, alias: string, opts?: RequestOptions & { report?: string }) =>
get<{ report?: { node_id?: string; doc_artifact?: string }; alias?: string; rendered?: string | null
chain?: EvidenceSegmentDetail['chain']; seal?: EvidenceSegmentDetail['seal']; redacted?: EvidenceSegmentDetail['redacted'] }>(
`/runs/${encodeURIComponent(runId)}/evidence/analyses/${encodeURIComponent(alias)}${qs({ report: opts?.report })}`, opts),
/** /**
* 审计表:每个有状态的片段按状态分组(有出处 / 无证据 / 可疑实体 / 旧运行猜测),附上封存核对的结果。 * 审计表:每个有状态的片段按状态分组(有出处 / 无证据 / 可疑实体 / 旧运行猜测),附上封存核对的结果。
* groups 只要这几组(none、suspicious…) * groups 只要这几组(none、suspicious…)
+31
View File
@@ -32,6 +32,7 @@ import evidenceEntity from '../run/__tests__/evidence-entity.json'
import evidenceJudge from '../run/__tests__/evidence-judge.json' import evidenceJudge from '../run/__tests__/evidence-judge.json'
import evidenceVerdict from '../run/__tests__/evidence-verdict.json' import evidenceVerdict from '../run/__tests__/evidence-verdict.json'
import evidenceProvenance from '../run/__tests__/evidence-provenance.json' import evidenceProvenance from '../run/__tests__/evidence-provenance.json'
import evidenceAnalysis from '../run/__tests__/evidence-analysis.json'
import { EvidenceGuessView } from '../run/EvidenceGuess' import { EvidenceGuessView } from '../run/EvidenceGuess'
import { IssuanceBanner } from '../run/AssistantStream' import { IssuanceBanner } from '../run/AssistantStream'
import mdPinned from '../run/__tests__/markdown-pinned.json' import mdPinned from '../run/__tests__/markdown-pinned.json'
@@ -214,6 +215,14 @@ const EV_VERDICT_EXPLORE = evidenceVerdict.explore.doc as unknown as EvidenceDoc
* 片段接口、推断来源接口、导入清单的工件都由 check-evidence 用 page.route 伪造(按 runId 认出这一份夹具) * 片段接口、推断来源接口、导入清单的工件都由 check-evidence 用 page.route 伪造(按 runId 认出这一份夹具)
*/ */
const EV_PROVENANCE = evidenceProvenance.doc as unknown as EvidenceDocData const EV_PROVENANCE = evidenceProvenance.doc as unknown as EvidenceDocData
/**
* 分析能力一轮:判定语、具名的数、结果表的格(evidence-analysis.json,真运行组装的文档)。agent 那份有三句说法违规和系统写的
* 分析范围;card 是分析卡的结论(报告里有一张表引用结果表的格);metric 是口径卡指标上挂着两期可比性。片段接口、证据图、分析
* 范围编号的出处链由 check-evidence 用 page.route 伪造(按 runId 认出这一份夹具)
*/
const EV_ANALYSIS = evidenceAnalysis.agent.doc as unknown as EvidenceDocData
const EV_ANALYSIS_CARD = evidenceAnalysis.card.doc as unknown as EvidenceDocData
const EV_ANALYSIS_METRIC = evidenceAnalysis.metric.doc as unknown as EvidenceDocData
/** 同一份旧契约的出具,去掉位置:老运行就是这样,只能按字符串标 */ /** 同一份旧契约的出具,去掉位置:老运行就是这样,只能按字符串标 */
const LEGACY_LOOSE = { const LEGACY_LOOSE = {
matched: EV_LEGACY.matched.map(({ start: _s, end: _e, ...m }) => m), matched: EV_LEGACY.matched.map(({ start: _s, end: _e, ...m }) => m),
@@ -452,6 +461,28 @@ function EvidenceOnly() {
label="answer" dense tally /> label="answer" dense tally />
</div> </div>
</div> </div>
{/* 分析能力一轮:分析结论的出处链、说法违规的标注、系统写的分析范围、口径卡指标上的两期可比性 */}
<div className="flex flex-col gap-4" id="evidence-analysis-demo">
<div id="evidence-analysis" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">分析结论(Agent 的分析工具)、说法违规、分析范围</div>
<EvidenceDoc doc={EV_ANALYSIS} artifact={evidenceAnalysis.agent.doc_artifact} runId={evidenceAnalysis.agent.run_id}
runClass="exploratory" label="answer" tally />
</div>
<div id="evidence-analysis-narrow" className="rounded-lg border bg-panel p-2.5" style={{ width: 360, maxWidth: '100%' }}>
<EvidenceDoc doc={EV_ANALYSIS} artifact={evidenceAnalysis.agent.doc_artifact} runId={evidenceAnalysis.agent.run_id}
runClass="exploratory" label="answer" dense tally />
</div>
<div id="evidence-analysis-card" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">分析卡的结论(模板定好的分析),表格里引用结果表的格</div>
<EvidenceDoc doc={EV_ANALYSIS_CARD} artifact={evidenceAnalysis.card.doc_artifact} runId={evidenceAnalysis.card.run_id}
runClass="exploratory" label="answer" tally />
</div>
<div id="evidence-analysis-metric" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">口径卡指标上的两期可比性</div>
<EvidenceDoc doc={EV_ANALYSIS_METRIC} artifact={evidenceAnalysis.metric.doc_artifact}
runId={evidenceAnalysis.metric.run_id} runClass="exploratory" label="answer" tally />
</div>
</div>
<div id="evidence-long" className="rounded-lg border p-3"> <div id="evidence-long" className="rounded-lg border p-3">
<div className="mb-1 text-2xs text-faint">长报告:{EV_LONG.blocks.length} 块,块级 content-visibility,超过 3000 字先折叠</div> <div className="mb-1 text-2xs text-faint">长报告:{EV_LONG.blocks.length} 块,块级 content-visibility,超过 3000 字先折叠</div>
<EvidenceDoc doc={EV_LONG} label="长报告" tally /> <EvidenceDoc doc={EV_LONG} label="长报告" tally />
+12
View File
@@ -1671,6 +1671,18 @@
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 1px; outline-offset: 1px;
} }
/* 说法违规(分析能力一轮):那段文字是可点的片段,但只在判断词那几个字下面画线——波浪线、提醒色,和「无证据」的点状线分得开;
整段不画线 */
.ev-seg[data-ev-state="claim"] { text-decoration-line: none; }
.ev-claim-word {
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: var(--st-waiting);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
/* 句末的「无证据」小标签:字形加文字,去掉颜色也读得出 */ /* 句末的「无证据」小标签:字形加文字,去掉颜色也读得出 */
.ev-tag { .ev-tag {
margin: 0 3px; margin: 0 3px;
+27
View File
@@ -26,6 +26,7 @@ import {
EVIDENCE_AUDIT_TEXT, EVIDENCE_KIND_LABEL, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, JUDGE_TEXT, UPGRADE_POLICY_LABEL, EVIDENCE_AUDIT_TEXT, EVIDENCE_KIND_LABEL, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, JUDGE_TEXT, UPGRADE_POLICY_LABEL,
claimTally, evidenceTally, type ClaimTallyCounts, claimTally, evidenceTally, type ClaimTallyCounts,
} from './terms' } from './terms'
import { ANALYSIS_TEXT } from './termsAnalysis'
import { NONE, formatNumber, shortId } from './format' import { NONE, formatNumber, shortId } from './format'
import type { import type {
EvidenceBlock, EvidenceCaliberSource, EvidenceCaliberUpgrade, EvidenceCandidate, EvidenceDocData, EvidenceFieldRef, EvidenceBlock, EvidenceCaliberSource, EvidenceCaliberUpgrade, EvidenceCandidate, EvidenceDocData, EvidenceFieldRef,
@@ -237,6 +238,21 @@ export function citeLabel(alias: string, doc: Pick<EvidenceDocData, 'catalog'>,
return typeof entry?.label === 'string' ? entry.label : undefined return typeof entry?.label === 'string' ? entry.label : undefined
} }
/**
* 证据图里一份分析结论派生自谁(edges 的 rel derived_from:A → Q 是它的输入快照,A → A 是下钻的上一层),按图里的先后、去重。
* 只认同一份报告的边(多份报告的运行里同一个编号可能各指各的);不是分析结论、证据图没给边的是空列表
*/
export function derivedFrom(graph: EvidenceGraph | undefined, alias: string, report?: string): string[] {
const out: string[] = []
for (const e of graph?.edges ?? []) {
const edge = e as { from?: string; to?: string; rel?: string; report?: string }
if (edge.rel !== 'derived_from' || edge.from !== alias || !edge.to) continue
if (report && edge.report && edge.report !== report) continue
if (!out.includes(edge.to)) out.push(edge.to)
}
return out
}
/** /**
* 片段的来源说一句:「口径卡指标 环比增幅」「运行输入 week」「查询 Q3 · 第 6 行 · amount」。 * 片段的来源说一句:「口径卡指标 环比增幅」「运行输入 week」「查询 Q3 · 第 6 行 · amount」。
* 单元格先看 cite 的种类:查询条目在目录里是 query,引用它的一格是 cell * 单元格先看 cite 的种类:查询条目在目录里是 query,引用它的一格是 cell
@@ -245,6 +261,14 @@ export function sourceOf(seg: EvidenceSegment, doc?: Pick<EvidenceDocData, 'cata
const cite = seg.cite const cite = seg.cite
if (!cite) return '' if (!cite) return ''
const entry = cite.alias ? doc?.catalog?.[cite.alias] : undefined const entry = cite.alias ? doc?.catalog?.[cite.alias] : undefined
if (cite.kind === 'analysis' || entry?.kind === 'analysis') {
// 分析结论:判定语([[a:A1]])、具名的数([[a:A1.top_share]])、结果表的一格([[v:A1.r0.delta]],cite 的种类是 cell)
const E = ANALYSIS_TEXT.evidence
const loc = cite.locator ?? {}
const what = cite.kind === 'cell' ? E.citedCell(Number(loc.row) || 0, String(loc.column ?? ''))
: loc.key ? E.citedValue(loc.key) : E.citedPhrase
return [`${E.title} ${cite.alias ?? ''}`.trim(), what].join(' · ')
}
if (cite.kind === 'cell' || (cite.kind !== 'metric' && cite.kind !== 'input' && entry?.kind === 'query')) { if (cite.kind === 'cell' || (cite.kind !== 'metric' && cite.kind !== 'input' && entry?.kind === 'query')) {
const where = locatorText(cite.locator) const where = locatorText(cite.locator)
return [EVIDENCE_TEXT.query(cite.alias ?? ''), where].filter(Boolean).join(' · ') return [EVIDENCE_TEXT.query(cite.alias ?? ''), where].filter(Boolean).join(' · ')
@@ -1273,6 +1297,8 @@ export interface AuditRow {
sealed: boolean | null sealed: boolean | null
/** 结论句那几行:是哪一句(正文里有句末徽标时点这一行打开它的「模型的解释」) */ /** 结论句那几行:是哪一句(正文里有句末徽标时点这一行打开它的「模型的解释」) */
unit?: string unit?: string
/** 违规那几行的问题代码(说法违规的叫法按它取:ANALYSIS_TEXT.claim.label) */
issue?: string
} }
const GROUP_OF: Record<EvidenceStateCode, AuditGroup> = { const GROUP_OF: Record<EvidenceStateCode, AuditGroup> = {
@@ -1330,6 +1356,7 @@ export function auditFromApi(body: unknown): AuditRow[] {
sentence: String(r.sentence ?? '').trim(), sentence: String(r.sentence ?? '').trim(),
sealed: typeof r.sealed === 'boolean' ? r.sealed : null, sealed: typeof r.sealed === 'boolean' ? r.sealed : null,
...(kind === 'claim' && typeof r.unit === 'string' && r.unit ? { unit: r.unit } : {}), ...(kind === 'claim' && typeof r.unit === 'string' && r.unit ? { unit: r.unit } : {}),
...(kind === 'violation' && typeof r.issue === 'string' && r.issue ? { issue: r.issue } : {}),
}) })
} }
}) })
+2 -4
View File
@@ -140,8 +140,6 @@ export function settingValueProblem(key: AnalysisSettingKey, raw: string): strin
return null return null
} }
const short = (text: string, n = 24) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
export const ANALYSIS_TEXT = { export const ANALYSIS_TEXT = {
/** 记录页工件清单里分析结论那一行的说明(工件没记算子时) */ /** 记录页工件清单里分析结论那一行的说明(工件没记算子时) */
artifactHint: '分析结论:判定、方法、计算、输入和复算所需的一切', artifactHint: '分析结论:判定、方法、计算、输入和复算所需的一切',
@@ -301,6 +299,8 @@ export const ANALYSIS_TEXT = {
parent: '下钻自', parent: '下钻自',
gotoInput: (alias: string) => `查看输入 ${alias}`, gotoInput: (alias: string) => `查看输入 ${alias}`,
inputOfAnalysis: (alias: string) => `分析结论 ${alias} 的输入`, inputOfAnalysis: (alias: string) => `分析结论 ${alias} 的输入`,
/** 整句面板「挂的依据」里一份分析结论派生自谁(证据图的 derived_from 边) */
derivedFrom: (aliases: string[]) => `派生自 ${aliases.join('、')}`,
recompute: '复算', recompute: '复算',
seal: '封存', seal: '封存',
artifact: '结论工件', artifact: '结论工件',
@@ -387,5 +387,3 @@ export const ANALYSIS_TEXT = {
item: (title: string, item: string) => `${title} · ${item}`, item: (title: string, item: string) => `${title} · ${item}`,
}, },
} }
export const shortPhrase = short
+12 -5
View File
@@ -13,8 +13,9 @@ import {
} from '../../lib/evidence' } from '../../lib/evidence'
import { formatNumber } from '../../lib/format' import { formatNumber } from '../../lib/format'
import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms'
import { ANALYSIS_TEXT } from '../../lib/termsAnalysis'
import { IssuanceBanner } from '../../run/AssistantStream' import { IssuanceBanner } from '../../run/AssistantStream'
import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc' import { EvidenceDoc, claimMarksOf, type EvidenceDocHandle } from '../../run/EvidenceDoc'
import { EvidenceGuessView } from '../../run/EvidenceGuess' import { EvidenceGuessView } from '../../run/EvidenceGuess'
import { ReportInquiry, inquiryOpen, locateIn, useInquiryAccess } from '../../run/Inquiry' import { ReportInquiry, inquiryOpen, locateIn, useInquiryAccess } from '../../run/Inquiry'
import { Markdown, inlineStyles } from '../../run/Markdown' import { Markdown, inlineStyles } from '../../run/Markdown'
@@ -119,7 +120,8 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
// 只收有状态的片段,和 EvidenceDoc 能打开的是同一批:违规行指着的文字片段(粗体、链接里的可疑名字)、 // 只收有状态的片段,和 EvidenceDoc 能打开的是同一批:违规行指着的文字片段(粗体、链接里的可疑名字)、
// 结构片段(列表序号 100. 里的数字)在正文里没有能点的地方,画成按钮就是点了没反应。 // 结构片段(列表序号 100. 里的数字)在正文里没有能点的地方,画成按钮就是点了没反应。
// 结论句的行(证据相矛盾、证据不足……)打开的是这一句的句末徽标(`报告:claim:句子`):句子有判定(封存的、 // 结论句的行(证据相矛盾、证据不足……)打开的是这一句的句末徽标(`报告:claim:句子`):句子有判定(封存的、
// 证据图里封存后追加的)才有徽标 // 证据图里封存后追加的)才有徽标。分析说法违规(判断词未挂分析结论……)落在的那段文字没有状态,正文里也点得开
//(claimMarksOf,和 EvidenceDoc 画波浪线的是同一批)
const openable = useMemo(() => { const openable = useMemo(() => {
const keys = new Set<string>() const keys = new Set<string>()
for (const r of reports) { for (const r of reports) {
@@ -129,6 +131,7 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
for (const seg of u.segments ?? []) if (segmentState(seg)) keys.add(`${r.node_id}:${seg.id}`) for (const seg of u.segments ?? []) if (segmentState(seg)) keys.add(`${r.node_id}:${seg.id}`)
if (verdictState(unitVerdict(u, late))) keys.add(`${r.node_id}:claim:${u.id}`) if (verdictState(unitVerdict(u, late))) keys.add(`${r.node_id}:claim:${u.id}`)
} }
if (doc) for (const id of claimMarksOf(doc).keys()) keys.add(`${r.node_id}:${id}`)
} }
return keys return keys
}, [reports, docOf, graph]) }, [reports, docOf, graph])
@@ -619,7 +622,8 @@ function AuditLine({ row, current, openable, multiReport, labelOf, onOpen, onFoc
const meta = EVIDENCE_STATE[row.state] const meta = EVIDENCE_STATE[row.state]
const text = plain(row.text) const text = plain(row.text)
const sentence = plain(row.sentence) const sentence = plain(row.sentence)
const label = `${text},${meta.label}${row.source ? `:${row.source}` : ''}` const claimIssue = row.kind === 'violation' && row.issue ? ANALYSIS_TEXT.claim.label[row.issue] : undefined
const label = `${text},${claimIssue ?? meta.label}${row.source ? `:${row.source}` : ''}`
const common = { const common = {
tabIndex: current ? 0 : -1, 'data-audit-focus': row.key, onFocus, onKeyDown: onKey, tabIndex: current ? 0 : -1, 'data-audit-focus': row.key, onFocus, onKeyDown: onKey,
className: clsx('mono max-w-full truncate rounded-sm text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]', className: clsx('mono max-w-full truncate rounded-sm text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]',
@@ -638,8 +642,11 @@ function AuditLine({ row, current, openable, multiReport, labelOf, onOpen, onFoc
<span {...common} className={clsx(common.className, 'block')} title={text}>{text}</span> <span {...common} className={clsx(common.className, 'block')} title={text}>{text}</span>
)} )}
</td> </td>
<td className="px-2 py-1.5" style={{ color: meta.color }}> <td className="px-2 py-1.5" style={{ color: meta.color }} data-audit-issue={row.issue ?? ''}>
{meta.glyph && <span aria-hidden className="mr-0.5">{meta.glyph}</span>}{meta.label} {/* 说法违规(判断词未挂分析结论……):写它自己的叫法,不写「无证据」——说的不是数字没有出处 */}
{claimIssue
? <>{meta.glyph && <span aria-hidden className="mr-0.5">{meta.glyph}</span>}{claimIssue}</>
: <>{meta.glyph && <span aria-hidden className="mr-0.5">{meta.glyph}</span>}{meta.label}</>}
</td> </td>
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<span className="block text-dim [overflow-wrap:anywhere]" data-audit-source="">{row.source || '—'}</span> <span className="block text-dim [overflow-wrap:anywhere]" data-audit-source="">{row.source || '—'}</span>
+6
View File
@@ -6,6 +6,7 @@
*/ */
import type { Approval, Run, RunStatus, RunUsage } from '../../types' import type { Approval, Run, RunStatus, RunUsage } from '../../types'
import { ANALYSIS_TEXT, analysisMethodLabel } from '../../lib/termsAnalysis'
import { import {
RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode, RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode,
} from '../../lib/status' } from '../../lib/status'
@@ -341,6 +342,7 @@ export const ARTIFACT_KIND_LABEL: Record<string, string> = {
challenge: '反方审稿记录', challenge: '反方审稿记录',
inquiry_answer: INQUIRY_TEXT.artifactKind, inquiry_answer: INQUIRY_TEXT.artifactKind,
metric_input: RUN_ARTIFACTS_TEXT.kindMetricInput, metric_input: RUN_ARTIFACTS_TEXT.kindMetricInput,
analysis_result: ANALYSIS_TEXT.evidence.title,
} }
export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind
@@ -355,6 +357,10 @@ export function artifactDescription(kind: string, meta?: Record<string, any> | n
case 'metric_set': return meta?.caliber case 'metric_set': return meta?.caliber
? `口径卡「${meta.caliber}」${meta.version ?? ''}` : '口径卡的指标、算式和输入' ? `口径卡「${meta.caliber}」${meta.version ?? ''}` : '口径卡的指标、算式和输入'
case 'report_doc': return '报告:逐段的片段和每个数字的出处' case 'report_doc': return '报告:逐段的片段和每个数字的出处'
// 一份分析结论(工件行的 meta 里有 {op, status}):哪一种分析,没有做成的照实写
case 'analysis_result': return meta?.op
? `${analysisMethodLabel(meta.op)}${meta.status === 'refused' ? `(${ANALYSIS_TEXT.step.refused})` : ''}`
: ANALYSIS_TEXT.artifactHint
// 查库时冻结的表结构(工件行的 meta 里有 {source, tables: 表数量}):报告里的表名、字段名按它核对 // 查库时冻结的表结构(工件行的 meta 里有 {source, tables: 表数量}):报告里的表名、字段名按它核对
case 'schema_snapshot': return meta?.source case 'schema_snapshot': return meta?.source
? `表结构:数据源「${meta.source}」${typeof meta.tables === 'number' ? ` · ${meta.tables} 张表` : ''}` : '查询时保存的表结构' ? `表结构:数据源「${meta.source}」${typeof meta.tables === 'number' ? ` · ${meta.tables} 张表` : ''}` : '查询时保存的表结构'
+472
View File
@@ -0,0 +1,472 @@
import { useState, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { evidenceValue as valueText } from '../lib/evidence'
import { NONE, shortId } from '../lib/format'
import { EVIDENCE_TEXT } from '../lib/terms'
import { ANALYSIS_TEXT } from '../lib/termsAnalysis'
import type {
AnalysisProbe, AnalysisScope, AnalysisScopeItem, EvidenceAnalysisStep, EvidenceComparability, EvidenceSeal, EvidenceViolation,
} from '../types'
import { ResultTable } from './AssistantStream'
import { useEvidenceHost } from './evidenceHost'
/*
* 证据面板、正文里分析结论的几块(docs/design/analysis-round/00-总方案.md §3.3、§3.6):
*
* - 分析结论的出处链:判定(判定语、判定标准、点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、Agent 写的假设)
* → 方法(方法与版本、参数、实际用的阈值、现查结果)→ 计算(关键数的代入式、具名的数、结果表)→ 输入(每份一步:角色、落到哪个
* Q、用到的列和行、总数那一格和闭合核对、下钻的上一层)→ 复算 → 封存。步骤、说法照导出页(backend/app/report_export/runtime.js
* 的 analysisPart…analysisSealPart):同一条链两处说法一样。输入快照的查询步骤由证据面板照常画(QueryPart),这里只画分析的几步
* - 口径卡指标上的两期可比性:判定、判定语、原因,展开看那份可比性结论的整条链
* - 正文里的说法违规:判断词未挂分析结论、判断与分析结论不符、因果说法没有依据,原话照服务端
* - 报告末尾系统写的「分析范围」:现象、拆过、排除、算不了、没拆、检验,编号点得开
*
* 界面层不放逻辑:判定语、判定标准、原因、附带说明、代入式、闭合核对的数、复算的那一句都是服务端写好的字,这里只排版。
*/
const E = ANALYSIS_TEXT.evidence
/** 面板里的一节:小标题 + 内容(同 EvidencePanel 的 Part) */
function Part({ title, children, ...rest }: { title: ReactNode; children: ReactNode } & Record<`data-${string}`, string>) {
return (
<div {...rest}>
<div className="mb-1 text-2xs font-medium text-faint">{title}</div>
{children}
</div>
)
}
/** 一组「名称 · 值」 */
function Rows({ rows }: { rows: ([string, ReactNode] | null | false | undefined)[] }) {
const shown = rows.filter((r): r is [string, ReactNode] => !!r)
if (!shown.length) return null
return (
<dl className="space-y-1">
{shown.map(([k, v]) => (
<div key={k} className="flex gap-2">
<dt className="w-20 shrink-0 text-2xs text-faint">{k}</dt>
<dd className="min-w-0 flex-1 [overflow-wrap:anywhere]">{v}</dd>
</div>
))}
</dl>
)
}
/** 逐项复核出来的问题:正常时不说,出问题才醒目(同 EvidencePanel 的 IntegrityList) */
function Problems({ code, items }: { code: string; items: string[] }) {
if (!items.length) return null
return (
<ul className="mt-1.5 space-y-0.5 rounded border px-2 py-1" data-ev-integrity={code}
style={{ color: 'var(--st-failed)', borderColor: 'var(--st-failed)', background: 'var(--st-failed-soft)' }}>
{items.map((t) => <li key={t}>{t}</li>)}
</ul>
)
}
const subjectsText = (subjects: unknown): string => (Array.isArray(subjects)
? subjects.map((s) => (Array.isArray(s) ? s.map((x) => valueText(x)).join(' · ') : valueText(s))).join('、') : '')
/** 现查结果的一条:有 expr 的是按运行当天现算的界,其余是一列的实际范围;现查到的范围最早、最晚都空是这一列一个值都没有 */
function probeText(p: AnalysisProbe): string {
const how = `(${E.probeBasis[String(p.basis ?? '')] ?? p.basis ?? NONE}${p.at ? ` · ${p.at}` : ''})`
if (typeof p.expr === 'string' && p.expr) return `${E.probeBound(p.expr, p.value || NONE)}${how}`
const where = `${p.table || NONE}.${p.column || NONE}`
const empty = p.basis === 'live' && !p.min && !p.max
return `${where}:${empty ? E.probeEmpty : E.probeRange(p.min || NONE, p.max || NONE)}${how}`
}
function NodeName({ id, label }: { id?: string | null; label?: string | null }) {
const host = useEvidenceHost()
if (!id) return null
const known = host.nodeLabel?.(id)
if (host.onNode && known !== undefined) {
return (
<button type="button" className="chip max-w-full truncate transition-colors hover:bg-hover" data-ev-node={id}
title={`在画布上选中并对准「${known || id}」`} onClick={() => host.onNode!(id)}>
{known || label || id}
</button>
)
}
return <span data-ev-node={id}>{label || id}</span>
}
/** 判定:判定语和它的依据,谁做的,复算、封存的结论(细节在后面几步) */
function JudgedPart({ step, recompute, seal, overall }: {
step: EvidenceAnalysisStep; recompute?: EvidenceAnalysisStep; seal?: EvidenceAnalysisStep; overall?: EvidenceSeal
}) {
const verdict = step.verdict && typeof step.verdict === 'object' ? step.verdict : null
const cited = step.cited ?? {}
const citedText = cited.what === 'value' ? E.citedValue(String(cited.key ?? ''))
: cited.what === 'cell' ? E.citedCell(Number(cited.row) || 0, String(cited.column ?? '')) : E.citedPhrase
const refused = step.status === 'refused'
const trusted = !!overall?.sealed && overall.ok !== false
const problems = [
step.eid_ok === false ? EVIDENCE_TEXT.integrity.eid : '',
step.render_ok === false && step.hash_ok !== false ? E.renderBad : '',
step.note ?? '',
trusted && seal && seal.sealed === false && seal.note ? seal.note : '',
].filter(Boolean)
const recomputeTone = recompute?.status === 'same' ? 'ok' : recompute?.status === 'different' ? 'bad' : 'none'
return (
<Part title={E.titleOf(step.alias ?? '', step.method_label ?? '')} data-ev-analysis={step.alias ?? ''}>
{step.phrase && (
<p className="font-medium" data-ev-analysis-phrase="">{step.phrase}</p>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-dim">
{refused && (
<span className="chip" style={{ color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' }} data-ev-analysis-refused="">
{E.refused}
</span>
)}
<span data-ev-analysis-origin={step.origin ?? ''}>{E.origin[String(step.origin ?? '')] ?? NONE}</span>
<NodeName id={step.node_id} label={step.label} />
{step.artifact && <span className="mono text-faint" title={`${E.artifact} ${step.artifact}`}>{E.artifact} {shortId(step.artifact)}</span>}
</div>
<Problems code="analysis" items={problems} />
<div className="mt-2">
<Rows rows={[
step.criterion ? [E.criterion, step.criterion] : null,
verdict?.subjects?.length ? [E.subjects, subjectsText(verdict.subjects)] : null,
verdict?.direction ? [E.direction, E.directionLabel[verdict.direction] ?? verdict.direction] : null,
refused && step.reason ? [E.reason, <span key="r" style={{ color: 'var(--st-waiting)' }}>{step.reason}</span>] : null,
[E.citedPart, <span key="c" data-ev-analysis-cited={cited.what ?? 'phrase'}>{citedText}</span>],
step.caveats?.length ? [E.caveats, (
<ul key="cv" className="space-y-0.5" data-ev-analysis-caveats="">
{step.caveats.map((c, i) => <li key={`${c.code ?? ''}-${i}`}>{c.text}</li>)}
</ul>
)] : null,
step.hypothesis ? [E.hypothesis, (
<span key="h" data-ev-analysis-hypothesis="">
{step.hypothesis}
{step.hypothesis_note && <span className="mt-0.5 block text-2xs text-faint">{step.hypothesis_note}</span>}
</span>
)] : null,
recompute ? [E.recompute, (
<span key="rc" data-ev-analysis-recompute={recomputeTone}
style={{ color: recomputeTone === 'ok' ? 'var(--st-done)' : recomputeTone === 'bad' ? 'var(--st-failed)' : 'var(--st-cancelled)' }}>
{recompute.text || NONE}
</span>
)] : null,
seal ? [E.seal, (
<span key="s" data-ev-analysis-seal={seal.via === 'metric_set' ? 'card' : seal.ledger ? 'ledger' : 'none'}>
{seal.via === 'metric_set' ? E.viaCard : seal.ledger ? E.inLedger : E.notInLedger}
</span>
)] : null,
]} />
</div>
</Part>
)
}
/** 方法:方法与版本、参数(规范化后的)、实际用的阈值(只列这个算子用到的)、现查结果 */
function MethodPart({ step }: { step: EvidenceAnalysisStep }) {
const params = step.params && typeof step.params === 'object' && Object.keys(step.params).length ? step.params : null
return (
<Part title={E.method} data-ev-analysis-method="">
<Rows rows={[
[E.methodVersion, <span key="m">{step.method_label || NONE} · <span className="mono">{step.method || NONE}</span></span>],
step.settings?.length ? [E.settings, (
<ul key="s" className="space-y-0.5" data-ev-analysis-settings="">
{step.settings.map((x) => (
<li key={x.key}>{x.label || x.key} <span className="mono tnum">{valueText(x.value)}</span></li>
))}
</ul>
)] : null,
step.probes?.length ? [E.probes, (
<ul key="p" className="space-y-0.5" data-ev-analysis-probes="">
{step.probes.map((p, i) => <li key={i}>{probeText(p)}</li>)}
</ul>
)] : null,
]} />
{params && (
<div className="mt-1.5">
<div className="mb-0.5 text-2xs text-faint">{E.params}</div>
<pre className="mono max-h-36 overflow-auto whitespace-pre-wrap rounded bg-bg px-1.5 py-1 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]"
data-ev-analysis-params="">
{JSON.stringify(params, null, 2)}
</pre>
</div>
)}
</Part>
)
}
/** 计算:关键数的代入式、具名的数(被引用的那个描出来)、结果表(被引用的那一格高亮,遮罩的列写「已遮罩」) */
function CalcPart({ step }: { step: EvidenceAnalysisStep }) {
const table = step.table && Array.isArray(step.table.columns) && Array.isArray(step.table.rows) ? step.table : null
const labels = table?.labels ?? {}
const heads = table ? table.columns.map((c) => labels[c] || c) : []
const index = table ? (Array.isArray(table.row_index) ? table.row_index : table.rows.map((_, i) => i)) : []
const masked = new Set(table?.masked ?? [])
// 高亮的格按快照行号给:换成窗口里的下标、列 id 换成表头上的叫法
const cells = (table?.highlight?.cells ?? [])
.map(([r, c]) => [index.indexOf(r), labels[c] || c] as [number, string]).filter(([i]) => i >= 0)
return (
<Part title={E.calc} data-ev-analysis-calc="">
{!!step.formulas?.length && (
<div className="mb-1.5">
<div className="mb-0.5 text-2xs text-faint">{E.formulas}</div>
<pre className="mono whitespace-pre-wrap rounded bg-bg px-1.5 py-1 text-2xs leading-relaxed [overflow-wrap:anywhere]"
data-ev-analysis-formulas="">
{step.formulas.map((f) => f.text ?? '').filter(Boolean).join('\n')}
</pre>
</div>
)}
{!!step.values?.length && (
<div className="mb-1.5">
<div className="mb-0.5 text-2xs text-faint">{E.values}</div>
<ul className="flex flex-wrap gap-1" data-ev-analysis-values="">
{step.values.map((v) => (
<li key={v.key} className="chip mono" data-ev-analysis-value={v.key}
{...(v.cited ? { 'data-ev-cited': '' } : {})}
style={v.cited ? { color: 'var(--st-done)', borderColor: 'var(--st-done)' } : undefined}>
{v.key} <span className="tnum">{v.rendered ?? NONE}</span>
</li>
))}
</ul>
</div>
)}
{table && (
<div data-ev-analysis-table="">
<div className="mb-0.5 text-2xs text-faint">
{E.resultTable}({E.resultRows(table.rows.length, table.total_rows ?? table.rows.length)})
</div>
<ResultTable table={{
columns: heads,
rows: table.rows.map((r) => (Array.isArray(r) ? r.map((v, j) => (masked.has(table.columns[j]) ? EVIDENCE_TEXT.masked : v)) : [])),
truncated: false,
}} highlight={{ rows: [], cols: [], cells }} masked={[...masked].map((c) => labels[c] || c)} />
{table.window_truncated && <p className="mt-0.5 text-2xs text-faint">{EVIDENCE_TEXT.windowCut(table.rows.length)}</p>}
</div>
)}
</Part>
)
}
/** 输入的一份:角色、调用里写的名字、落到哪个 Q(点一下跳到那个查询步骤)、用到的列和行、总数那一格和闭合核对、下钻的上一层 */
function InputPart({ step, onQuery, trusted }: { step: EvidenceAnalysisStep; onQuery?: (alias: string) => void; trusted: boolean }) {
const closure = step.closure && typeof step.closure === 'object' ? step.closure : null
const ratio = closure?.mode === 'ratio'
const closed = closure ? (ratio ? (closure.closed ? E.ratioClosureOk : E.ratioClosureBad)
: (closure.closed ? E.closureOk : E.closureBad))(closure.headline ?? NONE, closure.total ?? NONE) : null
const rows = Array.isArray(step.rows) ? E.rowsList(step.rows.filter((r): r is number => typeof r === 'number'))
: step.rows === 'all' ? E.allRows : null
const where = step.query || step.alias || ''
const parent = step.role === 'parent'
return (
<Part title={where ? E.inputOf(where) : E.input} data-ev-analysis-input={step.role ?? ''}>
<Rows rows={[
[E.role, step.role_label || step.role || NONE],
step.ref ? [E.ref, <code key="r" className="mono">{step.ref}</code>] : null,
step.query ? [E.query, onQuery
? <button key="q" type="button" className="chip mono hover:bg-hover" data-ev-goto-query={step.query}
onClick={() => onQuery(step.query!)}>{EVIDENCE_TEXT.gotoQuery(step.query)}</button>
: <span key="q" className="mono">{step.query}</span>] : null,
parent && step.alias ? [E.parent, <span key="p" className="mono">{step.alias}</span>] : null,
step.columns?.length ? [E.columnsUsed, <span key="c" className="mono">{step.columns.join('、')}</span>] : null,
rows ? [E.rowsUsed, rows] : null,
'value' in step && !parent ? [ratio ? E.headlineRatio : E.headline, (
<span key="v" className="tnum">
{step.column != null && typeof step.row === 'number' ? `${E.cellAt(step.row, step.column)} = ` : ''}{valueText(step.value)}
</span>
)] : null,
closure && closed ? [E.closure, (
<span key="cl" data-ev-analysis-closure={closure.closed ? 'closed' : 'open'}
style={{ color: closure.closed ? 'var(--st-done)' : 'var(--st-waiting)' }}>{closed}</span>
)] : null,
step.phrase ? [E.phrase, step.phrase] : null,
]} />
<Problems code="sealed" items={trusted && step.sealed === false ? [EVIDENCE_TEXT.integrity.sealed] : []} />
</Part>
)
}
/**
* 分析结论的出处链(查询步骤之外的那几步)。pending、failed 是证据链还没回来、回不来时的说法(同指标一节);onQuery 点输入里的
* Q 跳到下面的查询步骤(证据面板给)
*/
export function AnalysisChain({ chain, seal, pending, failed, onQuery }: {
chain: EvidenceAnalysisStep[]
seal?: EvidenceSeal
pending?: boolean
failed?: string
onQuery?: (alias: string) => void
}) {
const judged = chain.find((s) => s.step === 'analysis')
if (!judged) {
return (
<Part title={E.title} data-ev-analysis="">
<p className="text-2xs text-faint" data-ev-chain={pending ? 'loading' : 'missing'}>
{pending ? E.chainPending : failed || EVIDENCE_TEXT.chainMissing}
</p>
</Part>
)
}
const trusted = !!seal?.sealed && seal.ok !== false
const method = chain.find((s) => s.step === 'analysis_method')
const calc = chain.find((s) => s.step === 'analysis_calc')
const inputs = chain.filter((s) => s.step === 'analysis_input')
return (
<>
<JudgedPart step={judged} recompute={chain.find((s) => s.step === 'analysis_recompute')}
seal={chain.find((s) => s.step === 'analysis_seal')} overall={seal} />
{method && <MethodPart step={method} />}
{calc && <CalcPart step={calc} />}
{inputs.map((s, i) => <InputPart key={`${s.ref ?? ''}-${s.role ?? ''}-${i}`} step={s} onQuery={onQuery} trusted={trusted} />)}
</>
)
}
/**
* 指标步骤上的两期可比性(口径卡跨两份同形查询算的环比、同比,执行时自动做的检查):判定、判定语、原因一句一个,展开看那份
* 可比性结论的整条链(两份输入快照的查询步骤指标链里已经有了,不再给)。不进台账、随口径卡的指标集封存
*/
export function ComparabilityRow({ comparability, seal }: { comparability: EvidenceComparability; seal?: EvidenceSeal }) {
const [open, setOpen] = useState(false)
const verdict = String(comparability.verdict ?? '')
const tone = verdict === 'comparable' ? 'var(--st-done)' : verdict === 'incomparable' ? 'var(--st-failed)' : 'var(--st-waiting)'
const steps = Array.isArray(comparability.steps) ? comparability.steps.filter((s) => s && typeof s === 'object') : []
const trusted = !!seal?.sealed && seal.ok !== false
const problems = [
comparability.hash_ok === false ? E.comparabilityHash : '',
trusted && comparability.sealed === false && comparability.hash_ok !== false ? E.comparabilityUnsealed : '',
].filter(Boolean)
return (
<div data-ev-comparability={verdict}>
<dt className="text-2xs text-faint">{E.comparability}</dt>
<dd>
<div className="flex flex-wrap items-baseline gap-x-1.5">
<span className="chip" style={{ color: tone, borderColor: tone }}>{E.comparabilityLabel[verdict] ?? (verdict || NONE)}</span>
{comparability.phrase && <span data-ev-comparability-phrase="">{comparability.phrase}</span>}
</div>
{!!comparability.reasons?.length && (
<ul className="mt-0.5 space-y-0.5 text-2xs text-dim" data-ev-comparability-reasons="">
{comparability.reasons.map((r) => <li key={r}>· {r}</li>)}
</ul>
)}
<Problems code="comparability" items={problems} />
{steps.length > 0 && (
<button type="button" className="btn btn-xs btn-ghost -ml-1 mt-0.5" aria-expanded={open}
data-ev-comparability-toggle="" onClick={() => setOpen((v) => !v)}>
<ChevronRight size={11} aria-hidden style={{ transform: open ? 'rotate(90deg)' : 'none' }} />
{open ? E.comparabilityClose : E.comparabilityOpen}
</button>
)}
{open && (
<div className="mt-1 space-y-3 border-l-2 pl-2" data-ev-comparability-chain="">
<AnalysisChain chain={steps} seal={seal} />
</div>
)}
</dd>
</div>
)
}
/** 说法违规的代码(服务端 engine/analysis_claims.CODES) */
export const CLAIM_CODES = new Set(Object.keys(ANALYSIS_TEXT.claim.label))
export const isClaimViolation = (v: Pick<EvidenceViolation, 'code'>): boolean => CLAIM_CODES.has(v.code)
/** 证据面板:落在这个片段上的说法违规,一处一条:种类、服务端的原话、所挂的分析结论(点一下看那份结论) */
export function ClaimFlags({ violations, onAnalysis }: {
violations: EvidenceViolation[]
onAnalysis?: (alias: string) => void
}) {
if (!violations.length) return null
return (
<Part title={ANALYSIS_TEXT.claim.panelTitle} data-ev-claim-flags={String(violations.length)}>
<ul className="space-y-1.5">
{violations.map((v, i) => (
<li key={`${v.code}-${i}`} className="rounded border px-2 py-1" data-ev-claim-flag={v.code}
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))' }}>
<div className="flex flex-wrap items-baseline gap-x-1.5">
{v.text && <span className="font-semibold">「{v.text}」</span>}
<span className="chip" style={{ color: 'var(--st-waiting)' }}>{ANALYSIS_TEXT.claim.label[v.code] ?? v.code}</span>
</div>
{v.message && <p className="mt-0.5 text-dim">{v.message}</p>}
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-2xs text-faint">
{v.analyses?.length ? (
<>
<span>{ANALYSIS_TEXT.claim.analyses}</span>
{v.analyses.map((a) => (onAnalysis
? <button key={a} type="button" className="chip mono hover:bg-hover" data-ev-claim-analysis={a}
onClick={() => onAnalysis(a)}>{a}</button>
: <span key={a} className="mono">{a}</span>))}
</>
) : v.code !== 'causal_unsupported' && <span>{ANALYSIS_TEXT.claim.noAnalyses}</span>}
</div>
</li>
))}
</ul>
</Part>
)
}
/** 分析范围的一条:编号(点得开)、判定语;拆过的写按哪些维度、下钻自谁;算不了的判定语里已经写了原因 */
function ScopeLine({ item, onOpen }: { item: AnalysisScopeItem; onOpen?: (alias: string) => void }) {
const alias = item.alias ?? ''
const extra = [item.dims?.length ? ANALYSIS_TEXT.scope.dims(item.dims) : '', item.parent ? ANALYSIS_TEXT.scope.drill(item.parent) : '']
.filter(Boolean).join(' · ')
return (
<li className="flex items-baseline gap-1.5" data-ev-scope-item={alias}>
{alias && (onOpen
? <button type="button" className="chip mono shrink-0 hover:bg-hover" data-seg={`scope:${alias}`}
aria-label={ANALYSIS_TEXT.scope.open(alias)} onClick={() => onOpen(alias)}>{alias}</button>
: <span className="chip mono shrink-0">{alias}</span>)}
<span className="min-w-0 [overflow-wrap:anywhere]">
{item.phrase || NONE}
{extra && <span className="ml-1 text-2xs text-faint">({extra})</span>}
</span>
</li>
)
}
/**
* 报告末尾系统写的「分析范围」(文档的 analysis_scope,contract.ScopeBlock):不是写作者写的,随报告封存。几组都照服务端分好的
* 排:现象(可比性、基线、趋势)、拆过(按下钻链,上一层在前)、排除(分散、差异不足以下结论)、算不了、没拆(候选维度里没拆过的;
* 核对不了时照实说)、检验(做了几次差异检验、校正过没有)。编号点得开(onOpen 打开那份结论的出处链)
*/
export function ScopeSection({ scope, onOpen }: { scope: AnalysisScope; onOpen?: (alias: string) => void }) {
const S = ANALYSIS_TEXT.scope
const groups: [string, string, AnalysisScopeItem[]][] = [
['phenomena', S.phenomena, scope.phenomena ?? []],
['decomposed', S.decomposed, scope.decomposed ?? []],
['excluded', S.excluded, scope.excluded ?? []],
['refused', S.refused, scope.refused ?? []],
]
const shown = groups.filter(([, , items]) => items.length)
const notDecomposed = scope.not_decomposed === null || scope.not_decomposed === undefined
? (scope.note || (scope.not_decomposed === null ? S.notDecomposedUnknown : ''))
: scope.not_decomposed.length ? S.notDecomposedList(scope.not_decomposed) : S.notDecomposedNone
const tests = typeof scope.tests === 'number' && scope.tests > 0 ? S.testsText(scope.tests, scope.corrected) : ''
if (!shown.length && !notDecomposed && !tests) return null
return (
<section className="mt-3 rounded-lg border px-3 py-2" data-ev-scope="" aria-label={S.title}>
<div className="flex flex-wrap items-baseline gap-x-2">
<h4 className="text-xs font-semibold">{S.title}</h4>
<span className="text-2xs text-faint">{S.hint}</span>
</div>
<dl className="mt-1.5 space-y-1.5 text-xs">
{shown.map(([key, title, items]) => (
<div key={key} data-ev-scope-group={key}>
<dt className="text-2xs font-medium text-faint">{title}</dt>
<dd><ul className="mt-0.5 space-y-0.5">{items.map((it, i) => <ScopeLine key={`${it.alias ?? ''}-${i}`} item={it} onOpen={onOpen} />)}</ul></dd>
</div>
))}
{notDecomposed && (
<div data-ev-scope-group="not_decomposed">
<dt className="text-2xs font-medium text-faint">{S.notDecomposed}</dt>
<dd className="mt-0.5" data-ev-scope-not-decomposed={scope.not_decomposed == null ? 'unknown' : String(scope.not_decomposed.length)}>
{notDecomposed}
</dd>
</div>
)}
{tests && (
<div data-ev-scope-group="tests">
<dt className="text-2xs font-medium text-faint">{S.tests}</dt>
<dd className="mt-0.5">{tests}</dd>
</div>
)}
</dl>
</section>
)
}
+123 -12
View File
@@ -1,7 +1,7 @@
import { import {
Fragment, forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, Fragment, forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo,
useRef, useState, type CSSProperties, type FocusEvent as ReactFocusEvent, useRef, useState, type CSSProperties, type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject,
} from 'react' } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { import {
@@ -10,10 +10,12 @@ import {
import clsx from 'clsx' import clsx from 'clsx'
import { rovingTarget } from '../components/ui' import { rovingTarget } from '../components/ui'
import { import {
EVIDENCE_STATE, alertTier, claimCounts, claimLabel, claimProblems, conflicting, docTally, segmentKind, segmentLabel, EVIDENCE_STATE, alertTier, claimCounts, claimLabel, claimProblems, codePointIndex, conflicting, docTally, segmentKind,
segmentState, tallySummary, unitText, isJudged, limitOf, unitVerdict, verdictMissing, verdictState, withVerdicts, segmentLabel, segmentState, tallySummary, unitText, isJudged, limitOf, unitVerdict, verdictMissing, verdictState, withVerdicts,
type EvidenceStateCode, type EvidenceTally as Tally, type EvidenceStateCode, type EvidenceTally as Tally,
} from '../lib/evidence' } from '../lib/evidence'
import { ANALYSIS_TEXT } from '../lib/termsAnalysis'
import { ScopeSection, isClaimViolation } from './EvidenceAnalysis'
import { formatNumber } from '../lib/format' import { formatNumber } from '../lib/format'
import { import {
CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, LEDGER_TEXT, claimTally, evidenceTally, CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, LEDGER_TEXT, claimTally, evidenceTally,
@@ -55,6 +57,11 @@ import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type In
* 相关口径的数不加标记,只在证据面板的「口径」一步里说。标签按运行另取(GET /runs/{id}/ledger,store/ledger 按退避 * 相关口径的数不加标记,只在证据面板的「口径」一步里说。标签按运行另取(GET /runs/{id}/ledger,store/ledger 按退避
* 等比对出结果),正式运行、没有运行的文档不取。 * 等比对出结果),正式运行、没有运行的文档不取。
* *
* 分析能力一轮:判定语([[a:A1]])是一个系统渲染的片段,和数字一样画「有出处」、点得开(证据面板的分析一步)。说法违规
* (判断词未挂分析结论、判断与分析结论不符、因果说法没有依据,文档的 violations 按 unit / segment / span 给)标在正文上:判断词
* 那几个字画琥珀色波浪线,那段文字是一个可点的片段,句末挂「说法待核」,n / N 跳得到;点开看服务端的原话和所挂的结论。正文
* 后面是系统写的「分析范围」(文档的 analysis_scope),编号点得开。
*
* 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加 * 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加
* content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。 * content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。
*/ */
@@ -125,8 +132,47 @@ interface Model {
grid: Map<string, string> grid: Map<string, string>
} }
/**
* 说法违规落在哪段文字上:片段 id → 违规的种类、判断词在这段文字里的位置(UTF-16 下标,[起, 止))。服务端的 span 是全文的码点
* 位置,片段自己的 span 也是,相减后按码点换成下标;没给 span 的整段都算。只认说法违规(isClaimViolation)、能落到一段非结构
* 片段上的
*/
type ClaimMarks = Map<string, { codes: string[]; ranges: [number, number][] }>
export function claimMarksOf(doc: EvidenceDocData): ClaimMarks {
const out: ClaimMarks = new Map()
const list = (doc.violations ?? []).filter((v) => isClaimViolation(v) && v.segment)
if (!list.length) return out
const segs = new Map<string, EvidenceSegment>()
for (const b of doc.blocks ?? []) for (const u of b.units ?? []) for (const sg of u.segments ?? []) segs.set(sg.id, sg)
for (const v of list) {
const seg = segs.get(v.segment!)
if (!seg || seg.kind === 'structural' || segmentState(seg)) continue
const mark = out.get(seg.id) ?? { codes: [], ranges: [] }
if (!mark.codes.includes(v.code)) mark.codes.push(v.code)
const at = codePointIndex(seg.text)
const from = v.span && seg.span ? v.span[0] - seg.span[0] : 0
const to = v.span && seg.span ? v.span[1] - seg.span[0] : Infinity
const a = Math.max(0, Math.min(seg.text.length, at(Math.max(0, from))))
const z = Math.max(a, Math.min(seg.text.length, to === Infinity ? seg.text.length : at(Math.max(0, to))))
if (z > a) mark.ranges.push([a, z])
out.set(seg.id, mark)
}
for (const m of out.values()) {
// 有重叠的合在一起;一处位置都没认出来的整段画线
m.ranges.sort((x, y) => x[0] - y[0])
m.ranges = m.ranges.reduce<[number, number][]>((acc, r) => {
const last = acc[acc.length - 1]
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1])
else acc.push([...r])
return acc
}, [])
}
return out
}
function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>, ask = false, function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>, ask = false,
marks?: Record<string, string>): Model { marks?: Record<string, string>, claims?: ClaimMarks): Model {
const order: string[] = [] const order: string[] = []
const index = new Map<string, number>() const index = new Map<string, number>()
const seg = new Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>() const seg = new Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>()
@@ -146,13 +192,15 @@ function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVe
for (const s of unit.segments ?? []) { for (const s of unit.segments ?? []) {
if (s.kind !== 'structural') total += s.text.length if (s.kind !== 'structural') total += s.text.length
const state = segmentState(s) const state = segmentState(s)
if (!state) continue // 说法违规落在的那段文字:本身没有状态,也进键盘顺序,算第 1 轮的问题(n / N 跳得到)
const claimed = !state && !!claims?.has(s.id)
if (!state && !claimed) continue
seg.set(s.id, { seg: s, unit, block: b }) seg.set(s.id, { seg: s, unit, block: b })
index.set(s.id, order.length) index.set(s.id, order.length)
order.push(s.id) order.push(s.id)
if (first) { unitFirst.push(s.id); first = false } if (first) { unitFirst.push(s.id); first = false }
unitOf.set(s.id, unitFirst.length - 1) unitOf.set(s.id, unitFirst.length - 1)
const t = alertTier(state) const t = claimed ? 1 : alertTier(state)
if (t) tiered.push([s.id, t]) if (t) tiered.push([s.id, t])
if (loc) { if (loc) {
cell.set(s.id, { block: b, row: loc.row, col: loc.col }) cell.set(s.id, { block: b, row: loc.row, col: loc.col })
@@ -377,7 +425,9 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
const graph = useRunGraph(runId, report, artifact, settled) const graph = useRunGraph(runId, report, artifact, settled)
// 被反方审过的句子:句末挂标记(和裁判的判定并在一个徽标里),点开是整句面板 // 被反方审过的句子:句末挂标记(和裁判的判定并在一个徽标里),点开是整句面板
const marks = useMemo(() => challengeMarks(graph, report, artifact), [graph, report, artifact]) const marks = useMemo(() => challengeMarks(graph, report, artifact), [graph, report, artifact])
const model = useMemo(() => buildModel(blocks, overlay, ask, marks), [blocks, overlay, ask, marks]) // 说法违规标在正文上:判断词画线、那段文字可点(文档的 violations,服务端核出来的)
const claimMarks = useMemo(() => claimMarksOf(doc), [doc])
const model = useMemo(() => buildModel(blocks, overlay, ask, marks, claimMarks), [blocks, overlay, ask, marks, claimMarks])
// 来自口径的数字上的小标记:按运行取的口径账本里这份报告的标签(只画 caliber 一种) // 来自口径的数字上的小标记:按运行取的口径账本里这份报告的标签(只画 caliber 一种)
const ledger = useRunLedger(runId, ledgerEnabled(runId, runClass)) const ledger = useRunLedger(runId, ledgerEnabled(runId, runClass))
const labels = report ? ledger?.data?.reports?.[report]?.labels : undefined const labels = report ? ledger?.data?.reports?.[report]?.labels : undefined
@@ -478,8 +528,28 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
setAnnounce(`${EVIDENCE_TEXT.violations}:${formatNumber(doc.violations?.length ?? 0)} 条`) setAnnounce(`${EVIDENCE_TEXT.violations}:${formatNumber(doc.violations?.length ?? 0)} 条`)
}, [claim, instance, doc]) }, [claim, instance, doc])
/**
* 打开一份分析结论:分析范围里的编号、说法违规里所挂的结论。正文引用了它的判定语就落到那个片段(看得到上下文),没引用的打开
* 它自己的出处链(分析范围那一项)
*/
const openAnalysis = useCallback((alias: string, opts?: { scope?: boolean }) => {
const phrase = !opts?.scope
? [...model.seg.values()].find((x) => x.seg.kind === 'analysis' && x.seg.cite?.alias === alias && !x.seg.cite?.locator?.key)
: undefined
if (phrase) {
focusSeg(phrase.seg.id, { flash: true })
openSeg(phrase.seg.id)
return
}
if (doc.catalog?.[alias]?.kind !== 'analysis') return
claim(instance)
setView({ kind: 'scope', alias })
setAnnounce(`${ANALYSIS_TEXT.evidence.title} ${alias}`)
}, [model, doc, focusSeg, openSeg, claim, instance])
const close = useCallback((refocus = true) => { const close = useCallback((refocus = true) => {
const back = view?.kind === 'seg' ? view.id : view?.kind === 'unit' ? badgeKey(view.id) : current const back = view?.kind === 'seg' ? view.id : view?.kind === 'unit' ? badgeKey(view.id)
: view?.kind === 'scope' ? `scope:${view.alias}` : current
setView(null) setView(null)
setAnnounce('') setAnnounce('')
if (refocus && back) requestAnimationFrame(() => segEl(back)?.focus()) if (refocus && back) requestAnimationFrame(() => segEl(back)?.focus())
@@ -616,6 +686,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
onClose={() => close()} onClose={() => close()}
onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }} onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }}
onViolations={openViolations} onViolations={openViolations}
onAnalysis={(alias) => openAnalysis(alias)}
/> />
) )
@@ -676,7 +747,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
<DocBlock block={block} dense={dense} doc={doc} visual={visual} <DocBlock block={block} dense={dense} doc={doc} visual={visual}
current={b === currentBlock ? current : null} current={b === currentBlock ? current : null}
active={b === activeBlock ? activeSeg : null} active={b === activeBlock ? activeSeg : null}
panelId={panelId} model={model} ask={ask} calibers={caliberMarks} /> panelId={panelId} model={model} ask={ask} calibers={caliberMarks} claims={claimMarks} />
{mode === 'inline' && b === activeBlock && panelNode} {mode === 'inline' && b === activeBlock && panelNode}
</Fragment> </Fragment>
))} ))}
@@ -695,6 +766,11 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
<button type="button" className="text-[var(--accent)] hover:underline" onClick={() => setExpanded(false)}>收起</button> <button type="button" className="text-[var(--accent)] hover:underline" onClick={() => setExpanded(false)}>收起</button>
</div> </div>
)} )}
{doc.analysis_scope && (
// 系统写的「分析范围」:不是写作者写的,随报告封存(不进正文、不过核对);编号点得开
<ScopeSection scope={doc.analysis_scope} onOpen={(alias) => openAnalysis(alias, { scope: true })} />
)}
{mode === 'inline' && view?.kind === 'scope' && panelNode}
{mode === 'dock' && panelNode && dock && createPortal(panelNode, dock)} {mode === 'dock' && panelNode && dock && createPortal(panelNode, dock)}
{mode !== 'inline' && mode !== 'dock' && panelNode && createPortal(panelNode, document.body)} {mode !== 'inline' && mode !== 'dock' && panelNode && createPortal(panelNode, document.body)}
</div> </div>
@@ -890,10 +966,12 @@ export function ClaimTally({ claims, lead }: { claims: ClaimTallyCounts; lead: b
/** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */ /** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model, ask, calibers }: { const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model, ask, calibers, claims }: {
block: EvidenceBlock block: EvidenceBlock
/** 来自口径的数字:片段 id → 小标记(口径账本) */ /** 来自口径的数字:片段 id → 小标记(口径账本) */
calibers?: CaliberMarks calibers?: CaliberMarks
/** 说法违规落在的文字:片段 id → 判断词的位置 */
claims?: ClaimMarks
dense: boolean dense: boolean
doc: EvidenceDocData doc: EvidenceDocData
/** 句末徽标从这里取:这一句眼下的判定(封存后追加的盖过文档里的) */ /** 句末徽标从这里取:这一句眼下的判定(封存后追加的盖过文档里的) */
@@ -918,7 +996,7 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac
} }
const unit = (u: EvidenceUnit, opts?: { code?: boolean; tag?: boolean }) => ( const unit = (u: EvidenceUnit, opts?: { code?: boolean; tag?: boolean }) => (
<UnitView key={u.id} unit={u} doc={doc} current={current} active={active} panelId={panelId} <UnitView key={u.id} unit={u} doc={doc} current={current} active={active} panelId={panelId}
code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))} calibers={calibers} code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))} calibers={calibers} claims={claims}
ask={ask && askable(u, block) ? (model.ask.has(askKey(u.id)) ? 'roving' : 'follow') : undefined} /> ask={ask && askable(u, block) ? (model.ask.has(askKey(u.id)) ? 'roving' : 'follow') : undefined} />
) )
@@ -1047,10 +1125,11 @@ function ChallengeMark({ verdict, look, after }: { verdict: string; look: MarkLo
* 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线。被反方审过的 * 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线。被反方审过的
* 句子同样挂徽标(和判定并在一起),点开都是整句面板 * 句子同样挂徽标(和判定并在一起),点开都是整句面板
*/ */
function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask, calibers }: { function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask, calibers, claims }: {
unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string
code?: boolean; tag: boolean; badge?: Badge code?: boolean; tag: boolean; badge?: Badge
calibers?: CaliberMarks calibers?: CaliberMarks
claims?: ClaimMarks
/** /**
* 审批质询的「质询」:roving 是这一句一个可点的都没有,「质询」本身进键盘顺序;follow 跟在这一句后面,焦点在这一句上时 * 审批质询的「质询」:roving 是这一句一个可点的都没有,「质询」本身进键盘顺序;follow 跟在这一句后面,焦点在这一句上时
* 按 Tab 到达 * 按 Tab 到达
@@ -1062,6 +1141,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask,
const styles = useMemo<InlineStyles | null>(() => (code ? null : inlineStyles(text)), [text, code]) const styles = useMemo<InlineStyles | null>(() => (code ? null : inlineStyles(text)), [text, code])
let pos = 0 let pos = 0
const alerts = new Set<EvidenceStateCode>() const alerts = new Set<EvidenceStateCode>()
let claimTag = false
const nodes = segs.map((s) => { const nodes = segs.map((s) => {
const from = pos const from = pos
pos += s.text.length pos += s.text.length
@@ -1069,6 +1149,32 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask,
const body = styles const body = styles
? <StyledSlice text={text} styles={styles} from={from} to={pos} links={!state} /> ? <StyledSlice text={text} styles={styles} from={from} to={pos} links={!state} />
: s.text : s.text
const claimed = !state ? claims?.get(s.id) : undefined
if (claimed) {
// 说法违规:判断词画琥珀色波浪线,整段文字是一个可点的片段(点开看服务端的原话),句末挂「说法待核」
claimTag = true
const slice = (a: number, z: number) => (styles
? <StyledSlice text={text} styles={styles} from={from + a} to={from + z} links={false} /> : s.text.slice(a, z))
const parts: ReactNode[] = []
let at = 0
claimed.ranges.forEach(([a, z], k) => {
if (a > at) parts.push(<Fragment key={`t${k}`}>{slice(at, a)}</Fragment>)
parts.push(<span key={`w${k}`} className="ev-claim-word" data-ev-claim-word="">{slice(a, z)}</span>)
at = z
})
if (at < s.text.length) parts.push(<Fragment key="rest">{slice(at, s.text.length)}</Fragment>)
const open = active === s.id
const labels = claimed.codes.map((c) => ANALYSIS_TEXT.claim.label[c] ?? c).join('、')
return (
<button key={s.id} type="button" className="ev-seg" data-seg={s.id} data-ev-state="claim"
data-ev-claim-issue={claimed.codes.join(' ')} tabIndex={current === s.id ? 0 : -1}
aria-label={`${s.text},${ANALYSIS_TEXT.claim.tag}:${labels}`} aria-expanded={open}
aria-controls={open ? panelId : undefined} title={ANALYSIS_TEXT.claim.hint}
style={{ '--ev-soft': 'var(--st-waiting-soft)' } as CSSProperties}>
{s.strong ? <strong className="font-semibold">{parts}</strong> : parts}
</button>
)
}
if (!state) return <span key={s.id}>{s.strong ? <strong className="font-semibold">{body}</strong> : body}</span> if (!state) return <span key={s.id}>{s.strong ? <strong className="font-semibold">{body}</strong> : body}</span>
const meta = EVIDENCE_STATE[state] const meta = EVIDENCE_STATE[state]
if (meta.alert && TAG_TEXT[state]) alerts.add(state) if (meta.alert && TAG_TEXT[state]) alerts.add(state)
@@ -1133,6 +1239,11 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask,
{EVIDENCE_STATE[state].glyph}{TAG_TEXT[state]} {EVIDENCE_STATE[state].glyph}{TAG_TEXT[state]}
</span> </span>
))} ))}
{tag && claimTag && (
<span className="ev-tag" aria-hidden="true" title={ANALYSIS_TEXT.claim.hint} data-ev-tag="claim">
?{ANALYSIS_TEXT.claim.tag}
</span>
)}
{badge && (meta || look) && ( {badge && (meta || look) && (
<button <button
type="button" type="button"
+94 -23
View File
@@ -9,7 +9,7 @@ import clsx from 'clsx'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { Spinner, StatusBadge, isComposing } from '../components/ui' import { Spinner, StatusBadge, isComposing } from '../components/ui'
import { import {
EVIDENCE_KIND_STYLE, EVIDENCE_STATE, caliberSourceText, caliberUpgradeText, citeLabel, claimBasis, closestNames, EVIDENCE_KIND_STYLE, EVIDENCE_STATE, caliberSourceText, caliberUpgradeText, citeLabel, claimBasis, closestNames, derivedFrom,
docForeign, entitySources, evidenceTrace, evidenceValue as valueText, graphDoc, inputSource, integrityFailures, isJudged, docForeign, entitySources, evidenceTrace, evidenceValue as valueText, graphDoc, inputSource, integrityFailures, isJudged,
isJudgeLimit, judgeFlags, judgeLimitWords, judgeable, judgedVerdicts, limitOf, pointsToSettings, isJudgeLimit, judgeFlags, judgeLimitWords, judgeable, judgedVerdicts, limitOf, pointsToSettings,
locatable, locatorText, notableInput, queryOf, queryWindow, quoteWhere, quoteWindow, reasonOf, rewriteOf, sealVerdict, locatable, locatorText, notableInput, queryOf, queryWindow, quoteWhere, quoteWindow, reasonOf, rewriteOf, sealVerdict,
@@ -26,7 +26,7 @@ import { useAtLeast, useLoginOn } from '../store/auth'
import { useCatalog } from '../store/catalog' import { useCatalog } from '../store/catalog'
import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence' import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence'
import type { import type {
ApprovalReport, EvidenceBlock, EvidenceChallengeVerdicts, EvidenceDocData, EvidenceDualCheck, EvidenceInput, EvidenceMerge, ApprovalReport, EvidenceAnalysisStep, EvidenceBlock, EvidenceChallengeVerdicts, EvidenceDocData, EvidenceDualCheck, EvidenceInput, EvidenceMerge,
EvidenceOnDemand, EvidenceQuoteSource, EvidenceSeal, EvidenceSegment, EvidenceSegmentDetail, EvidenceStep, EvidenceUnit, EvidenceOnDemand, EvidenceQuoteSource, EvidenceSeal, EvidenceSegment, EvidenceSegmentDetail, EvidenceStep, EvidenceUnit,
EvidenceUnitChallenge, EvidenceUntraced, EvidenceVerdict, EvidenceViolation, EvidenceUnitChallenge, EvidenceUntraced, EvidenceVerdict, EvidenceViolation,
} from '../types' } from '../types'
@@ -34,6 +34,8 @@ import { ArtifactViewer, ResultTable } from './AssistantStream'
import { ChallengeSummary, ClaimVerdictPending, type ClaimWait } from './ChallengeSummary' import { ChallengeSummary, ClaimVerdictPending, type ClaimWait } from './ChallengeSummary'
import { useEvidenceHost } from './evidenceHost' import { useEvidenceHost } from './evidenceHost'
import { EvidenceProvenance } from './EvidenceProvenance' import { EvidenceProvenance } from './EvidenceProvenance'
import { AnalysisChain, ClaimFlags, ComparabilityRow, isClaimViolation } from './EvidenceAnalysis'
import { ANALYSIS_TEXT } from '../lib/termsAnalysis'
import { LedgerPart } from './LedgerStep' import { LedgerPart } from './LedgerStep'
import { CopyChip } from './Markdown' import { CopyChip } from './Markdown'
import { SqlCheckList } from './SqlChecks' import { SqlCheckList } from './SqlChecks'
@@ -135,8 +137,10 @@ function unverifiedOf(input: EvidenceInput): string {
export type PanelMode = 'side' | 'drawer' | 'inline' | 'dock' export type PanelMode = 'side' | 'drawer' | 'inline' | 'dock'
export type PanelView = { kind: 'seg'; id: string } | { kind: 'unit'; id: string } | { kind: 'violations' } export type PanelView = { kind: 'seg'; id: string } | { kind: 'unit'; id: string } | { kind: 'violations' }
/** 报告末尾「分析范围」里的一个编号(A1):那份分析结论的出处链(GET …/evidence/analyses/{alias}) */
| { kind: 'scope'; alias: string }
export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, marks, view, onClose, onLocate, onViolations }: { export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, marks, view, onClose, onLocate, onViolations, onAnalysis }: {
id: string id: string
mode: PanelMode mode: PanelMode
doc: EvidenceDocData doc: EvidenceDocData
@@ -155,13 +159,16 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, marks,
/** 跳到正文里的某个片段(违规清单里的「定位」) */ /** 跳到正文里的某个片段(违规清单里的「定位」) */
onLocate: (segId: string) => void onLocate: (segId: string) => void
onViolations: () => void onViolations: () => void
/** 打开一份分析结论(说法违规里所挂的结论、分析范围里的编号):正文引用了它就落到那个判定语,没引用的打开它的出处链 */
onAnalysis?: (alias: string) => void
}) { }) {
const titleId = `${id}-title` const titleId = `${id}-title`
// 推断的来源只在面板这次打开期间缓存:回答里有当前状态(原件已清除、接受已作废)和取决于数据文件现状的结论, // 推断的来源只在面板这次打开期间缓存:回答里有当前状态(原件已清除、接受已作废)和取决于数据文件现状的结论,
// 关掉面板(卸载)或换了运行就作废,再打开重新取。同一次打开里来回点同一片段只取一次 // 关掉面板(卸载)或换了运行就作废,再打开重新取。同一次打开里来回点同一片段只取一次
const holdProvenance = useEvidence((s) => s.holdProvenance) const holdProvenance = useEvidence((s) => s.holdProvenance)
useEffect(() => (runId ? holdProvenance(runId) : undefined), [runId, holdProvenance]) useEffect(() => (runId ? holdProvenance(runId) : undefined), [runId, holdProvenance])
const found = useMemo(() => (view.kind === 'seg' ? findSeg(doc, view.id) : null), [doc, view]) const found = useMemo(() => (view.kind === 'seg' ? findSeg(doc, view.id) : view.kind === 'scope' ? scopeSeg(doc, view.alias) : null),
[doc, view])
const claimAt = useMemo(() => (view.kind === 'unit' ? findUnit(doc, view.id) : null), [doc, view]) const claimAt = useMemo(() => (view.kind === 'unit' ? findUnit(doc, view.id) : null), [doc, view])
const overlay = useVerdicts(runId, doc.node_id || undefined) const overlay = useVerdicts(runId, doc.node_id || undefined)
const claimVerdict = claimAt ? unitVerdict(claimAt.unit, overlay) : undefined const claimVerdict = claimAt ? unitVerdict(claimAt.unit, overlay) : undefined
@@ -232,7 +239,7 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, marks,
: found : found
? <SegmentBody panelId={id} doc={doc} artifact={artifact} runId={runId} runClass={runClass} seg={found.seg} ? <SegmentBody panelId={id} doc={doc} artifact={artifact} runId={runId} runClass={runClass} seg={found.seg}
unit={found.unit} block={found.block} mark={marks?.[found.unit.id]} onViolations={onViolations} unit={found.unit} block={found.block} mark={marks?.[found.unit.id]} onViolations={onViolations}
onLocate={onLocate} /> onLocate={onLocate} onAnalysis={onAnalysis} scope={view.kind === 'scope'} />
: claimAt : claimAt
? <ClaimBody doc={doc} artifact={artifact} runId={runId} runClass={runClass} unit={claimAt.unit} ? <ClaimBody doc={doc} artifact={artifact} runId={runId} runClass={runClass} unit={claimAt.unit}
block={claimAt.block} mark={marks?.[claimAt.unit.id]} /> block={claimAt.block} mark={marks?.[claimAt.unit.id]} />
@@ -252,6 +259,24 @@ function findSeg(doc: EvidenceDocData, id: string): { seg: EvidenceSegment; unit
return null return null
} }
/**
* 分析范围里一个编号的「片段」:不在正文里,按目录里那份结论的判定语拼一个(片段 id scope:<编号>,store 按它改取
* GET …/evidence/analyses/{alias})。目录里没有这份结论的为 null
*/
function scopeSeg(doc: EvidenceDocData, alias: string): { seg: EvidenceSegment; unit: EvidenceUnit; block: EvidenceBlock } | null {
const entry = doc.catalog?.[alias]
if (entry?.kind !== 'analysis') return null
const phrase = (doc.analysis_scope ? [
...(doc.analysis_scope.phenomena ?? []), ...(doc.analysis_scope.decomposed ?? []), ...(doc.analysis_scope.excluded ?? []),
...(doc.analysis_scope.refused ?? []),
] : []).find((i) => i.alias === alias)?.phrase
const seg: EvidenceSegment = {
id: `scope:${alias}`, kind: 'analysis', state: 'deterministic', text: phrase || alias,
cite: { alias, kind: 'analysis', status: 'resolved', locator: {}, eid: entry.eid },
}
return { seg, unit: { id: `scope:${alias}`, segments: [seg] }, block: { id: 'scope', type: 'paragraph', units: [] } }
}
function findUnit(doc: EvidenceDocData, id: string): { unit: EvidenceUnit; block: EvidenceBlock } | null { function findUnit(doc: EvidenceDocData, id: string): { unit: EvidenceUnit; block: EvidenceBlock } | null {
for (const block of doc.blocks ?? []) { for (const block of doc.blocks ?? []) {
const unit = (block.units ?? []).find((u) => u.id === id) const unit = (block.units ?? []).find((u) => u.id === id)
@@ -276,7 +301,7 @@ function flashOnce(el: HTMLElement) {
setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600) setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600)
} }
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, mark, onViolations, onLocate }: { function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, mark, onViolations, onLocate, onAnalysis, scope = false }: {
panelId: string panelId: string
doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; seg: EvidenceSegment; unit: EvidenceUnit doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; seg: EvidenceSegment; unit: EvidenceUnit
block: EvidenceBlock block: EvidenceBlock
@@ -285,6 +310,10 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
onViolations: () => void onViolations: () => void
/** 跳到正文里的某个片段(推演区「报告中会变的地方」的定位) */ /** 跳到正文里的某个片段(推演区「报告中会变的地方」的定位) */
onLocate: (segId: string) => void onLocate: (segId: string) => void
/** 打开一份分析结论(说法违规里所挂的结论) */
onAnalysis?: (alias: string) => void
/** 分析范围里的编号:不在正文里,不画所在的句子、口径、模型的解释和反方审稿 */
scope?: boolean
}) { }) {
const report = doc.node_id || undefined const report = doc.node_id || undefined
const key = runId ? segmentKey(runId, seg.id, report) : '' const key = runId ? segmentKey(runId, seg.id, report) : ''
@@ -321,13 +350,17 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
const entityStep = chain.find((s) => s.step === 'entity') const entityStep = chain.find((s) => s.step === 'entity')
const quoteStep = chain.find((s) => s.step === 'quote') const quoteStep = chain.find((s) => s.step === 'quote')
const queries = chain.filter((s) => s.step === 'query') const queries = chain.filter((s) => s.step === 'query')
const isMetric = cite?.kind === 'metric' || entry?.kind === 'metric' || !!metricStep // 分析结论:判定语、具名的数(cite 的种类是 analysis),结果表的一格(cite 是 cell、目录条目是 analysis)。链是分析的几步,
const isInput = cite?.kind === 'input' || entry?.kind === 'input' || !!inputStep // 后面接输入快照的查询步骤
const analysisChain = chain.filter((s) => s.step.startsWith('analysis')) as unknown as EvidenceAnalysisStep[]
const isAnalysis = cite?.kind === 'analysis' || entry?.kind === 'analysis' || analysisChain.some((s) => s.step === 'analysis')
const isMetric = !isAnalysis && (cite?.kind === 'metric' || entry?.kind === 'metric' || !!metricStep)
const isInput = !isAnalysis && (cite?.kind === 'input' || entry?.kind === 'input' || !!inputStep)
// 三期:有出处的表名字段名、引文(原话对不上的引文也画这一节:说清原文在哪、对不上) // 三期:有出处的表名字段名、引文(原话对不上的引文也画这一节:说清原文在哪、对不上)
const isEntity = !isMetric && !isInput && seg.kind === 'entity' && state === 'deterministic' const isEntity = !isAnalysis && !isMetric && !isInput && seg.kind === 'entity' && state === 'deterministic'
const isQuote = !isMetric && !isInput && seg.kind === 'quote' const isQuote = !isAnalysis && !isMetric && !isInput && seg.kind === 'quote'
// 报告直接引用的查询单元格([[v:Q3.r5.amount]]、整表里的一格):证据链就是那一次查询 // 报告直接引用的查询单元格([[v:Q3.r5.amount]]、整表里的一格):证据链就是那一次查询
const isCell = !isMetric && !isInput && !isEntity && !isQuote const isCell = !isAnalysis && !isMetric && !isInput && !isEntity && !isQuote
&& (cite?.kind === 'cell' || entry?.kind === 'query' || !!queries.length) && (cite?.kind === 'cell' || entry?.kind === 'query' || !!queries.length)
const bad = state === 'none' || state === 'suspect' || state === 'unverified' const bad = state === 'none' || state === 'suspect' || state === 'unverified'
const closest = closestNames(detail?.closest ?? entityStep?.closest) const closest = closestNames(detail?.closest ?? entityStep?.closest)
@@ -388,6 +421,13 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
return i >= 0 ? { index: i, alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined return i >= 0 ? { index: i, alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined
} }
const violations = (doc.violations ?? []).filter((v) => v.segment === seg.id) const violations = (doc.violations ?? []).filter((v) => v.segment === seg.id)
// 说法违规(判断词没挂对分析结论、因果没有依据):落在这一段文字上的,单列一节,不和裸数字那些原因混在一起
const claimFlags = violations.filter(isClaimViolation)
// 分析结论的输入里点 Q:跳到下面那个查询步骤
const jumpToQuery = (alias: string) => {
const i = queries.findIndex((q) => q.alias === alias)
if (i >= 0) jumpTo(i)
}
// 数字推演:按(运行、报告、口径卡、指标)认一份草稿。同一个指标的几处引用之间来回切,推演不收起(定位到另一处 // 数字推演:按(运行、报告、口径卡、指标)认一份草稿。同一个指标的几处引用之间来回切,推演不收起(定位到另一处
// 引用也是这样);换了指标、换成别的片段才收起清空。卡和指标先按文档目录认:切片段时证据链还没回来,也认得出是同一个 // 引用也是这样);换了指标、换成别的片段才收起清空。卡和指标先按文档目录认:切片段时证据链还没回来,也认得出是同一个
const whatifCard = entry?.kind === 'metric' ? entry.node_id : metricStep?.node_id const whatifCard = entry?.kind === 'metric' ? entry.node_id : metricStep?.node_id
@@ -443,9 +483,13 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
]} /> ]} />
)} )}
<Part title={EVIDENCE_TEXT.sentence} data-ev-sentence=""> {!scope && (
<Sentence unit={unit} active={seg.id} /> <Part title={EVIDENCE_TEXT.sentence} data-ev-sentence="">
</Part> <Sentence unit={unit} active={seg.id} />
</Part>
)}
{claimFlags.length > 0 && <ClaimFlags violations={claimFlags} onAnalysis={onAnalysis} />}
{state === 'deterministic' && (isMetric || isCell) && !docIssue && ( {state === 'deterministic' && (isMetric || isCell) && !docIssue && (
// 口径账本:出处链最上面的「口径」一步(来自口径 / 相关口径 / 这次的推理)。比对在封存之后做,这一步按运行另取 // 口径账本:出处链最上面的「口径」一步(来自口径 / 相关口径 / 这次的推理)。比对在封存之后做,这一步按运行另取
@@ -484,6 +528,11 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
</Part> </Part>
)} )}
{isAnalysis && (
// 分析结论:判定 → 方法 → 计算 → 输入;输入快照的查询步骤照常在下面画
<AnalysisChain chain={analysisChain} seal={seal} pending={pending} failed={chainFailed} onQuery={jumpToQuery} />
)}
{isEntity && ( {isEntity && (
<EntityPart step={entityStep} entry={entry} cite={cite} seal={seal} pending={pending} <EntityPart step={entityStep} entry={entry} cite={cite} seal={seal} pending={pending}
failed={chainFailed} catalog={doc.catalog} /> failed={chainFailed} catalog={doc.catalog} />
@@ -553,16 +602,20 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
{provenanceOn && runId && <EvidenceProvenance runId={runId} segId={seg.id} report={report} />} {provenanceOn && runId && <EvidenceProvenance runId={runId} segId={seg.id} report={report} />}
{state === 'deterministic' && !isMetric && !isInput && !isCell && !isEntity && !isQuote && ( {state === 'deterministic' && !isAnalysis && !isMetric && !isInput && !isCell && !isEntity && !isQuote && (
<p className="text-dim">{sourceOf(seg, doc) || detail?.note}</p> <p className="text-dim">{sourceOf(seg, doc) || detail?.note}</p>
)} )}
<JudgePart doc={doc} unit={unit} block={block} runId={runId} runClass={runClass} {isAnalysis && detail?.note && <p className="text-2xs leading-relaxed text-faint" data-ev-analysis-note="">{detail.note}</p>}
onDemand={detail?.unit?.on_demand} answered={detail?.unit?.verdict} pending={pending} />
{!scope && (
<JudgePart doc={doc} unit={unit} block={block} runId={runId} runClass={runClass}
onDemand={detail?.unit?.on_demand} answered={detail?.unit?.verdict} pending={pending} />
)}
{/* 反方审稿对这一句的判断:点句中的数字也看得到。片段接口还没答、答不了时先按正文标记写判断。正文不是封存的那一份 {/* 反方审稿对这一句的判断:点句中的数字也看得到。片段接口还没答、答不了时先按正文标记写判断。正文不是封存的那一份
时都不用:服务端认的是封存的那份文档 */} 时都不用:服务端认的是封存的那份文档 */}
{!docIssue && (segChallenge || mark) && ( {!scope && !docIssue && (segChallenge || mark) && (
<ChallengePart challenge={segChallenge} mark={mark} wait={segWait} unit={unit} report={report} <ChallengePart challenge={segChallenge} mark={mark} wait={segWait} unit={unit} report={report}
onRetry={() => { if (runId) void loadSegment(runId, seg.id, report) }} /> onRetry={() => { if (runId) void loadSegment(runId, seg.id, report) }} />
)} )}
@@ -652,12 +705,14 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block, mark }: {
// 有指标依据时才去取证据图(正文挂上时一般已经取过,取过的不再发) // 有指标依据时才去取证据图(正文挂上时一般已经取过,取过的不再发)
const graphSlot = useEvidence((s) => (runId ? s.graphs[runId] : undefined)) const graphSlot = useEvidence((s) => (runId ? s.graphs[runId] : undefined))
const loadGraph = useEvidence((s) => s.loadGraph) const loadGraph = useEvidence((s) => s.loadGraph)
const metricCite = (unit.cites ?? []).some((alias) => doc.catalog?.[alias]?.kind === 'metric') // 分析结论的依据另写它派生自哪几份查询、哪一层拆解(证据图的 derived_from 边),同样要证据图
const graphCite = (unit.cites ?? []).some((alias) => ['metric', 'analysis'].includes(String(doc.catalog?.[alias]?.kind)))
useEffect(() => { useEffect(() => {
if (runId && metricCite) void loadGraph(runId) if (runId && graphCite) void loadGraph(runId)
}, [runId, metricCite, loadGraph]) }, [runId, graphCite, loadGraph])
const graph = !foreign && graphSlot?.status === 'ok' ? graphSlot.data : undefined const graph = !foreign && graphSlot?.status === 'ok' ? graphSlot.data : undefined
const cites = (unit.cites ?? []).map((alias) => ({ alias, label: citeLabel(alias, doc, graph, report, !!runId) })) const cites = (unit.cites ?? []).map((alias) => ({ alias, label: citeLabel(alias, doc, graph, report, !!runId),
derived: doc.catalog?.[alias]?.kind === 'analysis' ? derivedFrom(graph, alias, report) : [] }))
// 讲方法、讲结构的句子:SQL 和字段清单直接列在挂的依据下面。正文不是封存的那份时不列(链是另一份报告的) // 讲方法、讲结构的句子:SQL 和字段清单直接列在挂的依据下面。正文不是封存的那份时不列(链是另一份报告的)
const basis = useMemo(() => (foreign ? null : claimBasis(unit, doc)), [foreign, unit, doc]) const basis = useMemo(() => (foreign ? null : claimBasis(unit, doc)), [foreign, unit, doc])
// 反方审稿对这一句的判断:服务端只认审的就是封存的这一份文档;正文不是那一份时不用。片段接口还没答、答不了时先按正文 // 反方审稿对这一句的判断:服务端只认审的就是封存的这一份文档;正文不是那一份时不用。片段接口还没答、答不了时先按正文
@@ -685,9 +740,14 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block, mark }: {
{cites.length ? ( {cites.length ? (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{cites.map((c) => ( {cites.map((c) => (
<li key={c.alias} className="flex min-w-0 items-baseline gap-1.5"> <li key={c.alias} className="flex min-w-0 items-baseline gap-1.5" data-ev-cite={c.alias}>
<span className="mono shrink-0 font-medium">{c.alias}</span> <span className="mono shrink-0 font-medium">{c.alias}</span>
{c.label && <span className="min-w-0 truncate text-dim" title={c.label}>{c.label}</span>} {c.label && <span className="min-w-0 truncate text-dim" title={c.label}>{c.label}</span>}
{c.derived.length > 0 && (
<span className="shrink-0 text-2xs text-faint" data-ev-derived={c.derived.join(',')}>
{ANALYSIS_TEXT.evidence.derivedFrom(c.derived)}
</span>
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -1478,6 +1538,10 @@ function MetricPart({ step, entry, inputs, runId, runClass, seal, pending, faile
: step.recompute_ok ? EVIDENCE_TEXT.recomputeOk : EVIDENCE_TEXT.recomputeBad} : step.recompute_ok ? EVIDENCE_TEXT.recomputeOk : EVIDENCE_TEXT.recomputeBad}
</dd> </dd>
</div> </div>
{step.comparability && typeof step.comparability === 'object' && (
// 跨两份同形查询算的环比、同比:口径卡执行时自动做的可比性检查,展开看那份可比性结论的整条链
<ComparabilityRow comparability={step.comparability} seal={seal} />
)}
{untraced && ( {untraced && (
<div> <div>
<dt className="text-2xs text-faint">{EVIDENCE_TEXT.origin}</dt> <dt className="text-2xs text-faint">{EVIDENCE_TEXT.origin}</dt>
@@ -1849,6 +1913,11 @@ function QueryPart({ id, step, seal, masked, pick }: {
· {MERGE_TEXT.mergedInto(step.merged_into)} · {MERGE_TEXT.mergedInto(step.merged_into)}
</span> </span>
)} )}
{step.input_of && (
<span className="ml-1.5 font-normal" data-ev-input-of={step.input_of}>
· {ANALYSIS_TEXT.evidence.inputOfAnalysis(step.input_of)}
</span>
)}
</span> </span>
{host.onNode && <NodeChip id={step.node_id} />} {host.onNode && <NodeChip id={step.node_id} />}
{step.sql && ( {step.sql && (
@@ -2057,6 +2126,8 @@ const CODE_LABEL: Record<string, string> = {
unverified_entity: '无法核实的名称', unverified_entity: '无法核实的名称',
absence_claim: ABSENCE_CLAIM_TEXT.label, absence_claim: ABSENCE_CLAIM_TEXT.label,
masked_cell: MASKED_CELL_TEXT.label, masked_cell: MASKED_CELL_TEXT.label,
// 分析说法核对(engine/analysis_claims.CODES):叫法同审计
...ANALYSIS_TEXT.claim.label,
} }
/** 违规清单:报告撰写节点核对出来的全部问题,画得出线的能定位,画不出的说清在哪 */ /** 违规清单:报告撰写节点核对出来的全部问题,画得出线的能定位,画不出的说清在哪 */
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -94,6 +94,9 @@ interface EvidenceState {
export const docKey = (runId: string, report?: string) => `${runId}@${report ?? ''}` export const docKey = (runId: string, report?: string) => `${runId}@${report ?? ''}`
export const askKey = (runId: string, report: string | undefined, unit: string) => `${docKey(runId, report)}:${unit}` export const askKey = (runId: string, report: string | undefined, unit: string) => `${docKey(runId, report)}:${unit}`
/** 分析范围里一个编号的片段 id 前缀(证据面板拼的「scope:A1」,不在正文里;同服务端导出页的 SCOPE_REF) */
export const SCOPE_SEG = 'scope:'
/** /**
* 片段的缓存键:`runId:segId`。一次运行里有好几份报告时片段 id 会撞(每份都从 s0 数起), * 片段的缓存键:`runId:segId`。一次运行里有好几份报告时片段 id 会撞(每份都从 s0 数起),
* 带上报告节点:`runId:segId@report` * 带上报告节点:`runId:segId@report`
@@ -159,8 +162,15 @@ export const useEvidence = create<EvidenceState>((set, get) => {
}), }),
loadGraph: (runId) => fill('graphs', runId, () => api.evidence.graph(runId)), loadGraph: (runId) => fill('graphs', runId, () => api.evidence.graph(runId)),
reloadGraph: (runId) => fill('graphs', runId, () => api.evidence.graph(runId), true), reloadGraph: (runId) => fill('graphs', runId, () => api.evidence.graph(runId), true),
// 分析范围里的编号(片段 id「scope:A1」,证据面板拼的,不在正文里):取那份分析结论的出处链,按片段答复的样子放进同一张表
loadSegment: (runId, segmentId, report) => loadSegment: (runId, segmentId, report) =>
fill('segments', segmentKey(runId, segmentId, report), () => api.evidence.segment(runId, segmentId, { report })), fill('segments', segmentKey(runId, segmentId, report), () => (segmentId.startsWith(SCOPE_SEG)
? api.evidence.analysis(runId, segmentId.slice(SCOPE_SEG.length), { report }).then((r) => ({
report: r.report, chain: r.chain ?? [], seal: r.seal, redacted: r.redacted,
// 不带 text:范围里的这一项写的是文档自己记着的判定语,正文这份是不是封存的那一份按工件 id 认
segment: { id: segmentId, kind: 'analysis' },
}))
: api.evidence.segment(runId, segmentId, { report }))),
// 最近一次运行 → 它封存范围内的报告核对(report.checked)。审计接口的 reports 带着结论句策略(claims, // 最近一次运行 → 它封存范围内的报告核对(report.checked)。审计接口的 reports 带着结论句策略(claims,
// 章上「无证据」要不要算没挂依据的结论句靠它);只要 reports,所以只取最小的「可疑实体」那一组行。 // 章上「无证据」要不要算没挂依据的结论句靠它);只要 reports,所以只取最小的「可疑实体」那一组行。
// 老后端没有审计接口时退回证据图(没有 claims,没挂依据的结论句不计入)。没有运行、没有报告都是空表,不画章 // 老后端没有审计接口时退回证据图(没有 claims,没挂依据的结论句不计入)。没有运行、没有报告都是空表,不画章
+2 -1
View File
@@ -2135,7 +2135,8 @@ export interface EvidenceGraph {
challenge_verdicts?: EvidenceChallengeVerdicts }[] challenge_verdicts?: EvidenceChallengeVerdicts }[]
evidence?: { alias?: string; eid?: string; kind?: string; label?: string; node_id?: string; artifact?: string evidence?: { alias?: string; eid?: string; kind?: string; label?: string; node_id?: string; artifact?: string
sealed?: boolean; cited_by?: string[]; report?: string }[] sealed?: boolean; cited_by?: string[]; report?: string }[]
edges?: { from: string; to: string; rel: string }[] /** cites(片段 → 证据)、supports(句子 → 依据)、derived_from(分析结论 → 它的输入 Q、下钻的上一层 A);report 是哪份报告的边 */
edges?: { from: string; to: string; rel: string; report?: string }[]
} }
/** /**
+231
View File
@@ -41,6 +41,10 @@ const JUDGE_SETS = [fxj.formal, fxj.explore, fxj.plain, fxv.formal, fxv.explore]
// Excel 导入期 4:推断的来源。文档由 compose_doc 真跑(带文档标记),片段答复由 _chain 真跑,推断来源的答复按契约 // Excel 导入期 4:推断的来源。文档由 compose_doc 真跑(带文档标记),片段答复由 _chain 真跑,推断来源的答复按契约
// (backend/app/data/provenance_types.py)拼;cases 写明每个场景是哪个片段、片段文字、覆盖 P4-SPEC 4.4 的哪几条 // (backend/app/data/provenance_types.py)拼;cases 写明每个场景是哪个片段、片段文字、覆盖 P4-SPEC 4.4 的哪几条
const fxp = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-provenance.json`, 'utf8')) const fxp = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-provenance.json`, 'utf8'))
// 分析能力一轮:分析结论(判定语、具名的数、结果表的格)、说法违规、系统写的分析范围、口径卡指标上的两期可比性。文档、片段接口、
// 证据图、审计、分析范围每个编号的出处链(GET …/evidence/analyses/{alias})都是真后端答的(/tmp/agentlab-fix/w2c 的录制脚本)
const fxa = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-analysis.json`, 'utf8'))
const FXA = [fxa.agent, fxa.card, fxa.metric]
/** 推断来源夹具里各期导入清单的工件 id:夹具只收了两份清单,其余的按「工件不存在」答,不落到真服务端 */ /** 推断来源夹具里各期导入清单的工件 id:夹具只收了两份清单,其余的按「工件不存在」答,不落到真服务端 */
const FXP_MANIFESTS = new Set(Object.values(fxp.provenance).flatMap((a) => a.version?.parts ?? []).map((p) => p.manifest)) const FXP_MANIFESTS = new Set(Object.values(fxp.provenance).flatMap((a) => a.version?.parts ?? []).map((p) => p.manifest))
/** 夹具里某段文字是哪个片段:检查按文字认片段,重新生成夹具时编号变了也不用改 */ /** 夹具里某段文字是哪个片段:检查按文字认片段,重新生成夹具时编号变了也不用改 */
@@ -553,6 +557,233 @@ await section('artifacts', '记录页的工件:口径卡指标集、报告文
check('两种都算证据(列表里醒目)', got.evidence) check('两种都算证据(列表里醒目)', got.evidence)
}) })
/** 分析夹具的三次运行:片段、证据图、分析范围编号的出处链按运行 id 答(排在 routed 前面,别的交回 routed) */
async function analysisRoutes(page, hits, graphOf = (set) => set.graph) {
await page.route((u) => FXA.some((x) => new URL(u).pathname.includes(`/runs/${x.run_id}`)), (r) => {
const url = new URL(r.request().url())
const set = FXA.find((x) => url.pathname.includes(`/runs/${x.run_id}`))
const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (seg) {
const id = decodeURIComponent(seg[1])
hits.push(`seg:${id}`)
const body = set.segments[id]
return body ? r.fulfill({ json: body }) : r.fulfill({ status: 404, json: { detail: '报告中没有这个片段', code: 'evidence_segment_not_found' } })
}
const an = url.pathname.match(/\/evidence\/analyses\/([^/]+)$/)
if (an) {
const alias = decodeURIComponent(an[1])
hits.push(`analysis:${alias}`)
const body = set.analyses[alias]
return body ? r.fulfill({ json: body })
: r.fulfill({ status: 404, json: { detail: '这份报告的证据目录里没有这份分析结论', code: 'evidence_analysis_not_found' } })
}
if (/\/evidence$/.test(url.pathname)) return r.fulfill({ json: graphOf(set) })
if (url.pathname.endsWith(`/runs/${set.run_id}`)) return r.fulfill({ json: { id: set.run_id, run_class: 'exploratory', status: 'succeeded' } })
return r.fulfill({ status: 404, json: { detail: '检查脚本没有这一项' } })
})
}
await section('analysis', '分析结论:判定语、具名的数、结果表的格的出处链(判定 → 方法 → 计算 → 输入 → 复算 → 封存)', async () => {
const hits = []
const { ctx, page, errors } = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, hits) })
const box = page.locator('#evidence-analysis')
const A = fxa.agent
const phrase = box.locator('[data-seg="s3"]')
check('判定语是一个可点的片段,画「有出处」', await phrase.count() === 1 && (await phrase.getAttribute('data-ev-state')) === 'deterministic')
check('……读屏写出处:分析结论 A1 · 判定语', (await phrase.getAttribute('aria-label') ?? '').includes('有出处:分析结论 A1 · 判定语'),
await phrase.getAttribute('aria-label'))
await phrase.click()
const p = panel(page)
await p.locator('[data-ev-analysis="A1"]').waitFor({ timeout: 8000 })
const judged = p.locator('[data-ev-analysis="A1"]')
const jt = (await judged.innerText()).replace(/\s+/g, ' ')
check('判定一步:标题写编号和方法的叫法(服务端的 method_label)', jt.includes('分析结论 A1 · 变化拆解(可加)'), jt.slice(0, 60))
check('……判定语照原样、判定标准、点名的段、方向', (await judged.locator('[data-ev-analysis-phrase]').innerText()) === A.segments.s3.chain[0].phrase
&& jt.includes('一段占总变化的一半以上算主要来源') && jt.includes('华东') && jt.includes('下降'), jt.slice(0, 200))
check('……谁做的:Agent 现场做的分析、哪个节点', jt.includes('Agent 现场做的分析') && jt.includes('问数据'))
check('……附带说明、Agent 写的假设(另写一句它不是结论)', jt.includes('没能核对本期的数据是否到齐')
&& (await judged.locator('[data-ev-analysis-hypothesis]').innerText()).includes('下降是不是集中在某个地区')
&& jt.includes('不是分析结论'), jt.slice(-200))
check('……复算一致(服务端复算的那一句)、封存范围内的台账记过', await judged.locator('[data-ev-analysis-recompute="ok"]').count() === 1
&& jt.includes('复算一致:按封存的输入重新计算,结论逐项相同') && await judged.locator('[data-ev-analysis-seal="ledger"]').count() === 1)
check('……这一处引用的是判定语', (await judged.locator('[data-ev-analysis-cited]').getAttribute('data-ev-analysis-cited')) === 'phrase')
const method = (await p.locator('[data-ev-analysis-method]').innerText()).replace(/\s+/g, ' ')
check('方法一步:方法与版本、实际用的阈值(契约的叫法)、规范化后的参数', method.includes('变化拆解(可加) · decompose.additive@1')
&& method.includes('「主要来源」的占比 0.5') && (await p.locator('[data-ev-analysis-params]').innerText()).includes('"by"'), method.slice(0, 160))
const calc = p.locator('[data-ev-analysis-calc]')
check('计算一步:关键数的代入式照服务端', (await calc.locator('[data-ev-analysis-formulas]').innerText()).includes('华东的占比 = 华东的变化 ÷ 总变化 = -300 ÷ (-340) = 88.2%'))
check('……具名的数、结果表(表头是给人看的列名)', await calc.locator('[data-ev-analysis-value="top_share"]').count() === 1
&& (await calc.locator('[data-ev-analysis-table] th').allInnerTexts()).join('、').includes('占变化的比重'))
const inputs = p.locator('[data-ev-analysis-input]')
check('输入一步:每份一节(基期总数、本期总数、数据)', JSON.stringify(await inputs.evaluateAll((els) => els.map((e) => e.dataset.evAnalysisInput)))
=== JSON.stringify(['headline_base', 'headline_target', 'data']))
check('……总数那一格和各段之和的闭合核对(服务端算好的数)', (await inputs.nth(0).innerText()).includes('总数 2,700 与各段之和 2,700 闭合')
&& await inputs.nth(0).locator('[data-ev-analysis-closure="closed"]').count() === 1)
check('……数据:用到的列、全部行', (await inputs.nth(2).innerText()).includes('region、week、gmv') && (await inputs.nth(2).innerText()).includes('全部行'))
check('输入快照的查询步骤接在后面,写明是 A1 的输入', await p.locator('[data-ev-input-of="A1"]').count() === 2)
await inputs.nth(2).locator('[data-ev-goto-query="Q2"]').click()
await page.waitForTimeout(150)
check('……点输入里的 Q2:焦点跳到那个查询步骤', await page.evaluate(() => document.activeElement?.closest('[data-ev-query-alias]')?.getAttribute('data-ev-query-alias')) === 'Q2')
check('分析结论的数不走口径账本:没有「口径」一步', await p.locator('[data-ev-ledger]').count() === 0)
check('面板底下写服务端的说明(判定只说明变化来自哪里)', (await p.locator('[data-ev-analysis-note]').innerText()).includes('判定只说明变化来自哪里'))
// 具名的数、结果表的格:同一条链,「这一处引用的是」写清是哪一项,被引用的那个描出来
await box.locator('[data-seg="s9"]').click()
await p.locator('[data-ev-analysis-cited="value"]').waitFor({ timeout: 8000 })
check('具名的数(88.2%):这一处引用的是具名的数「top_share」,那个数在计算里描出来', (await p.locator('[data-ev-analysis-cited]').innerText()) === '具名的数「top_share」'
&& await p.locator('[data-ev-analysis-value="top_share"][data-ev-cited]').count() === 1)
await box.locator('[data-seg="s7"]').click()
await p.locator('[data-ev-analysis-cited="cell"]').waitFor({ timeout: 8000 })
check('结果表的格(-300):引用的是结果表第 1 行「delta」,那一格高亮', (await p.locator('[data-ev-analysis-cited]').innerText()) === '结果表第 1 行「delta」'
&& await p.locator('[data-ev-analysis-table] td[data-highlight="cell"]').count() === 1)
check('……读屏写出处:分析结论 A1 · 结果表第 1 行「delta」', (await box.locator('[data-seg="s7"]').getAttribute('aria-label') ?? '').includes('分析结论 A1 · 结果表第 1 行「delta」'))
check('片段接口按片段 id 取(s3、s9、s7)', ['seg:s3', 'seg:s9', 'seg:s7'].every((h) => hits.includes(h)), hits.join(','))
// 分析卡的结论:谁做的写分析卡(模板定好的分析);表格里引用结果表的格
const card = page.locator('#evidence-analysis-card')
await card.locator('[data-seg="s3"]').click()
await p.locator('[data-ev-analysis="A2"]').waitFor({ timeout: 8000 })
check('分析卡的结论:谁做的写「分析卡(模板定好的分析)」', (await p.locator('[data-ev-analysis-origin]').getAttribute('data-ev-analysis-origin')) === 'card'
&& (await p.locator('[data-ev-analysis="A2"]').innerText()).includes('分析卡(模板定好的分析)'))
await card.locator('[data-seg="s6"]').click()
await p.locator('[data-ev-analysis="A1"]').waitFor({ timeout: 8000 })
check('算不了的结论:标「这次分析没有做成」、写算不了的原因', await p.locator('[data-ev-analysis-refused]').count() === 1
&& (await p.locator('[data-ev-analysis="A1"]').innerText()).includes('只有 0 期历史,至少要 8 期'))
// 窄栏:分析的几步栏内展开,不出横向滚动
const narrow = page.locator('#evidence-analysis-narrow')
await narrow.locator('[data-seg="s3"]').click()
await page.waitForTimeout(400)
const over = await narrow.evaluate((el) => el.scrollWidth - el.clientWidth)
check('360 宽:分析结论的出处链栏内展开、不出横向滚动', over <= 1 && await narrow.locator('[data-ev-analysis-calc]').count() === 1, `${over}px`)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(150)
await box.locator('[data-seg="s3"]').click()
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/evidence-analysis-${theme}.png` })
}
}
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await ctx.close()
})
await section('analysis-claims', '说法违规标在正文上、点开看原话和所挂的结论;n 跳得到;系统写的分析范围编号点得开;整句的依据写派生自谁', async () => {
const hits = []
const { ctx, page, errors } = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, hits) })
const box = page.locator('#evidence-analysis')
const flagged = box.locator('[data-ev-state="claim"]')
check('三句说法违规各是一个可点的片段', JSON.stringify(await flagged.evaluateAll((els) => els.map((e) => e.dataset.seg))) === '["s12","s14","s16"]')
const words = await box.locator('[data-ev-claim-word]').allInnerTexts()
check('……只在判断词下面画线(主要来自、异常、由于)', JSON.stringify(words) === '["主要来自","异常","由于"]', JSON.stringify(words))
const deco = await box.locator('[data-ev-claim-word]').first().evaluate((el) => getComputedStyle(el).textDecorationStyle)
check('……线型是波浪线(和无证据的点状线分得开)', deco === 'wavy', deco)
check('……整段文字不画线', await flagged.first().evaluate((el) => getComputedStyle(el).textDecorationLine) === 'none')
check('……句末挂「说法待核」', await box.locator('[data-ev-tag="claim"]').count() === 3)
check('……读屏写种类', (await flagged.first().getAttribute('aria-label') ?? '').includes('说法待核:判断与分析结论不符'))
await flagged.first().click()
const p = panel(page)
await p.locator('[data-ev-claim-flags]').waitFor({ timeout: 8000 })
const flag = p.locator('[data-ev-claim-flag="analysis_claim_mismatch"]')
const ft = (await flag.innerText()).replace(/\s+/g, ' ')
check('点开:种类的叫法(同审计)、服务端的原话', ft.includes('判断与分析结论不符') && ft.includes('「主要来自华南」与所挂的分析结论 A1 对不上'), ft)
await flag.locator('[data-ev-claim-analysis="A1"]').click()
await page.waitForTimeout(400)
check('……所挂的结论 A1 点一下:落到正文里 A1 的判定语、打开它的出处链', await active(page) === 's3'
&& await p.locator('[data-ev-analysis="A1"]').count() === 1, await active(page))
await box.locator('[data-seg="s14"]').click()
await p.locator('[data-ev-claim-flag="analysis_claim_uncited"]').waitFor({ timeout: 8000 })
check('判断词没挂分析结论:写这句没有挂分析结论', (await p.locator('[data-ev-claim-flag="analysis_claim_uncited"]').innerText()).includes('这句没有挂分析结论'))
await box.locator('[data-seg="s16"]').click()
await p.locator('[data-ev-claim-flag="causal_unsupported"]').waitFor({ timeout: 8000 })
check('因果说法没有依据:原话照服务端', (await p.locator('[data-ev-claim-flag="causal_unsupported"]').innerText()).includes('是原因判断'))
// n:从头跳,第一处就是说法违规
await box.locator('[data-seg="s3"]').focus()
await page.keyboard.press('n')
await page.waitForTimeout(150)
check('n 跳到下一处有问题的地方:说法违规那一段', ['s12', 's14', 's16'].includes(await active(page)), await active(page))
await page.keyboard.press('Escape')
// 违规清单:三条说法违规的叫法、能定位
await page.evaluate(() => document.querySelector('#evidence-analysis [data-evidence-list]')?.click())
await p.locator('[data-ev-violations]').waitFor({ timeout: 8000 })
const vt = (await p.locator('[data-ev-violations]').innerText()).replace(/\s+/g, ' ')
check('违规清单:三条都写种类的叫法、都能定位', ['判断与分析结论不符', '判断词未挂分析结论', '因果说法没有依据'].every((t) => vt.includes(t))
&& await p.locator('[data-ev-violation][data-ev-locatable="yes"]').count() === 3, vt.slice(0, 160))
await page.keyboard.press('Escape')
// 分析范围:系统写的一段,编号点得开(正文没引用的 A2、A3 也点得开)
const scope = box.locator('[data-ev-scope]')
const st = (await scope.innerText()).replace(/\s+/g, ' ')
check('正文后面是系统写的「分析范围」:现象、拆过(按地区)、算不了、没拆(渠道)', st.includes('分析范围') && st.includes('系统按这次的分析结论写的')
&& JSON.stringify(await scope.locator('[data-ev-scope-group]').evaluateAll((els) => els.map((e) => e.dataset.evScopeGroup)))
=== '["phenomena","decomposed","refused","not_decomposed"]' && st.includes('按地区') && st.includes('候选维度里没拆过的:渠道'), st.slice(0, 200))
await scope.locator('[data-seg="scope:A2"]').click()
await p.locator('[data-ev-analysis="A2"]').waitFor({ timeout: 8000 })
check('点范围里的 A2(正文没引用):取它的出处链(…/evidence/analyses/A2),面板写判定和算不了的原因', hits.includes('analysis:A2')
&& await p.locator('[data-ev-analysis-refused]').count() === 1 && (await p.locator('[data-ev-analysis="A2"]').innerText()).includes('至少要 8 期'),
hits.join(','))
check('……不画所在的句子(它不在正文里)', await p.locator('[data-ev-sentence]').count() === 0)
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('……Esc 关掉:焦点回到范围里的那个编号', await page.evaluate(() => document.activeElement?.getAttribute('data-seg')) === 'scope:A2')
await scope.locator('[data-seg="scope:A1"]').click()
await p.locator('[data-ev-analysis="A1"]').waitFor({ timeout: 8000 })
check('点范围里的 A1:同一条链(方法、计算、输入都在)', await p.locator('[data-ev-analysis-method]').count() === 1
&& await p.locator('[data-ev-analysis-input]').count() === 3)
await page.keyboard.press('Escape')
// 审计行:说法违规的那几行带问题代码(记录页的审计表按它写种类的叫法,不写「无证据」)
const audit = await page.evaluate(async (a) => (await import('/src/lib/evidence.ts')).auditFromApi(a)
.filter((r) => r.kind === 'violation').map((r) => r.issue), fxa.agent.audit)
check('审计:说法违规的行带问题代码', JSON.stringify(audit) === '["analysis_claim_mismatch","analysis_claim_uncited","causal_unsupported"]', JSON.stringify(audit))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await ctx.close()
// 整句面板:挂的依据是分析结论时写它派生自哪几份查询(证据图的 derived_from 边)。这份报告没开裁判,句末没有徽标;
// 证据图上加一条封存后的按需判定(探索运行点「判这一句」后就是这样),u1 句末才有徽标、点得开整句
const judged = (set) => set !== fxa.agent ? set.graph : { ...set.graph, reports: set.graph.reports.map((r) => ({ ...r,
post_seal_verdicts: { u1: { status: 'supported', rationale: '判定语照 A1 的判定写', judge: 'judge-model-b', post_seal: true, used: ['A1'] } } })) }
const second = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, [], judged) })
const badge = second.page.locator('#evidence-analysis [data-ev-claim="u1"]')
await badge.waitFor({ timeout: 8000 })
await badge.click()
const cites = panel(second.page).locator('[data-ev-cites]')
await cites.waitFor({ timeout: 8000 })
await second.page.waitForTimeout(300)
const derived = cites.locator('[data-ev-cite="A1"] [data-ev-derived]')
check('整句的依据是 A1:写「派生自 Q1、Q2」(证据图的 derived_from 边,按这份报告认)', await derived.count() === 1
&& (await derived.getAttribute('data-ev-derived')) === 'Q1,Q2' && (await derived.innerText()).includes('Q1、Q2'),
(await cites.innerText()).replace(/\s+/g, ' '))
check('……挂的依据里 A1 写分析结论的叫法', (await cites.locator('[data-ev-cite="A1"]').innerText()).includes('变化拆解'),
(await cites.locator('[data-ev-cite="A1"]').innerText()).replace(/\s+/g, ' '))
check('没有运行时报错(整句面板)', second.errors.length === 0, second.errors.join(' | '))
await second.ctx.close()
})
await section('analysis-metric', '口径卡指标上的两期可比性:判定、判定语,展开看那份可比性结论的整条链(随卡封存)', async () => {
const hits = []
const { ctx, page, errors } = await open('/ui-harness.html?evidence=1', { before: (p) => analysisRoutes(p, hits) })
const box = page.locator('#evidence-analysis-metric')
await box.locator('[data-seg="s1"]').click()
const p = panel(page)
await p.locator('[data-ev-comparability]').waitFor({ timeout: 8000 })
const row = p.locator('[data-ev-comparability]')
check('指标一步里多一行「两期可比性」:判定写叫法、判定语照服务端', (await row.getAttribute('data-ev-comparability')) === 'comparable'
&& (await row.innerText()).includes('两期可比') && (await row.locator('[data-ev-comparability-phrase]').innerText()) === '两期可比,窗口长度、整期、星期构成核对不了')
await row.locator('[data-ev-comparability-toggle]').click()
await page.waitForTimeout(200)
const chain = row.locator('[data-ev-comparability-chain]')
check('展开:那份可比性结论的判定、方法、计算、输入', await chain.locator('[data-ev-analysis-origin="metric"]').count() === 1
&& await chain.locator('[data-ev-analysis-method]').count() === 1 && await chain.locator('[data-ev-analysis-input]').count() === 2)
check('……谁做的写「口径卡执行时自动做的可比性检查」,封存写随卡封存(不进台账)', (await chain.innerText()).includes('口径卡执行时自动做的可比性检查')
&& await chain.locator('[data-ev-analysis-seal="card"]').count() === 1)
check('……复算一致', await chain.locator('[data-ev-analysis-recompute="ok"]').count() === 1)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await ctx.close()
})
await section('long', '长报告:块级 content-visibility、折叠、跳进折叠区自动展开', async () => { await section('long', '长报告:块级 content-visibility、折叠、跳进折叠区自动展开', async () => {
const box = '#evidence-long' const box = '#evidence-long'
const info = await page.evaluate((sel) => { const info = await page.evaluate((sel) => {
+53
View File
@@ -2244,6 +2244,59 @@ await section('证据页签:裁判拆档——证据相矛盾、证据不足
} }
}) })
await section('证据页签:分析说法违规的审计行写种类的叫法;工件页签认得分析结论(分析能力一轮)', async () => {
// 夹具是后端真跑的(evidence-analysis.json 的 agent:Agent 做了变化拆解、波动基线(算不了)、可比性检查,报告有三处说法违规)。
// 审计行由 api/evidence.py 给出:说法违规列在「无证据」一组,issue 是 analysis_claim_mismatch 这几种
const fxa = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-analysis.json', import.meta.url), 'utf8'))
const a = fxa.agent
const EV_A = 'fake0evanalysis00000000000000'
const output = { 周报: a.doc.markdown, _evidence: { report_node: 'write', doc_artifact: a.doc_artifact, fields: ['周报'] }, _issuance: a.issuance }
evFakes(EV_A, { output, graph: a.graph, audit: a.audit })
fakes.set(`GET /api/artifacts/${a.doc_artifact}`, () => ({ status: 200, json: { id: a.doc_artifact, content: a.doc } }))
const results = Object.values(a.doc.catalog).filter((e) => e?.kind === 'analysis')
const at = new Date(Date.now() - 7198e3).toISOString()
fakes.set(`GET /api/runs/${EV_A}/artifacts`, () => ({ status: 200, json: results.map((e) => ({ id: e.artifact, kind: 'analysis_result',
node_id: 'fetch', size: 1800, meta: { op: a.analyses[e.alias].chain[0].op, status: a.analyses[e.alias].chain[0].status }, created_at: at })) }))
try {
await openEvidence(EV_A)
await page.locator('[data-audit-table]').waitFor({ timeout: 5000 }).catch(() => {})
const rows = await page.locator('[data-audit-table] tr[data-audit-row]').evaluateAll((els) => els.map((e) => ({
issue: e.querySelector('[data-audit-issue]')?.getAttribute('data-audit-issue') ?? '', state: e.children[1]?.textContent ?? '',
label: e.querySelector('[data-audit-focus]')?.getAttribute('aria-label') ?? '' })))
const claimed = rows.filter((r) => r.issue.startsWith('analysis_claim') || r.issue === 'causal_unsupported')
check('审计表:三条说法违规各一行,状态写种类的叫法(同正文、面板),字形照「无证据」那一组,不写「无证据」',
JSON.stringify(claimed.map((r) => r.state)) === '["?判断与分析结论不符","?判断词未挂分析结论","?因果说法没有依据"]', JSON.stringify(rows.map((r) => r.state)))
check('……三行都点得开(正文里那段文字画着波浪线、点得开),读屏写种类', claimed.length === 3
&& claimed.every((r) => r.label.includes(',') && !r.label.includes('无证据')), JSON.stringify(claimed.map((r) => r.label)))
check('……别的无证据行照旧写「无证据」(没挂依据的两句结论)', rows.filter((r) => !r.issue && r.state === '?无证据').length === 2,
JSON.stringify(rows.map((r) => `${r.issue}:${r.state}`)))
check('……有出处那一组:判定语、具名的数、结果表的格三行', rows.filter((r) => r.state.includes('有出处')).length === 3, JSON.stringify(rows.map((r) => r.state)))
await page.route(new RegExp(`/api/runs/${EV_A}/evidence/segments/`), (r) => {
const sid = decodeURIComponent(new URL(r.request().url()).pathname.split('/').pop())
return a.segments[sid] ? r.fulfill({ json: a.segments[sid] }) : r.fulfill({ status: 404, json: { detail: '没有这个片段' } })
})
await page.locator('[data-audit-table] [data-audit-open="s12"]').click()
const dock = page.locator('[data-evidence-dock] [data-evidence-panel]')
await dock.locator('[data-ev-claim-flags]').waitFor({ timeout: 5000 }).catch(() => {})
check('点说法违规那一行:右边打开那段文字,写种类和服务端的原话', (await dock.locator('[data-ev-claim-flag="analysis_claim_mismatch"]').innerText()
.catch(() => '')).includes('与所挂的分析结论 A1 对不上'))
await page.keyboard.press('Escape')
await page.goto(`${WEB}/runs/${EV_A}?view=artifacts`, { waitUntil: 'networkidle' })
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor({ timeout: 5000 }).catch(() => {})
const shown = await page.locator('[data-run-artifacts] [data-artifact-id]').evaluateAll((els) => els.map((e) => ({
kind: e.querySelector('[data-artifact-kind-label]')?.textContent ?? '', about: e.querySelector('[data-artifact-about]')?.textContent ?? '',
group: e.getAttribute('data-artifact-class') })))
check('工件页签:分析结论写种类的叫法、哪一种分析,算不了的写「没有做成」,归在证据快照一组',
JSON.stringify(shown) === JSON.stringify([{ kind: '分析结论', about: '变化拆解', group: 'evidence' },
{ kind: '分析结论', about: '波动基线(没有做成)', group: 'evidence' }, { kind: '分析结论', about: '可比性检查', group: 'evidence' }]),
JSON.stringify(shown))
} finally {
for (const k of [...fakes.keys()]) if (k.includes(EV_A) || k.includes(a.doc_artifact)) fakes.delete(k)
await page.unroute(new RegExp(`/api/runs/${EV_A}/evidence/segments/`)).catch(() => {})
}
})
await section('证据页签:没有报告的运行照实说明(none / 旧运行猜测 / 老后端)', async () => { await section('证据页签:没有报告的运行照实说明(none / 旧运行猜测 / 老后端)', async () => {
evFakes(EV_LEGACY, { output: fxe.legacy.output, graph: fxe.legacy.graph, audit: fxe.legacy.audit, report: false }) evFakes(EV_LEGACY, { output: fxe.legacy.output, graph: fxe.legacy.graph, audit: fxe.legacy.audit, report: false })
await openEvidence(EV_LEGACY) await openEvidence(EV_LEGACY)