diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 30ed5507..3947004e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -856,6 +856,14 @@ export const api = { get( `/runs/${encodeURIComponent(runId)}/evidence/segments/${encodeURIComponent(segmentId)}${qs({ report: opts?.report })}`, 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…) diff --git a/frontend/src/dev/ui-harness.tsx b/frontend/src/dev/ui-harness.tsx index d4bed0cb..85790867 100644 --- a/frontend/src/dev/ui-harness.tsx +++ b/frontend/src/dev/ui-harness.tsx @@ -32,6 +32,7 @@ import evidenceEntity from '../run/__tests__/evidence-entity.json' import evidenceJudge from '../run/__tests__/evidence-judge.json' import evidenceVerdict from '../run/__tests__/evidence-verdict.json' import evidenceProvenance from '../run/__tests__/evidence-provenance.json' +import evidenceAnalysis from '../run/__tests__/evidence-analysis.json' import { EvidenceGuessView } from '../run/EvidenceGuess' import { IssuanceBanner } from '../run/AssistantStream' 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 认出这一份夹具) */ 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 = { matched: EV_LEGACY.matched.map(({ start: _s, end: _e, ...m }) => m), @@ -452,6 +461,28 @@ function EvidenceOnly() { label="answer" dense tally /> + {/* 分析能力一轮:分析结论的出处链、说法违规的标注、系统写的分析范围、口径卡指标上的两期可比性 */} +
+
+
分析结论(Agent 的分析工具)、说法违规、分析范围
+ +
+
+ +
+
+
分析卡的结论(模板定好的分析),表格里引用结果表的格
+ +
+
+
口径卡指标上的两期可比性
+ +
+
长报告:{EV_LONG.blocks.length} 块,块级 content-visibility,超过 3000 字先折叠
diff --git a/frontend/src/index.css b/frontend/src/index.css index 7a48c438..de79d03a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1671,6 +1671,18 @@ outline: 2px solid var(--accent); 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 { margin: 0 3px; diff --git a/frontend/src/lib/evidence.ts b/frontend/src/lib/evidence.ts index 36cb0e71..811574fa 100644 --- a/frontend/src/lib/evidence.ts +++ b/frontend/src/lib/evidence.ts @@ -26,6 +26,7 @@ import { EVIDENCE_AUDIT_TEXT, EVIDENCE_KIND_LABEL, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, JUDGE_TEXT, UPGRADE_POLICY_LABEL, claimTally, evidenceTally, type ClaimTallyCounts, } from './terms' +import { ANALYSIS_TEXT } from './termsAnalysis' import { NONE, formatNumber, shortId } from './format' import type { EvidenceBlock, EvidenceCaliberSource, EvidenceCaliberUpgrade, EvidenceCandidate, EvidenceDocData, EvidenceFieldRef, @@ -237,6 +238,21 @@ export function citeLabel(alias: string, doc: Pick, 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」。 * 单元格先看 cite 的种类:查询条目在目录里是 query,引用它的一格是 cell @@ -245,6 +261,14 @@ export function sourceOf(seg: EvidenceSegment, doc?: Pick = { @@ -1330,6 +1356,7 @@ export function auditFromApi(body: unknown): AuditRow[] { sentence: String(r.sentence ?? '').trim(), sealed: typeof r.sealed === 'boolean' ? r.sealed : null, ...(kind === 'claim' && typeof r.unit === 'string' && r.unit ? { unit: r.unit } : {}), + ...(kind === 'violation' && typeof r.issue === 'string' && r.issue ? { issue: r.issue } : {}), }) } }) diff --git a/frontend/src/lib/termsAnalysis.ts b/frontend/src/lib/termsAnalysis.ts index bdbbcf0f..0995f3ed 100644 --- a/frontend/src/lib/termsAnalysis.ts +++ b/frontend/src/lib/termsAnalysis.ts @@ -140,8 +140,6 @@ export function settingValueProblem(key: AnalysisSettingKey, raw: string): strin return null } -const short = (text: string, n = 24) => (text.length > n ? `${text.slice(0, n - 1)}…` : text) - export const ANALYSIS_TEXT = { /** 记录页工件清单里分析结论那一行的说明(工件没记算子时) */ artifactHint: '分析结论:判定、方法、计算、输入和复算所需的一切', @@ -301,6 +299,8 @@ export const ANALYSIS_TEXT = { parent: '下钻自', gotoInput: (alias: string) => `查看输入 ${alias}`, inputOfAnalysis: (alias: string) => `分析结论 ${alias} 的输入`, + /** 整句面板「挂的依据」里一份分析结论派生自谁(证据图的 derived_from 边) */ + derivedFrom: (aliases: string[]) => `派生自 ${aliases.join('、')}`, recompute: '复算', seal: '封存', artifact: '结论工件', @@ -387,5 +387,3 @@ export const ANALYSIS_TEXT = { item: (title: string, item: string) => `${title} · ${item}`, }, } - -export const shortPhrase = short diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index 531d895c..685179c9 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -13,8 +13,9 @@ import { } from '../../lib/evidence' import { formatNumber } from '../../lib/format' import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' +import { ANALYSIS_TEXT } from '../../lib/termsAnalysis' 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 { ReportInquiry, inquiryOpen, locateIn, useInquiryAccess } from '../../run/Inquiry' import { Markdown, inlineStyles } from '../../run/Markdown' @@ -119,7 +120,8 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { // 只收有状态的片段,和 EvidenceDoc 能打开的是同一批:违规行指着的文字片段(粗体、链接里的可疑名字)、 // 结构片段(列表序号 100. 里的数字)在正文里没有能点的地方,画成按钮就是点了没反应。 // 结论句的行(证据相矛盾、证据不足……)打开的是这一句的句末徽标(`报告:claim:句子`):句子有判定(封存的、 - // 证据图里封存后追加的)才有徽标 + // 证据图里封存后追加的)才有徽标。分析说法违规(判断词未挂分析结论……)落在的那段文字没有状态,正文里也点得开 + //(claimMarksOf,和 EvidenceDoc 画波浪线的是同一批) const openable = useMemo(() => { const keys = new Set() 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}`) 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 }, [reports, docOf, graph]) @@ -619,7 +622,8 @@ function AuditLine({ row, current, openable, multiReport, labelOf, onOpen, onFoc const meta = EVIDENCE_STATE[row.state] const text = plain(row.text) 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 = { 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)]', @@ -638,8 +642,11 @@ function AuditLine({ row, current, openable, multiReport, labelOf, onOpen, onFoc {text} )} - - {meta.glyph && {meta.glyph}}{meta.label} + + {/* 说法违规(判断词未挂分析结论……):写它自己的叫法,不写「无证据」——说的不是数字没有出处 */} + {claimIssue + ? <>{meta.glyph && {meta.glyph}}{claimIssue} + : <>{meta.glyph && {meta.glyph}}{meta.label}} {row.source || '—'} diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 703c5fd5..b350ab0b 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -6,6 +6,7 @@ */ import type { Approval, Run, RunStatus, RunUsage } from '../../types' +import { ANALYSIS_TEXT, analysisMethodLabel } from '../../lib/termsAnalysis' import { RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode, } from '../../lib/status' @@ -341,6 +342,7 @@ export const ARTIFACT_KIND_LABEL: Record = { challenge: '反方审稿记录', inquiry_answer: INQUIRY_TEXT.artifactKind, metric_input: RUN_ARTIFACTS_TEXT.kindMetricInput, + analysis_result: ANALYSIS_TEXT.evidence.title, } export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind @@ -355,6 +357,10 @@ export function artifactDescription(kind: string, meta?: Record | n case 'metric_set': return meta?.caliber ? `口径卡「${meta.caliber}」${meta.version ?? ''}` : '口径卡的指标、算式和输入' 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: 表数量}):报告里的表名、字段名按它核对 case 'schema_snapshot': return meta?.source ? `表结构:数据源「${meta.source}」${typeof meta.tables === 'number' ? ` · ${meta.tables} 张表` : ''}` : '查询时保存的表结构' diff --git a/frontend/src/run/EvidenceAnalysis.tsx b/frontend/src/run/EvidenceAnalysis.tsx new file mode 100644 index 00000000..7eec12f5 --- /dev/null +++ b/frontend/src/run/EvidenceAnalysis.tsx @@ -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 ( +
+
{title}
+ {children} +
+ ) +} + +/** 一组「名称 · 值」 */ +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 ( +
+ {shown.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) +} + +/** 逐项复核出来的问题:正常时不说,出问题才醒目(同 EvidencePanel 的 IntegrityList) */ +function Problems({ code, items }: { code: string; items: string[] }) { + if (!items.length) return null + return ( +
    + {items.map((t) =>
  • {t}
  • )} +
+ ) +} + +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 ( + + ) + } + return {label || id} +} + +/** 判定:判定语和它的依据,谁做的,复算、封存的结论(细节在后面几步) */ +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 ( + + {step.phrase && ( +

{step.phrase}

+ )} +
+ {refused && ( + + {E.refused} + + )} + {E.origin[String(step.origin ?? '')] ?? NONE} + + {step.artifact && {E.artifact} {shortId(step.artifact)}} +
+ +
+ {step.reason}] : null, + [E.citedPart, {citedText}], + step.caveats?.length ? [E.caveats, ( +
    + {step.caveats.map((c, i) =>
  • {c.text}
  • )} +
+ )] : null, + step.hypothesis ? [E.hypothesis, ( + + {step.hypothesis} + {step.hypothesis_note && {step.hypothesis_note}} + + )] : null, + recompute ? [E.recompute, ( + + {recompute.text || NONE} + + )] : null, + seal ? [E.seal, ( + + {seal.via === 'metric_set' ? E.viaCard : seal.ledger ? E.inLedger : E.notInLedger} + + )] : null, + ]} /> +
+
+ ) +} + +/** 方法:方法与版本、参数(规范化后的)、实际用的阈值(只列这个算子用到的)、现查结果 */ +function MethodPart({ step }: { step: EvidenceAnalysisStep }) { + const params = step.params && typeof step.params === 'object' && Object.keys(step.params).length ? step.params : null + return ( + + {step.method_label || NONE} · {step.method || NONE}], + step.settings?.length ? [E.settings, ( +
    + {step.settings.map((x) => ( +
  • {x.label || x.key} {valueText(x.value)}
  • + ))} +
+ )] : null, + step.probes?.length ? [E.probes, ( +
    + {step.probes.map((p, i) =>
  • {probeText(p)}
  • )} +
+ )] : null, + ]} /> + {params && ( +
+
{E.params}
+
+            {JSON.stringify(params, null, 2)}
+          
+
+ )} +
+ ) +} + +/** 计算:关键数的代入式、具名的数(被引用的那个描出来)、结果表(被引用的那一格高亮,遮罩的列写「已遮罩」) */ +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 ( + + {!!step.formulas?.length && ( +
+
{E.formulas}
+
+            {step.formulas.map((f) => f.text ?? '').filter(Boolean).join('\n')}
+          
+
+ )} + {!!step.values?.length && ( +
+
{E.values}
+
    + {step.values.map((v) => ( +
  • + {v.key} {v.rendered ?? NONE} +
  • + ))} +
+
+ )} + {table && ( +
+
+ {E.resultTable}({E.resultRows(table.rows.length, table.total_rows ?? table.rows.length)}) +
+ (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 &&

{EVIDENCE_TEXT.windowCut(table.rows.length)}

} +
+ )} +
+ ) +} + +/** 输入的一份:角色、调用里写的名字、落到哪个 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 ( + + {step.ref}] : null, + step.query ? [E.query, onQuery + ? + : {step.query}] : null, + parent && step.alias ? [E.parent, {step.alias}] : null, + step.columns?.length ? [E.columnsUsed, {step.columns.join('、')}] : null, + rows ? [E.rowsUsed, rows] : null, + 'value' in step && !parent ? [ratio ? E.headlineRatio : E.headline, ( + + {step.column != null && typeof step.row === 'number' ? `${E.cellAt(step.row, step.column)} = ` : ''}{valueText(step.value)} + + )] : null, + closure && closed ? [E.closure, ( + {closed} + )] : null, + step.phrase ? [E.phrase, step.phrase] : null, + ]} /> + + + ) +} + +/** + * 分析结论的出处链(查询步骤之外的那几步)。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 ( + +

+ {pending ? E.chainPending : failed || EVIDENCE_TEXT.chainMissing} +

+
+ ) + } + 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 ( + <> + s.step === 'analysis_recompute')} + seal={chain.find((s) => s.step === 'analysis_seal')} overall={seal} /> + {method && } + {calc && } + {inputs.map((s, i) => )} + + ) +} + +/** + * 指标步骤上的两期可比性(口径卡跨两份同形查询算的环比、同比,执行时自动做的检查):判定、判定语、原因一句一个,展开看那份 + * 可比性结论的整条链(两份输入快照的查询步骤指标链里已经有了,不再给)。不进台账、随口径卡的指标集封存 + */ +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 ( +
+
{E.comparability}
+
+
+ {E.comparabilityLabel[verdict] ?? (verdict || NONE)} + {comparability.phrase && {comparability.phrase}} +
+ {!!comparability.reasons?.length && ( +
    + {comparability.reasons.map((r) =>
  • · {r}
  • )} +
+ )} + + {steps.length > 0 && ( + + )} + {open && ( +
+ +
+ )} +
+
+ ) +} + +/** 说法违规的代码(服务端 engine/analysis_claims.CODES) */ +export const CLAIM_CODES = new Set(Object.keys(ANALYSIS_TEXT.claim.label)) +export const isClaimViolation = (v: Pick): boolean => CLAIM_CODES.has(v.code) + +/** 证据面板:落在这个片段上的说法违规,一处一条:种类、服务端的原话、所挂的分析结论(点一下看那份结论) */ +export function ClaimFlags({ violations, onAnalysis }: { + violations: EvidenceViolation[] + onAnalysis?: (alias: string) => void +}) { + if (!violations.length) return null + return ( + +
    + {violations.map((v, i) => ( +
  • +
    + {v.text && 「{v.text}」} + {ANALYSIS_TEXT.claim.label[v.code] ?? v.code} +
    + {v.message &&

    {v.message}

    } +
    + {v.analyses?.length ? ( + <> + {ANALYSIS_TEXT.claim.analyses} + {v.analyses.map((a) => (onAnalysis + ? + : {a}))} + + ) : v.code !== 'causal_unsupported' && {ANALYSIS_TEXT.claim.noAnalyses}} +
    +
  • + ))} +
+
+ ) +} + +/** 分析范围的一条:编号(点得开)、判定语;拆过的写按哪些维度、下钻自谁;算不了的判定语里已经写了原因 */ +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 ( +
  • + {alias && (onOpen + ? + : {alias})} + + {item.phrase || NONE} + {extra && ({extra})} + +
  • + ) +} + +/** + * 报告末尾系统写的「分析范围」(文档的 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 ( +
    +
    +

    {S.title}

    + {S.hint} +
    +
    + {shown.map(([key, title, items]) => ( +
    +
    {title}
    +
      {items.map((it, i) => )}
    +
    + ))} + {notDecomposed && ( +
    +
    {S.notDecomposed}
    +
    + {notDecomposed} +
    +
    + )} + {tests && ( +
    +
    {S.tests}
    +
    {tests}
    +
    + )} +
    +
    + ) +} diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index 32493827..c990695f 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -1,7 +1,7 @@ import { Fragment, forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, 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' import { createPortal } from 'react-dom' import { @@ -10,10 +10,12 @@ import { import clsx from 'clsx' import { rovingTarget } from '../components/ui' import { - EVIDENCE_STATE, alertTier, claimCounts, claimLabel, claimProblems, conflicting, docTally, segmentKind, segmentLabel, - segmentState, tallySummary, unitText, isJudged, limitOf, unitVerdict, verdictMissing, verdictState, withVerdicts, + EVIDENCE_STATE, alertTier, claimCounts, claimLabel, claimProblems, codePointIndex, conflicting, docTally, segmentKind, + segmentLabel, segmentState, tallySummary, unitText, isJudged, limitOf, unitVerdict, verdictMissing, verdictState, withVerdicts, type EvidenceStateCode, type EvidenceTally as Tally, } from '../lib/evidence' +import { ANALYSIS_TEXT } from '../lib/termsAnalysis' +import { ScopeSection, isClaimViolation } from './EvidenceAnalysis' import { formatNumber } from '../lib/format' import { 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 按退避 * 等比对出结果),正式运行、没有运行的文档不取。 * + * 分析能力一轮:判定语([[a:A1]])是一个系统渲染的片段,和数字一样画「有出处」、点得开(证据面板的分析一步)。说法违规 + * (判断词未挂分析结论、判断与分析结论不符、因果说法没有依据,文档的 violations 按 unit / segment / span 给)标在正文上:判断词 + * 那几个字画琥珀色波浪线,那段文字是一个可点的片段,句末挂「说法待核」,n / N 跳得到;点开看服务端的原话和所挂的结论。正文 + * 后面是系统写的「分析范围」(文档的 analysis_scope),编号点得开。 + * * 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加 * content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。 */ @@ -125,8 +132,47 @@ interface Model { grid: Map } +/** + * 说法违规落在哪段文字上:片段 id → 违规的种类、判断词在这段文字里的位置(UTF-16 下标,[起, 止))。服务端的 span 是全文的码点 + * 位置,片段自己的 span 也是,相减后按码点换成下标;没给 span 的整段都算。只认说法违规(isClaimViolation)、能落到一段非结构 + * 片段上的 + */ +type ClaimMarks = Map + +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() + 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, ask = false, - marks?: Record): Model { + marks?: Record, claims?: ClaimMarks): Model { const order: string[] = [] const index = new Map() const seg = new Map() @@ -146,13 +192,15 @@ function buildModel(blocks: EvidenceBlock[], overlay?: Record 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 一种) const ledger = useRunLedger(runId, ledgerEnabled(runId, runClass)) const labels = report ? ledger?.data?.reports?.[report]?.labels : undefined @@ -478,8 +528,28 @@ export const EvidenceDoc = forwardRef { + 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 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) setAnnounce('') if (refocus && back) requestAnimationFrame(() => segEl(back)?.focus()) @@ -616,6 +686,7 @@ export const EvidenceDoc = forwardRef close()} onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }} onViolations={openViolations} + onAnalysis={(alias) => openAnalysis(alias)} /> ) @@ -676,7 +747,7 @@ export const EvidenceDoc = forwardRef + panelId={panelId} model={model} ask={ask} calibers={caliberMarks} claims={claimMarks} /> {mode === 'inline' && b === activeBlock && panelNode} ))} @@ -695,6 +766,11 @@ export const EvidenceDoc = forwardRef setExpanded(false)}>收起
    )} + {doc.analysis_scope && ( + // 系统写的「分析范围」:不是写作者写的,随报告封存(不进正文、不过核对);编号点得开 + openAnalysis(alias, { scope: true })} /> + )} + {mode === 'inline' && view?.kind === 'scope' && panelNode} {mode === 'dock' && panelNode && dock && createPortal(panelNode, dock)} {mode !== 'inline' && mode !== 'dock' && panelNode && createPortal(panelNode, document.body)} @@ -890,10 +966,12 @@ export function ClaimTally({ claims, lead }: { claims: ClaimTallyCounts; lead: b /** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */ 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 /** 来自口径的数字:片段 id → 小标记(口径账本) */ calibers?: CaliberMarks + /** 说法违规落在的文字:片段 id → 判断词的位置 */ + claims?: ClaimMarks dense: boolean 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 }) => ( ) @@ -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 code?: boolean; tag: boolean; badge?: Badge calibers?: CaliberMarks + claims?: ClaimMarks /** * 审批质询的「质询」:roving 是这一句一个可点的都没有,「质询」本身进键盘顺序;follow 跟在这一句后面,焦点在这一句上时 * 按 Tab 到达 @@ -1062,6 +1141,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask, const styles = useMemo(() => (code ? null : inlineStyles(text)), [text, code]) let pos = 0 const alerts = new Set() + let claimTag = false const nodes = segs.map((s) => { const from = pos pos += s.text.length @@ -1069,6 +1149,32 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask, const body = styles ? : s.text + const claimed = !state ? claims?.get(s.id) : undefined + if (claimed) { + // 说法违规:判断词画琥珀色波浪线,整段文字是一个可点的片段(点开看服务端的原话),句末挂「说法待核」 + claimTag = true + const slice = (a: number, z: number) => (styles + ? : s.text.slice(a, z)) + const parts: ReactNode[] = [] + let at = 0 + claimed.ranges.forEach(([a, z], k) => { + if (a > at) parts.push({slice(at, a)}) + parts.push({slice(a, z)}) + at = z + }) + if (at < s.text.length) parts.push({slice(at, s.text.length)}) + const open = active === s.id + const labels = claimed.codes.map((c) => ANALYSIS_TEXT.claim.label[c] ?? c).join('、') + return ( + + ) + } if (!state) return {s.strong ? {body} : body} const meta = EVIDENCE_STATE[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]} ))} + {tag && claimTag && ( + + )} {badge && (meta || look) && (