Files

713 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 报告导出的浏览器端:点开正文里的一个数字 / 名称 / 引文,展示它的出处链(口径、来源、计算方式)。
* 原生 JS,无依赖,不连网。正文是服务端按报告文档渲染好的 HTML,这里只做三件事:
* ① 填页面头部(报告节点、文档工件、封存状态、运行信息);
* ② 给带状态的片段接上点击和键盘(事件委托在正文容器上),点开右侧的出处面板;
* ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算、可比性)、
* 输入步骤(值、来自哪个节点哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查)、
* 运行输入、表名字段名、引文,分析结论的六步(判定、方法、计算、输入、复算、封存),和网页上的证据面板
* (api/evidence 的片段接口)同一个拼法。正文后面系统写的「分析范围」里的编号同样点得开(data-seg 是 scope:A1)。
* 链里的每一步带着导出那一刻的封存与哈希状态,照实标明;这份文件不是核对件(页脚写明)。
* 来自数据的文字一律经 textContent 写进页面,不拼 HTML。
*/
(() => {
'use strict'
// ------------------------------------------------------------------ 文案
const T = {
schema: (s) => `文件格式 ${s}`,
stateLabel: {
deterministic: '有出处', none: '无证据', suspect: '疑似不存在的名称', unverified: '无法核实',
unjudged: '未裁判', candidate: '猜测的来源',
},
kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文', analysis: '判定' },
segTitle: (kind, text) => `${kind}「${text}」`,
metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点',
metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间',
classLabel: { formal: '正式运行', exploratory: '探索运行' },
statusLabel: { succeeded: '已完成', failed: '已失败', cancelled: '已取消', rejected: '被驳回', running: '进行中', waiting: '等待审批' },
docHashBad: '这份报告文档与哈希不一致(疑似被修改),正文仅供参考,其中的口径和出处未经核对。',
sealNone: '这次运行导出时尚未封存:下列出处来自导出那一刻的系统记录,未经封存核对。',
sealOk: (hash) => `运行已封存,清单哈希 ${hash}。要向系统外的人证明文件未被修改,请导出「可验证报告」(记录页 · 凭证详情),拿封存哈希去系统对照。`,
sealBroken: '封存核对不一致:事件在封存后被修改、删除或插入,下列出处未经核对。',
noChain: '这一处没有可展示的出处链。',
// 指标步骤
metric: '口径卡指标', dName: '指标', dValue: '值', dCaliber: '口径', dExpression: '原式(计算方式)',
dSubstituted: '代入式(代入输入后的算式)', dInputs: '输入', dRecompute: '复算', dUnit: '单位',
dSource: '来源', dPinned: '口径来源', dUpgrade: '升版处置', dDual: '双盲复算', dArtifact: '工件',
dNode: '来源节点', dStatus: '状态', dDecimals: '小数位',
recomputeOk: '复算一致', recomputeBad: '复算不一致:代入式的计算结果与记录值不符', recomputeNone: '无法复算',
// 输入追没追到原始数据(查询快照里的格、运行输入):和「有出处」(确定性复算一致)是两件事,同证据面板的说法
dOrigin: '原始数据', originTraced: '输入都追到了原始数据(查询快照里的格或运行输入)',
originStop: (path, label) => `${path} 的出处追到「${label}」为止,没追到原始数据`,
originUnknown: (path) => `${path} 无法确定来自哪个节点,没追到原始数据`,
incomplete: '结果不完整', incompleteWhy: (t) => `基于被截断的查询结果计算,结果不完整${t ? `:${t}` : ''}`,
sqlCheckFailed: 'SQL 检查未通过', sqlCheckWhy: (t) => `所依据的查询未通过 SQL 检查,结果不可靠${t ? `:${t}` : ''}`,
missingValue: '缺少输入:口径卡本次未计算出该指标的值',
dualLabel: { agreed: '一致', disagreed: '不一致', unchecked: '无法复算', skipped: '未复算' },
dualMore: (t) => `(${t})`,
// 输入步骤
input: '输入', dPath: '路径', dFrom: '取自', dQuery: '查询', dCell: '位置', dMasked: '已遮罩',
maskNote: '取自设置了遮罩的列,值不随文件显示。遮罩只减少暴露,不是安全边界',
maskUnverified: (t) => `遮没遮核对不了:${t}`,
truncated: '查询结果已截断,该字段只含取回的部分行',
// 查询步骤
query: '查询', dSql: 'SQL', dSourceName: '数据源', dTool: '工具', dRows: '被引用的行', dAllRows: (n) => `共 ${n} 行`,
dTotalRows: '行数',
dColumnsMasked: '遮罩列', dChecks: 'SQL 检查', windowCut: '被引用的行前后行数太多,只列出其中一部分',
truncatedRows: '查询结果被截断,取回的行不完整',
merge: '合并查询', mergeInputs: '合并的输入', mergeTraced: '被引用格的来历', mergeWarnings: '执行时的警告',
mergeNoLineage: '没有逐格来历',
dRowOf: (n) => `第 ${n + 1} 行`,
// 其余步骤
runInput: '运行输入', dField: '字段', entity: '表或字段', quote: '引文', dNameEntity: '名称',
dTable: '表', dColumn: '列', dFields: '字段', dFieldsMore: (n) => `另 ${n} 个字段`,
dQueries: '出现在哪几次查询', dChecked: '查过的地方', dClosest: '最接近的已知名称',
dContent: '原文', dMatch: '比对', dCollection: '知识库', dWhy: '原因',
present: '在', absent: '不在',
matchOk: '原文一致', matchBad: '与原文不一致', matchNone: '无法比对',
// 完整性(每一步都可带)
dIntegrity: '核对', sealedOk: '已封存 · 哈希核对一致', sealedNo: '不在封存范围内',
hashBad: '工件哈希核对失败', hashNone: '未能读取工件',
note: '说明',
close: '关闭', openAria: (text) => `查看出处:${text}`,
// 口径(口径账本,封存后比对):来自口径 / 相关口径 / 这次的推理 / 没比对
ledger: '口径', ledgerFrom: (name, version, level) => `来自口径「${name}」v${version}${level ? `(${level})` : ''}`,
ledgerHow: { independent: '等价已证明', composed: '助手选用' },
ledgerRelated: '相关口径', ledgerReasoning: '这次的推理:没有相关的已发布口径', ledgerUnknown: '没有比对',
dBinding: '参数', dDiffers: '差在哪', dHow: '怎么认的', dColumnOf: '对应口径的列',
unnamed: '已删除的工作流',
// 口径卡指标上的可比性(跨两份同形查询算的环比、同比)
dComparability: '两期可比性', comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' },
comparabilityOpen: '展开这份可比性结论(判定、方法、计算、输入、复算、封存)',
// 分析结论:判定 → 方法 → 计算 → 输入 → 复算 → 封存
analysis: '分析结论', dPhrase: '判定语', dCriterion: '判定标准', dSubjects: '点名的段',
dDirection: '方向', directionLabel: { up: '上升', down: '下降' }, dCaveats: '附带说明', dReason: '算不了的原因',
dCitedPart: '这一处引用的是', citedPhrase: '判定语', citedValue: (k) => `具名的数「${k}」`,
citedCell: (r, c) => `结果表第 ${r + 1} 行「${c}」`, dWho: '谁做的',
originLabel: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析', metric: '口径卡执行时自动做的可比性检查' },
dHypothesis: 'Agent 写的假设', refused: '这次分析没有做成', renderOk: '与按结论渲染的结果一致', renderBad: '与按结论渲染的结果不一致',
method: '方法', dMethod: '方法与版本', dParams: '参数', dSettings: '实际用的阈值', dProbes: '现查结果',
probeBasis: { live: '现查', profile: '剖析', observed: '取值观察' },
probeBound: (expr, value) => `按运行当天现算的界 ${expr} = ${value}`, probeEmpty: '这一列一个值都没有',
probeRange: (min, max) => `${min} 至 ${max}`,
calc: '计算', dValues: '具名的数', dFormulas: '关键数的代入式', dResultTable: '结果表',
resultRows: (shown, total) => `列出 ${shown} 行,共 ${total} 行`,
analysisInput: '输入', dRole: '角色', dRef: '调用里写的名字', dColumnsUsed: '用到的列', dRowsUsed: '用到的行',
allRows: '全部行', dHeadline: '总数那一格', dHeadlineRatio: '整体比率那一格', dClosure: '闭合核对',
closureOk: (h, t) => `总数 ${h} 与各段之和 ${t} 闭合`, closureBad: (h, t) => `总数 ${h} 与各段之和 ${t} 对不上`,
ratioClosureOk: (h, t) => `整体比率 ${h} 与各段分子之和 ÷ 分母之和 ${t} 闭合`,
ratioClosureBad: (h, t) => `整体比率 ${h} 与各段分子之和 ÷ 分母之和 ${t} 对不上`,
dParent: '下钻自', recompute: '复算', seal: '封存', inputOfAnalysis: (alias) => `分析结论 ${alias} 的输入`,
inLedger: '封存范围内的台账记过这份分析结论', notInLedger: '封存范围内的台账里没有这份分析结论',
viaCard: '记在口径卡的指标集里,随卡封存(不进台账)',
}
const MASKED = '已遮罩'
const NONE = '—'
// ------------------------------------------------------------------ 工具
const $ = (id) => document.getElementById(id)
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const own = (obj, key) => (isObj(obj) && typeof key === 'string' && Object.prototype.hasOwnProperty.call(obj, key)
? obj[key] : undefined)
const textOf = (v) => (typeof v === 'string' ? v : isObj(v) || Array.isArray(v) ? JSON.stringify(v) : String(v))
const short = (id) => (typeof id === 'string' && id.length > 12 ? `${id.slice(0, 12)}…` : String(id ?? ''))
const pad = (n) => String(n).padStart(2, '0')
function h(tag, props, ...kids) {
const el = document.createElement(tag)
for (const [key, value] of Object.entries(props || {})) {
if (value == null || value === false) continue
if (key === 'class') el.className = value
else if (key.startsWith('on')) el.addEventListener(key.slice(2), value)
else el.setAttribute(key, value === true ? '' : String(value))
}
for (const kid of kids.flat(Infinity)) {
if (kid != null && kid !== false) el.append(kid instanceof Node ? kid : String(kid))
}
return el
}
/** 时间按读者所在时区显示(文件会被转给别的时区的人),后面注明时区 */
function when(iso) {
if (!iso) return NONE
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
const offset = -d.getTimezoneOffset()
const zone = `UTC${offset < 0 ? '-' : '+'}${Math.floor(Math.abs(offset) / 60)}${Math.abs(offset) % 60 ? `:${pad(Math.abs(offset) % 60)}` : ''}`
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}(${zone})`
}
const valueText = (v) => (v == null ? '空值(NULL)' : typeof v === 'string' ? `「${v}」` : textOf(v))
function kv(rows) {
return h('dl', { class: 'kv' }, rows.filter(Boolean).map(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
}
const badge = (kind, text) => h('span', { class: 'badge', 'data-kind': kind }, text)
const ok = (good) => (good === true ? badge('ok', '✓') : good === false ? badge('bad', '✗') : badge('na', '—'))
// ------------------------------------------------------------------ 数据
const view = { data: null, active: null, back: null }
function parseData() {
try {
const data = JSON.parse($('report-data').textContent)
if (isObj(data) && isObj(data.meta)) return data
} catch (e) {
// 数据块坏了:正文还在(服务端渲染),只是点不开出处
}
return null
}
// ------------------------------------------------------------------ 头部
function renderHead(data) {
const meta = data.meta
$('report-label').textContent = `报告节点「${meta.report?.label || meta.report?.node_id || NONE}」`
$('doc-id').textContent = meta.doc_artifact ? `文档 ${short(meta.doc_artifact)}` : ''
if (meta.doc_hash_ok === false) {
const warn = $('doc-warn')
warn.textContent = T.docHashBad
warn.hidden = false
}
const label = (map, value) => map[value] || (value ? String(value) : NONE)
$('meta').replaceChildren(...[...[
[T.metaRun, h('code', {}, String(meta.run_id || ''))],
[T.metaClass, label(T.classLabel, meta.run_class)],
// 被驳回(cancelled + end_reason=rejected)照运行记录写「被驳回」,不写成手动停止的「已取消」
[T.metaStatus, label(T.statusLabel, meta.status === 'cancelled' && meta.end_reason === 'rejected' ? 'rejected' : meta.status)],
[T.metaReport, String(meta.report?.label || meta.report?.node_id || NONE)],
[T.metaStarted, when(meta.started_at)],
[T.metaFinished, when(meta.finished_at)],
[T.metaExported, when(meta.exported_at)],
].filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)])])
const seal = meta.seal || {}
const box = $('seal-text')
if (!seal.sealed) box.textContent = T.sealNone
else if (seal.ok === false) box.textContent = T.sealBroken
else box.textContent = T.sealOk(String(seal.manifest_hash || ''))
$('foot-schema').textContent = T.schema(String(data.schema || ''))
}
// ------------------------------------------------------------------ 每一步的渲染
const nodeLabel = (id) => {
const node = own(view.data.nodes, id)
return isObj(node) && node.label ? String(node.label) : (id ? String(id) : NONE)
}
/** 指标步骤:名称、值、口径(口径卡、版本、钉住的来源、升版处置)、原式、代入式、输入、复算、双盲复算 */
function metricPart(step) {
const body = []
const rows = [
[T.dName, step.name || step.metric || NONE],
step.unit ? [T.dUnit, String(step.unit)] : null,
step.decimals != null ? [T.dDecimals, String(step.decimals)] : null,
]
body.push(kv(rows))
// 口径:口径卡名 + 版本;钉在别的工作流上的写明来自哪一版
const caliber = [`${step.caliber || NONE}`, step.version ? ` ${step.version}` : ''].join('')
const source = isObj(step.source) ? step.source : null
body.push(kv([
[T.dCaliber, h('span', {}, `口径卡「${caliber}」`)],
source ? [T.dPinned, `工作流「${source.workflow_name || source.workflow_id}」的 ${source.caliber} @ ${source.caliber_version}`] : null,
]))
if (source && isObj(step.caliber_upgrade)) {
body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' },
`${T.dUpgrade}:钉在 ${step.caliber_upgrade.pinned || NONE},上游最新 ${step.caliber_upgrade.latest || NONE},${step.caliber_upgrade.policy_label || step.caliber_upgrade.policy || NONE}`))
}
if (step.status === 'missing_input') body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.missingValue))
if (step.incomplete) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.incompleteWhy(step.incomplete_reason)))
if (step.sql_check_failed) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.sqlCheckWhy(step.sql_check_reason)))
// 计算方式:原式 + 代入式
body.push(h('p', { class: 'sub' }, T.dExpression), h('pre', { class: 'sql' }, String(step.expression || NONE)))
body.push(h('p', { class: 'sub' }, T.dSubstituted), h('pre', { class: 'sql' }, String(step.substituted || NONE)))
const untraced = Array.isArray(step.untraced) ? step.untraced.filter(isObj) : null
body.push(h('div', {}, kv([
[T.dRecompute, step.recompute_ok == null ? T.recomputeNone
: step.recompute_ok ? T.recomputeOk : T.recomputeBad],
// 卡上没有输入、导出之前的服务端没给时不写
untraced ? [T.dOrigin, untraced.length
? untraced.map((u) => (u.node_id ? T.originStop(String(u.path || NONE), u.label || nodeLabel(u.node_id))
: T.originUnknown(String(u.path || NONE)))).join(';')
: T.originTraced] : null,
])))
// 可比性:口径卡执行时自动做的可比性检查(跨两份同形查询算的环比、同比),做过才有
if (isObj(step.comparability)) {
const c = step.comparability
const why = Array.isArray(c.reasons) && c.reasons.length ? `:${c.reasons.join(';')}` : ''
body.push(h('div', { style: 'margin-top:8px' }, kv([
[T.dComparability,
`${own(T.comparabilityLabel, c.verdict) || c.phrase || NONE}${c.verdict === 'comparable' ? '' : why}`],
c.criterion ? [T.dCriterion, String(c.criterion)] : null,
c.artifact ? [T.dArtifact, h('code', {}, short(c.artifact))] : null,
])))
// 点得开那份可比性结论:服务端给的出处链(同分析结论的引用),收在一个可展开的块里
const steps = Array.isArray(c.steps) ? c.steps.filter((x) => isObj(x) && own(PARTS, x.step)) : []
if (steps.length) {
body.push(h('details', { class: 'comparability', 'data-comparability': '' },
h('summary', {}, T.comparabilityOpen), ...steps.map((x) => PARTS[x.step](x))))
}
}
// 双盲复算(期 4):复核过这个指标才有
if (isObj(step.dual_check)) {
const d = step.dual_check
const more = d.reason || (d.blind_rendered ? `盲算值 ${d.blind_rendered}` : '')
body.push(h('div', { style: 'margin-top:8px' }, kv([
[T.dDual, `${T.dualLabel[d.status] || d.status || NONE}${more ? T.dualMore(more) : ''}`],
])))
}
return h('section', { class: 'part', 'data-step': 'metric' }, h('h3', {}, T.metric),
h('div', { class: 'part-body' }, ...body))
}
/** 输入步骤:路径、值(遮罩的写「已遮罩」)、取自哪个节点、哪次查询的哪一格 */
function inputPart(step) {
const masked = Array.isArray(step.masked) && step.masked.length
const rows = [
[T.dPath, h('code', {}, String(step.path || NONE))],
[T.dValue, step.value === MASKED ? T.dMasked : valueText(step.value)],
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.cell ? [T.dCell, String(step.cell)] : null,
step.via ? [T.dFrom, step.via === 'tool_cell' ? '查询结果的一格' : step.via === 'agent_field' ? 'Agent 交来的字段'
: step.via === 'position' ? '按位置取的一格' : String(step.via)] : null,
]
const bits = []
if (masked) bits.push(badge('na', `${T.dColumnsMasked}:${step.masked.join('、')}`))
if (step.mask_unverified) bits.push(badge('na', T.maskUnverified(step.mask_unverified)))
if (step.status === 'truncated') bits.push(badge('wait', T.truncated))
return h('section', { class: 'part', 'data-step': 'input' },
h('h3', {}, `${T.input}「${step.path || NONE}」`),
h('div', { class: 'part-body' }, kv(rows),
masked ? h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.maskNote) : null,
bits.length ? h('div', { class: 'chips' }, ...bits) : null))
}
/** 查询步骤:SQL、数据源、被引用的行(高亮那一格)、遮罩列、SQL 检查、合并视图 */
function queryPart(step) {
const body = []
const rows = [
step.source ? [T.dSourceName, String(step.source)] : null,
step.tool ? [T.dTool, h('code', {}, String(step.tool))] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.alias ? [T.dQuery, String(step.alias)] : null,
step.total_rows != null ? [T.dTotalRows, T.dAllRows(step.total_rows)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.sql === 'string' && step.sql) body.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, step.sql))
if (step.mask_note) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(step.mask_note)))
if (step.note) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.note)))
// 被引用的行:前后各带几行,引用的那几格高亮;遮罩列写「已遮罩」
if (Array.isArray(step.rows) && step.rows.length && Array.isArray(step.columns)) {
const offset = Number.isInteger(step.row_offset) ? step.row_offset : 0
const index = Array.isArray(step.row_index) ? step.row_index : step.rows.map((_, i) => i + offset)
const cells = (step.highlight && Array.isArray(step.highlight.cells) ? step.highlight.cells : [])
const hit = new Set(cells.map((c) => `${c[0]}:${c[1]}`))
const hitCols = new Set(cells.map((c) => String(c[1])))
const masked = new Set((step.masked || []).map(String))
const table = h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, step.columns.map((c) => h('th', { class: hitCols.has(String(c)) ? 'hit' : null }, String(c))))),
h('tbody', {}, step.rows.map((row, i) => {
const r = index[i]
const record = Array.isArray(row) ? row : isObj(row) ? step.columns.map((c) => own(row, c)) : [row]
return h('tr', {}, record.map((v, j) => h('td', {
class: [hit.has(`${r}:${step.columns[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : '']
.filter(Boolean).join(' ') || null,
}, masked.has(String(step.columns[j])) ? MASKED : v == null ? NONE : textOf(v))))
}))))
body.push(h('p', { class: 'sub' }, `${T.dRows}(${T.dRowOf(offset)} 起,引用的格高亮)`), table)
if (step.window_truncated) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.windowCut))
if (step.truncated) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.truncatedRows))
}
// SQL 检查(查出问题才有)
if (Array.isArray(step.checks) && step.checks.length) {
body.push(h('p', { class: 'sub' }, T.dChecks),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.checks.map((c) => h('li', {}, `${c.level || 'error'} · ${c.code || ''}:${c.message || ''}`))))
}
// 合并查询:输入、合并 SQL、逐格来历、警告
if (isObj(step.merge)) {
body.push(h('p', { class: 'sub' }, T.merge))
if (Array.isArray(step.merge.inputs) && step.merge.inputs.length) {
body.push(h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.inputs.map((i) => h('li', {},
`${i.query || i.alias || NONE} · ${i.label || nodeLabel(i.node_id)} · ${i.rows ?? '?'} 行${i.sealed === false ? ' · 未封存' : ''}`))))
}
if (Array.isArray(step.merge.traced) && step.merge.traced.length) {
body.push(h('p', { class: 'sub' }, T.mergeTraced),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.traced.map((t) => h('li', {}, t.input
? `第 ${t.cell[0] + 1} 行「${t.cell[1]}」← ${t.query || t.input} 的 ${T.dRowOf(t.row)}「${t.column}」`
: `第 ${t.cell[0] + 1} 行「${t.cell[1]}」:${t.note || T.mergeNoLineage}`))))
}
if (Array.isArray(step.merge.warnings) && step.merge.warnings.length) {
body.push(h('p', { class: 'sub' }, T.mergeWarnings),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.warnings.map((w) => h('li', {}, String(w.message || '')))))
}
}
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'query' },
h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}${step.input_of ? ` · ${T.inputOfAnalysis(step.input_of)}` : ''}`),
h('div', { class: 'part-body' }, ...body))
}
/** 运行输入步骤 */
function runInputPart(step) {
return h('section', { class: 'part', 'data-step': 'run_input' }, h('h3', {}, T.runInput),
h('div', { class: 'part-body' }, kv([
[T.dField, String(step.field || NONE)],
[T.dValue, valueText(step.value)],
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
])))
}
/** 实体(表、字段)步骤:出现在哪几次查询、表结构快照、字段清单;可疑实体的查证和最接近的名字 */
function entityPart(step) {
const body = []
const rows = [
[T.dNameEntity, step.name || NONE],
step.table ? [T.dTable, String(step.table)] : null,
step.column ? [T.dColumn, String(step.column)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (step.reason) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.reason)))
if (Array.isArray(step.sources) && step.sources.length) {
body.push(h('p', { class: 'sub' }, T.dQueries),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.sources.map((s) => h('li', {},
`${s.kind === 'schema' ? '表结构快照' : '查询结果'} ${s.alias || short(s.artifact)} · ${s.sealed ? '已封存' : '未封存'}${s.hash_ok === false ? ' · 哈希核对失败' : ''}${s.present === false ? ` · 名字${T.absent}` : ''}`))))
}
if (Array.isArray(step.fields) && step.fields.length) {
body.push(h('p', { class: 'sub' }, T.dFields),
h('p', { style: 'margin:4px 0 0;font-size:13px' },
step.fields.map((f) => `${f.name}${f.type ? `(${f.type})` : ''}`).join('、')
+ (step.fields_more ? T.dFieldsMore(step.fields_more) : '')))
}
if (step.checked && (step.checked.schemas || step.checked.queries)) {
body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' },
`${T.dChecked}:表结构快照 ${step.checked.schemas} 份、查询 ${step.checked.queries} 次,都没有这个名字`))
}
if (Array.isArray(step.closest) && step.closest.length) {
body.push(h('p', { class: 'sub' }, T.dClosest),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.closest.map((c) => h('li', {}, `${c.name}${c.table ? `(表 ${c.table})` : ''}`))))
}
return h('section', { class: 'part', 'data-step': 'entity' }, h('h3', {}, `${T.entity}「${step.name || NONE}」`),
h('div', { class: 'part-body' }, ...body))
}
/** 引文步骤:原话在哪次检索的哪条命中里,带上原文好高亮 */
function quotePart(step) {
const body = []
const rows = [
[T.dContent, String(step.text || NONE)],
step.collection ? [T.dCollection, String(step.collection)] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.content === 'string' && step.content) {
const match = isObj(step.match) ? step.match : {}
const start = Number.isInteger(match.start) ? match.start : null
const end = Number.isInteger(match.end) ? match.end : null
const frag = document.createDocumentFragment()
if (start != null && end != null && 0 <= start && start <= end && end <= step.content.length) {
frag.append(step.content.slice(0, start))
frag.append(h('mark', { class: 'hit', style: 'padding:0 1px;border-radius:3px' }, step.content.slice(start, end)))
frag.append(step.content.slice(end))
} else {
frag.append(step.content)
}
body.push(h('p', { class: 'sub' }, T.dContent), h('blockquote', { style: 'margin:4px 0 0' }, frag))
}
body.push(kv([[T.dMatch, step.match_ok === true ? T.matchOk : step.match_ok === false ? T.matchBad : T.matchNone]]))
if (step.note) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.note)))
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'quote' }, h('h3', {}, `${T.quote}「${String(step.text || '').slice(0, 24)}…」`),
h('div', { class: 'part-body' }, ...body))
}
/** 完整性徽标:已封存、哈希核对失败(每一步都可带) */
function integrity(step) {
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
return bits.length ? h('div', { class: 'chips' }, ...bits) : null
}
const muted = (text) => h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(text))
const waiting = (text) => h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(text))
/** 一张小表:表头 heads,行 rows(每行一组格),高亮的格(行号:列 id)和列 id 对上 */
function smallTable(heads, ids, rows, index, hits) {
const hit = new Set((hits || []).map((c) => `${c[0]}:${c[1]}`))
return h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, heads.map((c) => h('th', {}, String(c))))),
h('tbody', {}, rows.map((row, i) => h('tr', {}, (Array.isArray(row) ? row : ids.map((c) => own(row, c))).map((v, j) => h('td', {
class: [hit.has(`${index[i]}:${ids[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : ''].filter(Boolean).join(' ') || null,
}, v == null ? NONE : textOf(v))))))))
}
/** 分析结论 · 判定:判定语、判定标准、判定码和点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、
* 「在验证什么」(谁写的照实标:Agent 写的假设 / 分析卡上写的『在验证什么』) */
function analysisPart(step) {
const body = []
const verdict = isObj(step.verdict) ? step.verdict : null
const cited = isObj(step.cited) ? step.cited : {}
const citedText = cited.what === 'value' ? T.citedValue(cited.key) : cited.what === 'cell'
// 结果表的格写列名(服务端照结果表记的 labels 给),不把列 id 当叫法;老接口没给列名的照旧写 id
? T.citedCell(Number(cited.row) || 0, String(cited.column_label || cited.column || '')) : T.citedPhrase
body.push(kv([
[T.dPhrase, String(step.phrase || NONE)],
step.criterion ? [T.dCriterion, String(step.criterion)] : null,
verdict && Array.isArray(verdict.subjects) && verdict.subjects.length
? [T.dSubjects, verdict.subjects.map((s) => (Array.isArray(s) ? s.join(' · ') : String(s))).join('、')] : null,
verdict && verdict.direction ? [T.dDirection, T.directionLabel[verdict.direction] || String(verdict.direction)] : null,
[T.dCitedPart, citedText],
[T.dWho, `${own(T.originLabel, step.origin) || NONE}`
+ `${step.node_id ? ` · ${step.label || nodeLabel(step.node_id)}` : ''}`],
]))
if (step.status === 'refused') body.push(waiting(`${T.refused}${step.reason ? `:${step.reason}` : ''}`))
if (Array.isArray(step.caveats) && step.caveats.length) {
body.push(h('p', { class: 'sub' }, T.dCaveats),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' }, step.caveats.map((c) => h('li', {}, String(c.text || '')))))
}
if (step.hypothesis) {
// 「在验证什么」:谁写的服务端照实给(Agent 写的假设 / 分析卡上写的『在验证什么』),这里照原样显示
body.push(h('p', { class: 'sub' }, String(step.hypothesis_label || T.dHypothesis)),
h('p', { style: 'margin:4px 0 0;font-size:13px' }, String(step.hypothesis)))
if (step.hypothesis_note) body.push(muted(step.hypothesis_note))
}
if (step.note) body.push(waiting(step.note))
if (typeof step.render_ok === 'boolean') body.push(muted(step.render_ok ? T.renderOk : T.renderBad))
return h('section', { class: 'part', 'data-step': 'analysis' },
h('h3', {}, `${[T.analysis, step.alias].filter(Boolean).join(' ')} · ${step.method_label || NONE}`),
h('div', { class: 'part-body' }, ...body))
}
/** 现查结果的一条:有 expr 的是按运行当天现算的界(CURRENT_DATE 这类在库里算出的日期),其余是一列的实际范围;现查到的
* 范围最早、最晚都空,是这一列一个值都没有 */
function probeText(p) {
const how = `(${own(T.probeBasis, p.basis) || p.basis || NONE}${p.at ? ` · ${p.at}` : ''})`
if (typeof p.expr === 'string' && p.expr) return `${T.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 ? T.probeEmpty : T.probeRange(p.min || NONE, p.max || NONE)}${how}`
}
/** 分析结论 · 方法:方法与版本、参数、实际用的阈值、现查结果 */
function analysisMethodPart(step) {
const body = [kv([[T.dMethod, `${step.method_label || NONE} · ${step.method || NONE}`]])]
if (isObj(step.params) && Object.keys(step.params).length) {
body.push(h('p', { class: 'sub' }, T.dParams), h('pre', { class: 'sql' }, JSON.stringify(step.params, null, 2)))
}
if (Array.isArray(step.settings) && step.settings.length) {
body.push(h('p', { class: 'sub' }, T.dSettings), kv(step.settings.map((x) => [String(x.label || x.key), textOf(x.value)])))
}
if (Array.isArray(step.probes) && step.probes.length) {
body.push(h('p', { class: 'sub' }, T.dProbes), h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.probes.filter(isObj).map((p) => h('li', {}, probeText(p)))))
}
return h('section', { class: 'part', 'data-step': 'analysis_method' }, h('h3', {}, T.method), h('div', { class: 'part-body' }, ...body))
}
/** 分析结论 · 计算:关键数的代入式、具名的数、结果表(引用的格高亮) */
function analysisCalcPart(step) {
const body = []
if (Array.isArray(step.formulas) && step.formulas.length) {
body.push(h('p', { class: 'sub' }, T.dFormulas), h('pre', { class: 'sql' }, step.formulas.map((f) => String(f.text || '')).join('\n')))
}
if (Array.isArray(step.values) && step.values.length) {
body.push(h('p', { class: 'sub' }, T.dValues), kv(step.values.map((v) => [String(v.key),
h('span', { class: v.cited ? 'hit' : null }, String(v.rendered ?? NONE))])))
}
const table = isObj(step.table) ? step.table : null
if (table && Array.isArray(table.columns) && Array.isArray(table.rows)) {
const labels = isObj(table.labels) ? table.labels : {}
const index = Array.isArray(table.row_index) ? table.row_index : table.rows.map((_, i) => i)
body.push(h('p', { class: 'sub' }, `${T.dResultTable}(${T.resultRows(table.rows.length, table.total_rows ?? table.rows.length)})`),
smallTable(table.columns.map((c) => labels[c] || c), table.columns, table.rows.map((r) => (Array.isArray(r)
? r.map((v, j) => ((table.masked || []).includes(table.columns[j]) ? MASKED : v)) : r)), index,
isObj(table.highlight) ? table.highlight.cells : []))
if (table.window_truncated) body.push(muted(T.windowCut))
}
return h('section', { class: 'part', 'data-step': 'analysis_calc' }, h('h3', {}, T.calc), h('div', { class: 'part-body' }, ...body))
}
/** 分析结论 · 输入:角色、落到哪个 Q、用到的列和行、总数(比率拆解是整体比率)那一格和闭合核对、下钻的上一层 */
function analysisInputPart(step) {
const rows = Array.isArray(step.rows) ? step.rows.map((r) => `第 ${Number(r) + 1} 行`).join('、') : step.rows === 'all' ? T.allRows : null
const closure = isObj(step.closure) ? step.closure : null
const ratio = closure !== null && closure.mode === 'ratio'
const closed = closure && (ratio ? (closure.closed ? T.ratioClosureOk : T.ratioClosureBad)
: (closure.closed ? T.closureOk : T.closureBad))
const body = [kv([
[T.dRole, String(step.role_label || step.role || NONE)],
step.ref ? [T.dRef, h('code', {}, String(step.ref))] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.alias ? [T.dParent, String(step.alias)] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
Array.isArray(step.columns) && step.columns.length ? [T.dColumnsUsed, step.columns.join('、')] : null,
rows ? [T.dRowsUsed, rows] : null,
'value' in step ? [ratio ? T.dHeadlineRatio : T.dHeadline, `${step.column ? `第 ${Number(step.row) + 1} 行「${step.column}」 = ` : ''}${step.value_rendered ?? valueText(step.value)}`] : null,
closure ? [T.dClosure, closed(closure.headline, closure.total)] : null,
step.phrase ? [T.dPhrase, String(step.phrase)] : null,
])]
const bits = integrity(step)
if (bits) body.push(bits)
return h('section', { class: 'part', 'data-step': 'analysis_input' },
h('h3', {}, `${T.analysisInput}${step.query || step.alias ? ` ${step.query || step.alias}` : ''}`), h('div', { class: 'part-body' }, ...body))
}
/** 分析结论 · 复算:按封存的输入重算、和封存的比(服务端算好的那一句) */
function analysisRecomputePart(step) {
const kind = step.status === 'same' ? 'ok' : step.status === 'different' ? 'bad' : 'na'
return h('section', { class: 'part', 'data-step': 'analysis_recompute' }, h('h3', {}, T.recompute),
h('div', { class: 'part-body' }, h('div', { class: 'chips' }, badge(kind, String(step.text || NONE)))))
}
/** 分析结论 · 封存:台账记没记过、哈希核对 */
function analysisSealPart(step) {
const body = [kv([[T.dArtifact, step.artifact ? h('code', {}, short(step.artifact)) : NONE],
[T.seal, step.via === 'metric_set' ? T.viaCard : step.ledger ? T.inLedger : T.notInLedger]])]
if (step.note) body.push(waiting(step.note))
const bits = integrity(step)
if (bits) body.push(bits)
return h('section', { class: 'part', 'data-step': 'analysis_seal' }, h('h3', {}, T.seal), h('div', { class: 'part-body' }, ...body))
}
const PARTS = {
metric: metricPart, input: inputPart, query: queryPart, run_input: runInputPart,
entity: entityPart, quote: quotePart,
analysis: analysisPart, analysis_method: analysisMethodPart, analysis_calc: analysisCalcPart,
analysis_input: analysisInputPart, analysis_recompute: analysisRecomputePart, analysis_seal: analysisSealPart,
}
/** 口径一步(口径账本给的标签,服务端已按遮罩给):来自口径的写明哪一版、级别、怎么认的、参数;相关口径写差在哪;
* 这次的推理、没比对照实说。比对在封存之后,底下写明不在封存范围内(note),事后补比的另写按现在的口径回看 */
function ledgerPart(label, ledger) {
const body = []
const nameOf = (x) => x.workflow_name || T.unnamed
const bindingText = (items) => (Array.isArray(items) && items.length
? items.map((b) => `${b.field} = ${b.masked ? MASKED : valueText(b.value)}`).join(';') : NONE)
if (label.kind === 'caliber') {
body.push(h('p', {}, T.ledgerFrom(nameOf(label), label.version, label.level_text || '')))
body.push(kv([
[T.dHow, T.ledgerHow[label.how] || String(label.how || NONE)],
[T.dBinding, bindingText(label.binding)],
label.column ? [T.dColumnOf, String(label.column)] : null,
]))
} else if (label.kind === 'related') {
body.push(h('p', {}, T.ledgerRelated))
for (const item of Array.isArray(label.related) ? label.related : []) {
const lines = (((item.diff || {}).items) || []).map((d) => h('li', {}, String(d.text || '')))
body.push(h('p', { class: 'sub' }, T.ledgerFrom(nameOf(item), item.version, item.level_text || '')))
if (item.reason) body.push(h('p', { class: 'muted' }, String(item.reason)))
if (lines.length) body.push(h('p', { class: 'sub' }, T.dDiffers), h('ul', {}, lines))
}
if (label.reason) body.push(h('p', { class: 'muted' }, String(label.reason)))
} else if (label.kind === 'reasoning') {
body.push(h('p', {}, T.ledgerReasoning))
} else {
body.push(h('p', {}, T.ledgerUnknown + (label.reason ? `:${label.reason}` : '')))
}
const notes = [ledger.note, ledger.retro ? ledger.retro_note : null].filter(Boolean).join(' · ')
if (notes) body.push(h('p', { class: 'muted' }, notes))
return h('section', { class: 'part', 'data-step': 'ledger' }, h('h3', {}, T.ledger), h('div', { class: 'part-body' }, ...body))
}
// ------------------------------------------------------------------ 面板
function detailOf(seg, chain, note) {
const kind = T.kindLabel[seg.dataset.kind] || '片段'
const state = seg.dataset.state || ''
const head = h('section', { class: 'part' }, h('h3', {}, `${kind}「${seg.textContent}」`),
h('div', { class: 'part-body' }, kv([
[T.dStatus, T.stateLabel[state] || state || NONE],
[T.dNode, nodeLabel((view.data.meta.report || {}).node_id)],
])))
const parts = [head]
const ledger = isObj(view.data.ledger) ? view.data.ledger : null
const label = ledger ? own(ledger.labels, seg.dataset.seg) : undefined
if (isObj(label)) parts.push(ledgerPart(label, ledger))
if (chain && chain.length) {
for (const step of chain) {
const render = PARTS[step.step]
if (render) parts.push(render(step))
}
} else {
parts.push(h('p', { class: 'muted' }, T.noChain))
}
if (note) parts.push(h('section', { class: 'part' }, h('h3', {}, T.note), h('div', { class: 'part-body' }, note)))
return parts
}
function openSeg(seg) {
const panel = $('detail')
if (panel.hidden) view.back = document.activeElement
if (view.active) view.active.classList.remove('is-active')
view.active = seg
seg.classList.add('is-active')
const chain = view.data ? (view.data.chains || {})[(view.data.refs || {})[seg.dataset.seg]] : undefined
const note = view.data ? (view.data.notes || {})[seg.dataset.seg] : undefined
$('detail-title').textContent = `${T.segTitle(T.kindLabel[seg.dataset.kind] || '片段', seg.textContent)}`
$('detail-body').replaceChildren(...detailOf(seg, Array.isArray(chain) ? chain : null, note))
panel.hidden = false
panel.setAttribute('data-open', seg.dataset.seg)
document.documentElement.classList.add('detail-open')
$('detail-close').focus({ preventScroll: true })
const sheet = panel.getBoundingClientRect()
const spot = seg.getBoundingClientRect()
if (sheet.top > 0) {
if (spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
} else {
seg.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
}
function closeDetail() {
const panel = $('detail')
if (panel.hidden) return
panel.hidden = true
panel.removeAttribute('data-open')
document.documentElement.classList.remove('detail-open')
if (view.active) view.active.classList.remove('is-active')
if (view.back && typeof view.back.focus === 'function') view.back.focus()
view.active = null
}
// ------------------------------------------------------------------ 启动
function main() {
const data = parseData()
if (data) {
view.data = data
renderHead(data)
}
const body = $('report')
body.addEventListener('click', (e) => {
const seg = e.target.closest('button[data-seg]')
if (seg) openSeg(seg)
})
$('detail-close').addEventListener('click', closeDetail)
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail() })
document.documentElement.setAttribute('data-js', '')
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', main, { once: true })
else main()
})()