mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
证据面板与报告正文接上分析结论(分析能力一轮)。点判定语、具名的数、结果表的格:出处链照服务端的分析几步(判定 → 方法 → 计算 → 输入 → 复算 → 封存)——判定写编号和方法的叫法(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:
@@ -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…)
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,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} 张表` : ''}` : '查询时保存的表结构'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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,没挂依据的结论句不计入)。没有运行、没有报告都是空表,不画章
|
||||||
|
|||||||
@@ -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 }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user