diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 5deb102d..5e6692a0 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -1015,8 +1015,12 @@ async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed, # 分析结论的出处链 # -------------------------------------------------------------------------- -#: Agent 写的假设(hypothesis)旁边那句:它不是结论,只在证据面板显示(细案 01 §7.3) -HYPOTHESIS_NOTE = "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" +#: 「在验证什么」(hypothesis)旁边那句:它不是结论,只在证据面板显示(细案 01 §7.3)。谁写的照实说:Agent 现场做的 +#: (台账条目带 call_id)写「Agent 写的假设」,分析卡做的(带 item)是模板作者写的,写「分析卡上写的『在验证什么』」 +HYPOTHESIS_LABEL = "Agent 写的假设" +HYPOTHESIS_NOTE = f"{HYPOTHESIS_LABEL}:说明这次在验证什么,不是分析结论,也不进报告正文" +CARD_HYPOTHESIS_LABEL = "分析卡上写的『在验证什么』" +CARD_HYPOTHESIS_NOTE = f"{CARD_HYPOTHESIS_LABEL}:模板作者写的那一句,不是分析结论,也不进报告正文" UNSEALED_ANALYSIS = "这份分析结论不属于封存范围内的任何事件(封存前的台账里没有它),不能作为证据展示" TAMPERED_ANALYSIS = "分析结论与哈希不一致,疑似被修改,不展示其中的判定和数" MISSING_ANALYSIS = "无法读取分析结论" @@ -1063,7 +1067,10 @@ def _catalog_alias_of(catalog: dict[str, Any], artifact: Any) -> str | None: def _analysis_cited(cite: dict[str, Any], result: dict[str, Any] | None) -> dict[str, Any]: - """这一处引用的是结论的哪一项:判定语(phrase)、具名标量(value:键、原值、换算)、结果表的一格(cell:行、列、原值)。""" + """这一处引用的是结论的哪一项:判定语(phrase)、具名标量(value:键、原值、换算)、结果表的一格(cell:行、列、原值)。 + + 结果表的列写给人看:column 是稳定的列 id(share、delta……,引用里就是这么写的),column_label 是结果表记的列名 + (「占变化的比重」);页面照原样显示 column_label,不把列 id 当叫法。""" locator = cite.get("locator") or {} if cite.get("kind") == "cell": row, column = locator.get("row"), locator.get("column") @@ -1072,7 +1079,9 @@ def _analysis_cited(cite: dict[str, Any], result: dict[str, Any] | None) -> dict if table is not None: with contextlib.suppress(Exception): raw, column = locate_cell(table, row, column) - return {"what": "cell", "row": row, "column": column, "raw": raw} + labels = (table or {}).get("labels") if isinstance((table or {}).get("labels"), dict) else {} + return {"what": "cell", "row": row, "column": column, + "column_label": str(labels.get(column) or column) if column is not None else None, "raw": raw} if locator.get("key"): values = (result or {}).get("values") if isinstance((result or {}).get("values"), dict) else {} return {"what": "value", "key": locator["key"], "raw": values.get(locator["key"]), @@ -1104,11 +1113,13 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict """分析结论的出处链(00-总方案 §3.3):判定 → 方法 → 计算 → 输入 → 复算 → 封存。 - 判定(analysis):判定语、判定标准、判定码和点名的段、附带说明、算不了的原因,这一处引用的是哪一项(判定语、具名标量、 - 结果表的一格);Agent 写的假设另列,标成「Agent 写的假设」(HYPOTHESIS_NOTE) + 结果表的一格,列另写结果表记的列名 column_label);「在验证什么」另列,谁写的照实标:Agent 现场做的写「Agent 写的假设」 + (HYPOTHESIS_NOTE),分析卡做的写「分析卡上写的『在验证什么』」(CARD_HYPOTHESIS_NOTE,模板作者写的) - 方法(analysis_method):方法与版本、规范化后的参数、实际用的阈值(只列这个算子用到的)、现查的表范围(probes) - 计算(analysis_calc):结果表(前 table_top_k 段,加上被引用的那一行附近)、具名的数、关键数的代入式 (evidence.analysis_formulas) - - 输入(analysis_input,每份一步):落到报告目录的哪个 Q、用了哪些列和行、headline 那一格和结论里总数的闭合核对、 + - 输入(analysis_input,每份一步):落到报告目录的哪个 Q、用了哪些列和行、headline 那一格(值另由服务端渲染成 value_rendered, + 和闭合核对同一个写法)和结论里总数的闭合核对、 下钻的上一层是哪个 A;后面接上每份输入快照的查询步骤(只认封存范围内交回过的,遮罩照查询步骤的办法) - 复算(analysis_recompute):按 sha256 取回封存的输入、用同一个函数复算,和封存的逐字段比(evidence.analysis_recheck); 方法升过版、复算不了都照实写 @@ -1154,7 +1165,11 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict phrase=result.get("phrase"), criterion=result.get("criterion"), verdict=verdict, caveats=[{k: c.get(k) for k in ("code", "text")} for c in result.get("caveats") or [] if isinstance(c, dict)]) if isinstance(result.get("hypothesis"), str) and result["hypothesis"].strip(): - judged.update(hypothesis=result["hypothesis"], hypothesis_note=HYPOTHESIS_NOTE) + # 谁写的照实说:分析卡(台账条目带 item)是模板作者写的,Agent 工具(带 call_id)是现场写的 + card = judged["origin"] == "card" + judged.update(hypothesis=result["hypothesis"], + hypothesis_label=CARD_HYPOTHESIS_LABEL if card else HYPOTHESIS_LABEL, + hypothesis_note=CARD_HYPOTHESIS_NOTE if card else HYPOTHESIS_NOTE) settings = result.get("settings") if isinstance(result.get("settings"), dict) else {} method = { "step": "analysis_method", "alias": alias, "method": result.get("method"), @@ -1235,6 +1250,10 @@ async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str "role_label": roles.get(role, role or "输入"), "artifact": artifact or None, **{k: item[k] for k in ("columns", "rows", "row", "column", "value") if k in item}, } + # 那一格的值由服务端渲染(render_cell:千分位、NULL 写「—」,和闭合核对里的字同一个写法),页面照原样显示 + if "value" in item: + with contextlib.suppress(RenderError): + step["value_rendered"] = render_cell(item.get("value")) if role == "parent": step["alias"] = _catalog_alias_of(catalog, artifact) parent, ok = _analysis_of(artifact) diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index a0ad030a..0a24e27b 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -259,7 +259,7 @@ dCell: '快照里的值', dShown: '正文显示', dShownSame: '与按显示规则渲染的结果一致', dExpected: '按显示规则应为', dArtifact: '所在工件', dReport: '所在报告', dPhrase: '判定语', dCriterion: '判定标准', dMethod: '方法与版本', dAnalysis: '分析结论里的值', dRecheck: '复算', - dAnalysisInputs: '输入', dHypothesis: 'Agent 写的假设', dCaveats: '附带说明', + dAnalysisInputs: '输入', dHypothesis: 'Agent 写的假设', dCardHypothesis: '分析卡上写的『在验证什么』', dCaveats: '附带说明', recheckBy: '导出时服务端按封存的输入复算的结论;本页只核哈希,不在浏览器里重算统计', recheckNone: '导出时没有复算(结论未随文件附带)', analysisInput: (role, alias) => `${role}${alias ? ` · ${alias}` : ''}`, @@ -1212,7 +1212,7 @@ async function verify(data, digest, shownSeal) { const out = { events: [], manifest: [], artifacts: [], chain: [], numbers: [], docs: [], meta: [], checked: new Map(), order: [], - arts: new Map(), queryAliases: new Map(), + arts: new Map(), queryAliases: new Map(), analysisEntries: new Map(), } const events = Array.isArray(data.events) ? data.events.filter(isObj) : [] const meta = data.meta @@ -1373,6 +1373,15 @@ for (const [id, refs] of byDoc) { if (refs.length && !out.queryAliases.has(id)) out.queryAliases.set(id, refs[0].alias) } + // 工件 → 报告目录里这条分析结论的条目(item、call_id):谁做的那一行、「在验证什么」的说法照它认 + out.analysisEntries = new Map() + for (const [id, refs] of byDoc) { + for (const ref of refs) { + const doc = trusted.get(ref.report) + const entry = own(isObj(doc) ? doc.catalog : null, ref.alias) + if (isObj(entry) && entry.kind === 'analysis' && !out.analysisEntries.has(id)) out.analysisEntries.set(id, entry) + } + } out.arts = arts out.chained = chained // 已核对的口径卡记着的输入 → 输入指向的查询快照。卡本身核过哈希(③ 通过),它记的引用和文档的证据目录 @@ -1452,6 +1461,7 @@ const loc = isObj(cite.locator) ? cite.locator : {} if (cite.kind === 'metric') return T.srcMetric(own(entry, 'name') || loc.metric || cite.ref || '') if (cite.kind === 'cell' && own(entry, 'kind') === 'analysis' && Number.isInteger(loc.row)) { + // 结果表的格写列名:核对到结论之后换成结论记的列名(checkAnalysis 的 info.source),这里先照引用里的列 id 说 return T.srcAnalysisCell(cite.alias, loc.row, String(loc.column ?? '')) } if (cite.kind === 'cell') { @@ -1564,6 +1574,9 @@ const [raw, column, key] = locateCell(table, loc.row, loc.column) info.row = loc.row info.column = column + // 出处面板写结果表记的列名(labels),不把列 id 当叫法;目录条目和文档引用里带的那份先认,没有再查这份结论 + info.columnLabel = analysisColumnLabel(table, column) + info.source = T.srcAnalysisCell(cite.alias, loc.row, info.columnLabel) info.truth = cellValue(raw, columnKind(table, column)) info.truthToken = tokenAt(tokensOf(art), ['table', 'rows', loc.row, key]) if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw) @@ -1591,6 +1604,14 @@ return checkShown(info, seg, () => renderCell(result.phrase, null, null, 'text', 1)) } + /** 分析结论结果表的一格写给人看的列名:结论结果表记的 labels(「占变化的比重」),没起名字的列才写列 id。 + * 文档里的引用只记列 id(引用就这么写),列名照随文件附带的结论认 */ + function analysisColumnLabel(table, column) { + const id = String(column ?? '') + const labels = isObj(table.labels) ? table.labels : {} + return own(labels, id) ? String(labels[id]) : id + } + /** 口径卡的单位:服务端是 str(unit or ""),不是文字的本页不认 */ function unitOf(unit) { if (!unit) return '' @@ -2032,12 +2053,17 @@ return parts } - /** 数字详情里分析结论一节:判定语、判定标准、方法与版本、附带说明、Agent 写的假设、输入(各自的核对状态)、导出时服务端 - * 的复算结论。结论没随文件附带(遮罩、文件不在)时只写复算一句 */ + /** 数字详情里分析结论一节:判定语、判定标准、方法与版本、附带说明、「在验证什么」(谁写的照实标:Agent 写的假设 / + * 分析卡上写的『在验证什么』,照报告目录里这条结论的条目认)、输入(各自的核对状态)、导出时服务端的复算结论。 + * 结论没随文件附带(遮罩、文件不在)时只写复算一句 */ function analysisBlock(number) { const result = isObj(number.analysis) ? number.analysis : null const art = view.out.artifacts.find((a) => a.a.id === number.artifact) const recheck = art && isObj(art.a.recheck) ? art.a.recheck : null + // 谁做的:报告目录里这条结论的条目(带 item 的是分析卡做的,带 call_id 的是 Agent 现场做的)。「在验证什么」的说法照它认, + // 不把模板作者写的那一句标成 Agent 写的假设 + const entry = view.out.analysisEntries.get(number.artifact) + const card = isObj(entry) ? !!entry.item && !entry.call_id : false const parts = [] if (result) { parts.push(kv([ @@ -2048,7 +2074,7 @@ const caveats = (Array.isArray(result.caveats) ? result.caveats : []).filter(isObj).map((c) => String(c.text || '')) if (caveats.length) parts.push(h('p', { class: 'sub' }, T.dCaveats), h('ul', { class: 'items' }, caveats.map((c) => h('li', {}, c)))) if (typeof result.hypothesis === 'string' && result.hypothesis.trim()) { - parts.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', {}, result.hypothesis)) + parts.push(h('p', { class: 'sub' }, card ? T.dCardHypothesis : T.dHypothesis), h('p', {}, result.hypothesis)) } const inputs = (Array.isArray(result.inputs) ? result.inputs : []).filter((i) => isObj(i) && typeof i.artifact === 'string') if (inputs.length) { diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index e73eeba0..6fd87d63 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -1030,7 +1030,8 @@ _ANALYSIS_KEYS = ("node_id", "exec", "artifact", "call_id", "item", "op", "mode" def _analysis_entry(entry: dict[str, Any], alias: str, snaps: _Snapshots) -> dict[str, Any]: """台账里的一条分析结论 → 写作目录的 A 条目:照抄台账的键,带上从工件取回、复验过哈希的整份结论(result, analysis.contract.catalog_result 读它)。取不回、哈希不符、形状不对的不带 result,记 unreadable(以编号开头的一句,引用 - 它时照实说)。rows 是结果表的行数:依据 [[see:A1.r3]] 按它核对,存进报告文档的摘要里也留着。""" + 它时照实说)。rows 是结果表的行数:依据 [[see:A1.r3]] 按它核对,存进报告文档的摘要里也留着;labels 是结果表记的列名 + (列 id → 给人看的名字):证据面板、读屏的出处写「结果表第 1 行「占变化的比重」」,不把列 id 当叫法。""" out: dict[str, Any] = {"alias": alias, "kind": LEDGER_KIND, "eid": make_eid(LEDGER_KIND, entry.get("artifact"), {}), "locator": {}, **{k: entry[k] for k in _ANALYSIS_KEYS if k in entry}} result, why = snaps.analysis(alias, {"artifact": entry.get("artifact")}) @@ -1039,8 +1040,11 @@ def _analysis_entry(entry: dict[str, Any], alias: str, snaps: _Snapshots) -> dic else: out["result"] = result table = result.get("table") - if isinstance(table, dict) and isinstance(table.get("rows"), list): - out["rows"] = len(table["rows"]) + if isinstance(table, dict): + if isinstance(table.get("rows"), list): + out["rows"] = len(table["rows"]) + if isinstance(table.get("labels"), dict) and table["labels"]: + out["labels"] = {str(k): str(v) for k, v in table["labels"].items()} phrase = _clean(str((result or {}).get("phrase") or "")) name = method_label(out.get("op"), out.get("mode")) out["label"] = f"{name} · {phrase}" if phrase else name diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 11892067..eefdf394 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -462,9 +462,10 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: agent=name, call_id=cid, preview=content[:1500], duration_ms=int((time.perf_counter() - started) * 1000), **failure, **({"checks": checks} if checks else {}), - # 判定的那一小段同 Agent 节点(event_view);编号是成员自己的分析容器里的,紧接着 _numbered 按它编 + # 判定的那一小段同 Agent 节点(event_view)。不带编号:成员自己的分析容器里的 A1 不是报告目录里的 + # A1(协作成员的结论还进不了台账),时间线上照它写「· A1」会和报告里点名的结论对不上 **({"analysis_artifact": analysis["artifact"], - "analysis": event_view(analysis["result"], scope.next_alias())} if analysis else {})) + "analysis": event_view(analysis["result"])} if analysis else {})) if analysis is not None and analysis["result"].get("status") == "refused": ctx.emit(EventType.LOG, level="warn", code=ANALYSIS_REFUSED, agent=name, call_id=cid, tool=tname, reason_code=analysis["result"].get("reason_code"), diff --git a/backend/app/report_export/runtime.js b/backend/app/report_export/runtime.js index 1d574e44..829b4c5f 100644 --- a/backend/app/report_export/runtime.js +++ b/backend/app/report_export/runtime.js @@ -461,13 +461,15 @@ }, v == null ? NONE : textOf(v)))))))) } - /** 分析结论 · 判定:判定语、判定标准、判定码和点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、Agent 写的假设 */ + /** 分析结论 · 判定:判定语、判定标准、判定码和点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、 + * 「在验证什么」(谁写的照实标: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' - ? T.citedCell(Number(cited.row) || 0, String(cited.column ?? '')) : T.citedPhrase + // 结果表的格写列名(服务端照结果表记的 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, @@ -484,7 +486,9 @@ 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) { - body.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', { style: 'margin:4px 0 0;font-size:13px' }, String(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)) @@ -558,7 +562,7 @@ 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}」 = ` : ''}${valueText(step.value)}`] : 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, ])] diff --git a/backend/tests/test_analysis_bundle.py b/backend/tests/test_analysis_bundle.py index 6572040a..f8794167 100644 --- a/backend/tests/test_analysis_bundle.py +++ b/backend/tests/test_analysis_bundle.py @@ -100,6 +100,7 @@ def verdicts(bundle: dict[str, Any]) -> dict[str, Any]: def test_the_analysis_and_its_input_travel_with_the_file_and_verify(): + """Agent 现场做的结论(台账条目带 call_id):那一句「在验证什么」标成「Agent 写的假设」。""" bundle, files, a = bundle_of() analysis_item = item(bundle, a) assert analysis_item["kind"] == ARTIFACT_KIND and "b64" in analysis_item and analysis_item["node_id"] == "an" diff --git a/backend/tests/test_analysis_evidence.py b/backend/tests/test_analysis_evidence.py index 150fdf1a..04ef45f7 100644 --- a/backend/tests/test_analysis_evidence.py +++ b/backend/tests/test_analysis_evidence.py @@ -330,6 +330,8 @@ def test_the_doc_keeps_a_summary_and_reloads_the_result_from_the_artifact(): doc = compose_doc("[[a:A1]],华东占 [[a:A1.top_share|pct]]。", catalog, loader=store.load) stored = doc["catalog"]["A1"] assert "result" not in stored and stored["artifact"] and stored["rows"] == 4 and stored["verdict"] == "driver" + # 结果表记的列名(列 id → 给人看的名字)跟着摘要走:证据面板、读屏的出处写「结果表第 1 行「变化」」 + assert stored["labels"]["delta"] == "变化" # 文档里的摘要照样解析得了:结论按工件取回 assert resolve_ref("a:A1", doc["catalog"], loader=store.load)["status"] == "resolved" assert verify_doc(doc, doc["catalog"], loader=store.load)["ok"] diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py index 87e403da..322c3801 100644 --- a/backend/tests/test_analysis_evidence_api.py +++ b/backend/tests/test_analysis_evidence_api.py @@ -5,7 +5,8 @@ 真的 recompute:按 sha256 取回封存的输入快照、下钻的上一层结论,逐字段比。只有要它出错、升版的几处用 monkeypatch。 - 证据图:A1 条目带算子、判定;「派生自」的边 A1 → Q1 -- 片段接口:判定 → 方法 → 计算 → 输入(接查询步骤)→ 复算 → 封存;Agent 写的假设标出来;复算不了照实写,不让接口出错 +- 片段接口:判定 → 方法 → 计算 → 输入(接查询步骤)→ 复算 → 封存;「在验证什么」谁写的照实标(Agent 写的假设 / 分析卡上 + 写的『在验证什么』);复算不了照实写,不让接口出错 - 审计:分析判定、分析结论的叫法;分析说法核对的违规单列一行 - 报告导出:数据块里有分析结论那几步;文档带「分析范围」的,正文后面渲染、编号点得开 - 指标步骤带可比性:口径卡指标上的可比性结论(不进台账)照卡上记的 sha256 取回,点得开(判定 → … → 复算 → 封存) @@ -135,7 +136,9 @@ async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and assert judged["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"} assert judged["cited"] == {"what": "phrase"} and judged["render_ok"] is True and judged["eid_ok"] is True assert judged["origin"] == "card" and judged["item"] == "drop" and judged["label"] == "分析" - assert judged["hypothesis"] == "华东拖累" and judged["hypothesis_note"].startswith("Agent 写的假设") + # 分析卡做的:模板作者写的「在验证什么」,不标成 Agent 写的假设 + assert judged["hypothesis"] == "华东拖累" and judged["hypothesis_label"] == "分析卡上写的『在验证什么』" + assert judged["hypothesis_note"].startswith("分析卡上写的『在验证什么』") assert {c["code"] for c in judged["caveats"]} == {"measure_unlabeled", "headline_basis", "comparability"} assert method["method"] == "decompose.additive@1" and method["method_label"] == "变化拆解(可加)" assert {s["key"]: s["label"] for s in method["settings"]}["main_share"] == "「主要来源」的占比" @@ -173,7 +176,8 @@ async def test_named_values_and_result_cells_point_at_what_they_cite(client): cell = (await client.get( f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'v:A1.r0.delta')}")).json() - assert cell["chain"][0]["cited"] == {"what": "cell", "row": 0, "column": "delta", "raw": "-120"} + assert cell["chain"][0]["cited"] == {"what": "cell", "row": 0, "column": "delta", "column_label": "变化", + "raw": "-120"} assert cell["chain"][2]["table"]["highlight"] == {"cells": [[0, "delta"]]} assert cell["note"] == "该值由系统从分析结论的结果表中取出、按固定规则显示,未经模型转写" @@ -216,7 +220,9 @@ async def test_a_result_cell_rerenders_the_way_it_was_composed(client, monkeypat body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{segment['id']}")).json() judged = body["chain"][0] assert judged["rendered"] == shown and judged["render_ok"] is True - assert judged["cited"] == {"what": "cell", "row": 0, "column": by, "raw": snapshot["rows"][0][0]} + # column_label 是结果表记的列名:维度列照输入快照里的列名(store、slot),不另起叫法 + assert judged["cited"] == {"what": "cell", "row": 0, "column": by, "column_label": by, + "raw": snapshot["rows"][0][0]} # 导出页的数据块是同一条链 data = data_block((await client.get(f"/api/runs/{made['run']}/report.html")).text) chain = data["chains"][data["refs"][segment["id"]]] @@ -307,9 +313,12 @@ def test_suspicious_name_hints_do_not_read_the_analysis_results(monkeypatch): async def test_agent_analyses_are_marked_as_such(client): + """Agent 现场做的:谁做的写 Agent,「在验证什么」照旧标「Agent 写的假设」(分析卡上那一句是模板作者写的,另说)""" made = await sealed_run(TEXT, item=None, call_id="call-9") body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() - assert body["chain"][0]["origin"] == "agent" and body["chain"][0]["call_id"] == "call-9" + judged = body["chain"][0] + assert judged["origin"] == "agent" and judged["call_id"] == "call-9" + assert judged["hypothesis_label"] == "Agent 写的假设" and judged["hypothesis_note"].startswith("Agent 写的假设") async def test_headline_inputs_show_the_closure(client): @@ -329,6 +338,9 @@ async def test_headline_inputs_show_the_closure(client): assert inputs[1]["closure"] == {"mode": "additive", "total_key": "target_total", "total": "2,026", "headline": "2,026", "closed": True} assert [s["role_label"] for s in inputs[:2]] == ["基期总数", "本期总数"] + # 那一格的值服务端渲染好了(render_cell:千分位),和闭合核对里的字同一个写法;页面照原样显示 + assert inputs[0]["value"] == "2220" and inputs[0]["value_rendered"] == "2,220" + assert inputs[1]["value"] == "2026" and inputs[1]["value_rendered"] == "2,026" assert inputs[1]["query"] == "Q2" and inputs[1]["sealed"] is True and inputs[2]["query"] == "Q1" assert next(s for s in body["chain"] if s["step"] == "analysis_recompute")["status"] == "same" diff --git a/backend/tests/test_analysis_pages.py b/backend/tests/test_analysis_pages.py index 6d086b2f..f497873b 100644 --- a/backend/tests/test_analysis_pages.py +++ b/backend/tests/test_analysis_pages.py @@ -20,6 +20,8 @@ import app.bundle import app.report_export from app.main import app as asgi from tests.test_analysis_bundle import bundle_of, card_bundle +from app.core import artifact_store +from tests.test_analysis_evidence import SNAPSHOT, analyzed from tests.test_analysis_evidence_api import ( LAST_WEEK, SCOPE, TEXT, THIS_WEEK, comparability_card, ratio_run, sealed_run, seg_id, ) @@ -147,13 +149,15 @@ async def test_the_export_page_opens_the_analysis_steps_and_the_scope_numbers(): "analysis_recompute", "analysis_seal"] assert opened["title"].startswith("判定「销售额下降") body = opened["body"] - for text in ("判定标准一段占总变化的一半以上算主要来源", "Agent 写的假设华东拖累", "实际用的阈值", "「主要来源」的占比", + # 分析卡做的结论(台账条目带 item):那一句是模板作者写的,照实写「分析卡上写的『在验证什么』」,不标成 Agent 写的假设 + for text in ("判定标准一段占总变化的一半以上算主要来源", "分析卡上写的『在验证什么』华东拖累", "实际用的阈值", "「主要来源」的占比", "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%", "结果表(列出 4 行,共 4 行)", "复算一致:按封存的输入重新计算,结论逐项相同", "封存范围内的台账记过这份分析结论", "按运行当天现算的界 date('now', '-7 days') = 2026-10-14(现查 · 2026-10-21T09:00:00)", "orders.refund_date:这一列一个值都没有(现查", "orders.order_date:2025-01-01 至 2026-10-20(剖析"): assert text in body, text - assert "结果表第 1 行「delta」" in out[cell]["body"] + # 结果表的格写结果表记的列名(labels 的「变化」),不把列 id 当叫法 + assert "结果表第 1 行「变化」" in out[cell]["body"] assert out["scope:A1"]["steps"] == opened["steps"] @@ -168,12 +172,36 @@ async def test_the_export_page_speaks_of_the_overall_ratio_for_a_ratio_decomposi out = run_node(RUN_RUNTIME, RUNTIME, {"data": data, "segs": [ {"seg": phrase, "kind": "analysis", "state": "deterministic", "text": made["result"]["phrase"]}]}) body = out[phrase]["body"] - for text in ("基期整体比率", "整体比率那一格", "整体比率 0.035 与各段分子之和 ÷ 分母之和 0.035 闭合", + for text in ("基期整体比率", "整体比率那一格", "第 1 行「rate」 = 0.035", + "整体比率 0.035 与各段分子之和 ÷ 分母之和 0.035 闭合", "整体比率 0.053 与各段分子之和 ÷ 分母之和 0.053 闭合"): assert text in body, text assert "各段之和 0.0" not in body +async def test_the_export_page_writes_the_headline_cell_the_way_the_server_rendered_it(): + """总数那一格的值由服务端渲染(render_cell:千分位),页面照原样显示:以前照原值写「2220」,同一节里闭合核对写 + 「2,220」,一处引用里两个写法并排。""" + totals = {"columns": ["gmv_base", "gmv"], "column_types": {"gmv_base": "number", "gmv": "number"}, + "rows": [[2220, 2026]], "sql": "SELECT 2220 AS gmv_base, 2026 AS gmv", "source": "shop", + "truncated": False} + t = artifact_store.content_hash(artifact_store.canonical_json(totals)) + q = artifact_store.content_hash(artifact_store.canonical_json(SNAPSHOT)) + result = await analyzed({"Q2": (SNAPSHOT, q), "Q5": (totals, t)}, headline=["Q5.r0.gmv_base", "Q5.r0.gmv"]) + made = await sealed_run("[[a:A1]]。", result=result, queries=[totals]) + async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client: + html = (await client.get(f"/api/runs/{made['run']}/report.html")).text + data = re.search(r'', html, re.S).group(1) + phrase = seg_id(made["doc"], "a:A1") + out = run_node(RUN_RUNTIME, RUNTIME, {"data": data, "segs": [ + {"seg": phrase, "kind": "analysis", "state": "deterministic", "text": made["result"]["phrase"]}]}) + body = out[phrase]["body"] + for text in ("总数那一格第 1 行「gmv_base」 = 2,220", "总数那一格第 1 行「gmv」 = 2,026", + "总数 2,220 与各段之和 2,220 闭合"): + assert text in body, text + assert "= 2220" not in body and "= 2026" not in body + + async def test_the_bundle_page_shows_the_analysis_detail_and_the_scope(): bundle, _, _ = bundle_of(doc_extra=SCOPE) out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]}) @@ -186,6 +214,8 @@ async def test_the_bundle_page_shows_the_analysis_detail_and_the_scope(): # 方法写服务端带来的叫法和 id(和导出页同一个写法),不只写 id assert "判定语销售额下降 194(-8.7%)" in phrase and "方法与版本变化拆解(可加) · decompose.additive@1" in phrase assert "输入数据 · Q1" in phrase # 输入的角色写叫法,不写 data + # 这份结论是分析卡做的(台账条目带 item):那一句「在验证什么」写「分析卡上写的『在验证什么』」,不标成 Agent 写的假设 + assert "分析卡上写的『在验证什么』" in phrase and "Agent 写的假设" not in phrase assert "复算一致:按封存的输入重新计算,结论逐项相同(导出时服务端按封存的输入复算的结论;本页只核哈希" in phrase value = next(text for text in out["opened"].values() if "具名的数「top_share」" in text) assert "引用的值0.618556701031" in value and "正文显示61.9%(与按显示规则渲染的结果一致)" in value diff --git a/backend/tests/test_analysis_tool_events.py b/backend/tests/test_analysis_tool_events.py index f41a1237..720809d3 100644 --- a/backend/tests/test_analysis_tool_events.py +++ b/backend/tests/test_analysis_tool_events.py @@ -70,7 +70,7 @@ async def test_tool_end_carries_the_phrase_and_the_same_number_the_model_got(mon assert error["call_error"] == "unknown_column" and "analysis" not in error -async def test_a_team_member_numbers_its_own_analyses(monkeypatch, shop): +async def test_a_team_member_analysis_carries_no_number(monkeypatch, shop): Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)], route={"assignments": [{"agent": "分析员", "instruction": "拆一下销售额的变化"}], "done": False}) nodes = [node("start", "input"), @@ -82,7 +82,10 @@ async def test_a_team_member_numbers_its_own_analyses(monkeypatch, shop): input_payload={}) row = await wait(run.id) assert row.status == "succeeded", row.error - [end] = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")] - assert end["agent"] == "分析员" - assert (end["analysis"]["alias"], end["analysis"]["op"], end["analysis"]["verdict"]) == ("A1", "decompose", "driver") + ends = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")] + assert [e["agent"] for e in ends] == ["分析员"] + end = ends[0] + # 不带编号:成员自己的分析容器里的 A1 不是报告目录里的 A1(协作成员的结论还进不了台账),报告目录另按整次运行的台账编 + assert end["analysis"]["alias"] is None + assert (end["analysis"]["op"], end["analysis"]["verdict"]) == ("decompose", "driver") assert json.dumps(end["analysis"], ensure_ascii=False).count("下降是不是集中在某个地区") == 0, "Agent 写的假设不带" diff --git a/frontend/src/lib/evidence.ts b/frontend/src/lib/evidence.ts index 811574fa..8d5faaa1 100644 --- a/frontend/src/lib/evidence.ts +++ b/frontend/src/lib/evidence.ts @@ -262,10 +262,13 @@ export function sourceOf(seg: EvidenceSegment, doc?: Pick : {} + const what = cite.kind === 'cell' ? E.citedCell(Number(loc.row) || 0, labels[column] || column) : loc.key ? E.citedValue(loc.key) : E.citedPhrase return [`${E.title} ${cite.alias ?? ''}`.trim(), what].join(' · ') } diff --git a/frontend/src/lib/termsAnalysis.ts b/frontend/src/lib/termsAnalysis.ts index 0995f3ed..c09e1c93 100644 --- a/frontend/src/lib/termsAnalysis.ts +++ b/frontend/src/lib/termsAnalysis.ts @@ -8,7 +8,8 @@ * 的数、说法违规的原话都是服务端写好的,界面照原样显示,不按判定码、原因码自己拼。规矩同 terms.ts 开头的术语表。 * * 术语:一次分析的结果叫「分析结论」,编号 A1、A2……;系统写的那一句叫「判定语」;判定的依据叫「判定标准」;做不成的叫「没有做成」 - * (服务端判定语的写法),原因叫「算不了的原因」;写错的调用叫「调用有误」,不记成结论;Agent 写的那句叫「Agent 写的假设」。 + * (服务端判定语的写法),原因叫「算不了的原因」;写错的调用叫「调用有误」,不记成结论;「这次在验证什么」那一句谁写的照实说: + * Agent 现场做的那句叫「Agent 写的假设」,分析卡上那条是模板作者写的,叫「分析卡上写的『在验证什么』」。 */ import { formatNumber } from './format' @@ -221,8 +222,11 @@ export const ANALYSIS_TEXT = { step: { /** 运行中的状态字(store/chat) */ running: (label: string) => `正在执行分析:${label}…`, - /** 一步的标题:「变化拆解(可加)· A1」;分析卡的一项:「变化拆解(可加)· where」 */ - title: (method: string, alias?: string | null) => (alias ? `${method} · ${alias}` : method), + /** 分析卡的一项:「变化拆解(可加)· where」。标识是这一项在分析卡里的 id,不是报告目录里的编号 */ + title: (method: string, item?: string | null) => (item ? `${method} · ${item}` : method), + /** Agent 工具那一步:「变化拆解(可加)· 本节点 A1」。编号是这份 Agent 自己的分析容器里的(交回模型的「【分析 A1】」), + * 报告目录里的 A1 按整次运行的台账编,两者不一定是同一个(图里还有分析卡时就不是) */ + nodeTitle: (method: string, alias?: string | null) => (alias ? `${method} · 本节点 ${alias}` : method), callError: '调用有误,没有记成结论', callErrorNext: '这次调用没有记成结论,Agent 会按这条说明改正参数后重新调用', refused: '没有做成', @@ -261,6 +265,7 @@ export const ANALYSIS_TEXT = { citedCell: (row: number, column: string) => `结果表第 ${formatNumber(row + 1)} 行「${column}」`, who: '谁做的', origin: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析', metric: '口径卡执行时自动做的可比性检查' } as Record, + /** 「在验证什么」那一行:服务端按谁写的给说法(Agent 写的是假设,分析卡上那一条是模板作者写的),照原样显示 */ hypothesis: 'Agent 写的假设', refused: '这次分析没有做成', renderOk: '与按结论渲染的结果一致', diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx index 67c99ffc..948275af 100644 --- a/frontend/src/pages/StudioPage.tsx +++ b/frontend/src/pages/StudioPage.tsx @@ -527,9 +527,11 @@ export function StudioPage() { {workflow?.name ?? '选择工作流'} - {/* 改名只改名字,画布上没保存的改动不受影响;内置模板不给改名(见 renameWorkflow) */} + {/* 改名只改名字,画布上没保存的改动不受影响;内置模板不给改名(见 renameWorkflow)。 + 窄屏收起来:工具栏挤不下,改名在「切换工作流」的列表里照样有 */} {workflow && !workflow.is_template && ( - void renameWorkflow(workflow, refresh)} icon={} /> )} diff --git a/frontend/src/run/EvidenceAnalysis.tsx b/frontend/src/run/EvidenceAnalysis.tsx index 7eec12f5..b3f8a599 100644 --- a/frontend/src/run/EvidenceAnalysis.tsx +++ b/frontend/src/run/EvidenceAnalysis.tsx @@ -97,7 +97,8 @@ function JudgedPart({ step, recompute, seal, overall }: { 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 + // 结果表的格写列名(服务端照结果表记的 labels 给),不把列 id 当叫法;老接口没给列名的照旧写 id + : cited.what === 'cell' ? E.citedCell(Number(cited.row) || 0, String(cited.column_label || cited.column || '')) : E.citedPhrase const refused = step.status === 'refused' const trusted = !!overall?.sealed && overall.ok !== false const problems = [ @@ -135,7 +136,7 @@ function JudgedPart({ step, recompute, seal, overall }: { {step.caveats.map((c, i) =>
  • {c.text}
  • )} )] : null, - step.hypothesis ? [E.hypothesis, ( + step.hypothesis ? [step.hypothesis_label || E.hypothesis, ( {step.hypothesis} {step.hypothesis_note && {step.hypothesis_note}} @@ -267,7 +268,9 @@ function InputPart({ step, onQuery, trusted }: { step: EvidenceAnalysisStep; onQ rows ? [E.rowsUsed, rows] : null, 'value' in step && !parent ? [ratio ? E.headlineRatio : E.headline, ( - {step.column != null && typeof step.row === 'number' ? `${E.cellAt(step.row, step.column)} = ` : ''}{valueText(step.value)} + {step.column != null && typeof step.row === 'number' ? `${E.cellAt(step.row, step.column)} = ` : ''} + {/* 那一格的值服务端渲染好了(千分位、NULL 写「—」,和闭合核对同一个写法),照原样显示;老接口没给的按值本身写 */} + {step.value_rendered ?? valueText(step.value)} )] : null, closure && closed ? [E.closure, ( diff --git a/frontend/src/run/__tests__/evidence-analysis.json b/frontend/src/run/__tests__/evidence-analysis.json index 0131364f..a4780114 100644 --- a/frontend/src/run/__tests__/evidence-analysis.json +++ b/frontend/src/run/__tests__/evidence-analysis.json @@ -423,7 +423,16 @@ "op": "decompose", "rows": 4, "status": "ok", - "verdict": "driver" + "verdict": "driver", + "labels": { + "base": "基期", + "delta": "变化", + "flag": "说明", + "pct": "变化率", + "region": "地区", + "share": "占变化的比重", + "target": "本期" + } }, "A2": { "alias": "A2", @@ -460,7 +469,14 @@ "op": "comparable", "rows": 5, "status": "ok", - "verdict": "comparable" + "verdict": "comparable", + "labels": { + "base": "基期", + "check": "检查项", + "note": "说明", + "status": "结果", + "target": "本期" + } }, "Q1": { "alias": "Q1", @@ -673,7 +689,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文", + "hypothesis_label": "Agent 写的假设" }, { "step": "analysis_method", @@ -897,7 +914,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -918,7 +936,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -1300,7 +1319,8 @@ "what": "cell", "row": 0, "column": "delta", - "raw": "-300" + "raw": "-300", + "column_label": "变化" }, "rendered": "-300", "render_ok": true, @@ -1326,7 +1346,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文", + "hypothesis_label": "Agent 写的假设" }, { "step": "analysis_method", @@ -1555,7 +1576,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -1576,7 +1598,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -1984,7 +2007,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文", + "hypothesis_label": "Agent 写的假设" }, { "step": "analysis_method", @@ -2209,7 +2233,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -2230,7 +2255,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -3280,7 +3306,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文", + "hypothesis_label": "Agent 写的假设" }, { "step": "analysis_method", @@ -3504,7 +3531,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -3525,7 +3553,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -3871,7 +3900,8 @@ "verdict": null, "caveats": [], "hypothesis": "这周的下降算不算异常", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文", + "hypothesis_label": "Agent 写的假设" }, { "step": "analysis_method", @@ -4075,7 +4105,8 @@ }, "caveats": [], "hypothesis": "两周能不能直接比", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文", + "hypothesis_label": "Agent 写的假设" }, { "step": "analysis_method", @@ -5028,7 +5059,16 @@ "op": "decompose", "rows": 4, "status": "ok", - "verdict": "driver" + "verdict": "driver", + "labels": { + "base": "基期", + "delta": "变化", + "flag": "说明", + "pct": "变化率", + "region": "地区", + "share": "占变化的比重", + "target": "本期" + } }, "Q1": { "alias": "Q1", @@ -5189,7 +5229,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "分析卡上写的『在验证什么』:模板作者写的那一句,不是分析结论,也不进报告正文", + "hypothesis_label": "分析卡上写的『在验证什么』" }, { "step": "analysis_method", @@ -5413,7 +5454,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -5434,7 +5476,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -6056,7 +6099,8 @@ "what": "cell", "row": 0, "column": "delta", - "raw": "-300" + "raw": "-300", + "column_label": "变化" }, "rendered": "-300", "render_ok": true, @@ -6082,7 +6126,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "分析卡上写的『在验证什么』:模板作者写的那一句,不是分析结论,也不进报告正文", + "hypothesis_label": "分析卡上写的『在验证什么』" }, { "step": "analysis_method", @@ -6311,7 +6356,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -6332,7 +6378,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -6714,7 +6761,8 @@ "what": "cell", "row": 0, "column": "delta", - "raw": "-300" + "raw": "-300", + "column_label": "变化" }, "rendered": "-300", "render_ok": true, @@ -6740,7 +6788,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "分析卡上写的『在验证什么』:模板作者写的那一句,不是分析结论,也不进报告正文", + "hypothesis_label": "分析卡上写的『在验证什么』" }, { "step": "analysis_method", @@ -6969,7 +7018,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -6990,7 +7040,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -7924,7 +7975,8 @@ } ], "hypothesis": "下降是不是集中在某个地区", - "hypothesis_note": "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" + "hypothesis_note": "分析卡上写的『在验证什么』:模板作者写的那一句,不是分析结论,也不进报告正文", + "hypothesis_label": "分析卡上写的『在验证什么』" }, { "step": "analysis_method", @@ -8148,7 +8200,8 @@ "total": "2,700", "headline": "2,700", "closed": true - } + }, + "value_rendered": "2,700" }, { "step": "analysis_input", @@ -8169,7 +8222,8 @@ "total": "2,360", "headline": "2,360", "closed": true - } + }, + "value_rendered": "2,360" }, { "step": "analysis_input", @@ -9453,4 +9507,4 @@ "declared_at": "2026-10-21T10:36:37.326911+00:00" } } -} \ No newline at end of file +} diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts index 8e82baea..a1f5fa49 100644 --- a/frontend/src/run/__tests__/synthetic.ts +++ b/frontend/src/run/__tests__/synthetic.ts @@ -739,13 +739,13 @@ export function analysisTeamRun(): RunEvent[] { ev('tool.end', 'team', { tool: 'analysis_decompose', agent: '分析员', call_id: 'm2', duration_ms: 8, preview: '{\n "analysis_artifact": "a1f0", \n "result": {\n "op": "decompose",\n "mode": "additive",\n "method": "decompose.additive@1",', analysis_artifact: 'a1f0c3d2b4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9', - analysis: { alias: 'A1', op: 'decompose', mode: 'additive', status: 'ok', verdict: 'driver', direction: 'down', phrase: TEAM_PHRASE, + analysis: { alias: null, op: 'decompose', mode: 'additive', status: 'ok', verdict: 'driver', direction: 'down', phrase: TEAM_PHRASE, reason_code: null } }, 0.01) ev('tool.start', 'team', { tool: 'analysis_baseline', agent: '分析员', call_id: 'm3', args: { query: 'Q1', period: 'week', measure: 'gmv' } }, 0.1) ev('tool.end', 'team', { tool: 'analysis_baseline', agent: '分析员', call_id: 'm3', duration_ms: 4, preview: '{\n "analysis_artifact": "b2e1",', analysis_artifact: 'b2e1d4c3a5f6e70819203b4c5d6e7f8091a2b3c4d5e6f70819203b4c5d6e7f80', - analysis: { alias: 'A2', op: 'baseline', mode: null, status: 'refused', verdict: null, direction: null, + analysis: { alias: null, op: 'baseline', mode: null, status: 'refused', verdict: null, direction: null, phrase: '波动基线没有做成:只有 0 期历史,至少要 8 期', reason_code: 'history_short' } }, 0.01) ev('log', 'team', { level: 'warn', code: 'analysis_refused', agent: '分析员', call_id: 'm3', tool: 'analysis_baseline', reason_code: 'history_short', reason: '只有 0 期历史,至少要 8 期', message: '分析员 的 analysis_baseline 没有做成:只有 0 期历史,至少要 8 期' }) diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index ed0ab060..dbcafc65 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -1387,8 +1387,9 @@ function toolStep(seq: number, tool: string, args: Record): Step { } /** - * tool.end 的 analysis(tools/analysis.event_view)折进分析那一行:标题接上编号,副标题是判定语(算不了的就是「…没有做成:原因」, - * 服务端写好的);算不了的用提醒色。老服务端没有这一段:preview 只是结论的前一截,判定语多半被截掉,只照参数写叫法,不从里面拼 + * tool.end 的 analysis(tools/analysis.event_view)折进分析那一行:标题接上这份 Agent 自己的编号(「本节点 A1」),副标题是 + * 判定语(算不了的就是「…没有做成:原因」,服务端写好的);算不了的用提醒色。老服务端没有这一段:preview 只是结论的前一截, + * 判定语多半被截掉,只照参数写叫法,不从里面拼 */ function settleAnalysis(step: Step, d: Record): void { const view = d.analysis && typeof d.analysis === 'object' ? d.analysis as Record : null @@ -1403,7 +1404,9 @@ function settleAnalysis(step: Step, d: Record): void { if (typeof view.reason_code === 'string') info.reasonCode = view.reason_code } step.analysis = info - step.title = ANALYSIS_TEXT.step.title(analysisMethodLabel(info.op, info.mode), info.alias) + // Agent 工具那一步的编号是这份 Agent 自己的分析容器里的(交回模型的「【分析 A1】」),报告目录里的 A1 按整次运行的台账编, + // 两者不一定相同(图里还有分析卡时就不是):标题写「本节点 A1」,不冒充报告里的编号 + step.title = ANALYSIS_TEXT.step.nodeTitle(analysisMethodLabel(info.op, info.mode), info.alias) if (info.phrase) step.sub = step.sub ? `${info.phrase} · ${step.sub}` : info.phrase if (info.status === 'refused') { step.level = 'warn' diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d86672a1..165f5755 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2435,8 +2435,16 @@ export interface EvidenceAnalysisStep { artifact?: string | null eid?: string eid_ok?: boolean - /** 这一处引用的是结论的哪一项:判定语、具名的数(key)、结果表的一格(row、column) */ - cited?: { what?: 'phrase' | 'value' | 'cell' | string; key?: string; row?: number; column?: string; raw?: unknown; conv?: string } + /** 这一处引用的是结论的哪一项:判定语、具名的数(key)、结果表的一格(row、column;column_label 是结果表的列名,给人看的) */ + cited?: { + what?: 'phrase' | 'value' | 'cell' | string + key?: string + row?: number + column?: string + column_label?: string | null + raw?: unknown + conv?: string + } rendered?: string render_ok?: boolean hash_ok?: boolean | null @@ -2448,6 +2456,8 @@ export interface EvidenceAnalysisStep { verdict?: AnalysisVerdict | null caveats?: AnalysisCaveat[] hypothesis?: string + /** 「在验证什么」那一行的说法:谁写的照实说,服务端按台账条目给(Agent 写的假设 / 分析卡上写的『在验证什么』) */ + hypothesis_label?: string hypothesis_note?: string note?: string // ---- analysis_method:方法、参数、实际用的阈值、现查结果 ---- @@ -2479,6 +2489,8 @@ export interface EvidenceAnalysisStep { row?: number column?: string value?: string | null + /** 那一格的值按服务端的规则渲染出来的字(render_cell:千分位、NULL 写「—」),和闭合核对里的写法一致 */ + value_rendered?: string /** 输入落到报告目录的哪个 Q */ query?: string | null sealed?: boolean @@ -4589,7 +4601,6 @@ export interface RunCompareQuery { problem?: string } -/** GET /runs/{id}/compare?base=。任一未封存或封存核对不一致 409;不是同一工作流、是同一次运行 422 */ /** 运行对比里分析卡的一边(analysis.end 那一项):没有这一项为 null */ export interface RunCompareAnalysisSide { status?: string | null @@ -4614,6 +4625,7 @@ export interface RunCompareAnalysis { change: 'same' | 'verdict' | 'phrase' | 'added' | 'removed' | string } +/** GET /runs/{id}/compare?base=。任一未封存或封存核对不一致 409;不是同一工作流、是同一次运行 422 */ export interface RunComparison { target: RunCompareRef base: RunCompareRef diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index 3598421b..e8557d05 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -927,8 +927,9 @@ await section('样本页:各种形状', async () => { check('analysis:点开判定语:判定语、判定标准、方法与版本(服务端带的叫法)', detail.includes('判定语') && detail.includes('一段占总变化的一半以上算主要来源') && detail.includes('变化拆解(可加) · decompose.additive@1'), detail.replace(/\s+/g, ' ').slice(0, 300)) + // 这三份分析都是 Agent 现场做的(台账条目带 call_id):那一句照旧标「Agent 写的假设」 check('analysis:……附带说明、Agent 写的假设', detail.includes('附带说明') && detail.includes('Agent 写的假设') - && detail.includes('下降是不是集中在某个地区'), detail.replace(/\s+/g, ' ').slice(0, 400)) + && !detail.includes('分析卡上写的『在验证什么』') && detail.includes('下降是不是集中在某个地区'), detail.replace(/\s+/g, ' ').slice(0, 400)) check('analysis:……输入(角色、编号、各自的核对状态)', ['基期总数 · Q1', '本期总数 · Q1', '数据 · Q2'].every((t) => detail.includes(t)), detail.replace(/\s+/g, ' ').slice(0, 500)) check('analysis:……复算写导出时服务端复算的结论、本页不重算统计', detail.includes('复算一致:按封存的输入重新计算,结论逐项相同') @@ -939,7 +940,8 @@ await section('样本页:各种形状', async () => { && shareDetail.includes('与按显示规则渲染的结果一致'), shareDetail.replace(/\s+/g, ' ').slice(0, 300)) const cell = page.locator('.doc p button.num').filter({ hasText: /^-120$/ }) const cellDetail = await cell.count() ? await detailText(page, cell.first()) : '找不到「-120」' - check('analysis:结果表的格:出处写分析结论 A1 的结果表 · 第 1 行 · delta', cellDetail.includes('分析结论 A1 的结果表 · 第 1 行 · delta'), + // 列写结果表记的列名(「变化」),不把列 id(delta)当叫法 + check('analysis:结果表的格:出处写分析结论 A1 的结果表 · 第 1 行 · 变化', cellDetail.includes('分析结论 A1 的结果表 · 第 1 行 · 变化'), cellDetail.replace(/\s+/g, ' ').slice(0, 300)) check('analysis:结果表整表引用:表头是给人看的列名,数字列右对齐', (await page.locator('.doc table th').allTextContents()).join('、') === '地区、基期、本期、变化' && await page.locator('.doc td.n button.num').count() === 12, (await page.locator('.doc table th').allTextContents()).join('、')) diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index d17f8fc0..81b70725 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -127,17 +127,17 @@ await section('分析:Agent 的分析工具、分析卡的每一项', async () && wrong.sub === '调用有误,没有记成结论' && !!wrong.next?.includes('Agent 会按这条说明改正参数'), `${wrong?.status} ${wrong?.sub}`) check('……服务端的原话留在展开区,调用错误的代码记在这一行', !!wrong?.result?.includes('Q2 里没有「sales」这一列') && wrong.analysis?.callError === 'unknown_column', wrong?.result) - check('做成的:标题接上本节点的编号(A1,和交回模型的「【分析 A1】」同一个)', decompose?.title === '变化拆解(可加) · A1', decompose?.title) + check('做成的:标题接上本节点的编号(A1,和交回模型的「【分析 A1】」同一个)', decompose?.title === '变化拆解(可加) · 本节点 A1', decompose?.title) check('……副标题是服务端渲染的判定语', decompose?.sub === '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)', decompose?.sub) check('……结论工件可下钻,Agent 写的假设另记、不进参数', !!decompose?.artifact && decompose.analysis?.hypothesis === '下降是不是集中在某个地区' && !decompose.detail?.includes('hypothesis') && !decompose.detail?.includes('null'), decompose?.detail?.slice(0, 80)) check('……preview(结论 JSON 的前一截)不摆出来', !decompose?.result, decompose?.result?.slice(0, 40)) check('算不了的:一条结论(不是失败),提醒色,判定语就是「…没有做成:原因」', baseline?.status === 'done' && baseline.level === 'warn' - && baseline.title === '波动基线 · A2' && baseline.sub === '波动基线没有做成:只有 0 期历史,至少要 8 期', `${baseline?.title} ${baseline?.sub}`) + && baseline.title === '波动基线 · 本节点 A2' && baseline.sub === '波动基线没有做成:只有 0 期历史,至少要 8 期', `${baseline?.title} ${baseline?.sub}`) check('……下一步说能照实写原因、不能据此下判断', !!baseline?.next?.includes('算不了也是一条结论')) check('……紧跟着的「没有做成」提醒折进这一行,不另起一行', !all.some((s) => s.code === 'analysis_refused'), all.filter((s) => s.code === 'analysis_refused').map((s) => s.title).join(' | ')) - check('可比性检查也有编号和判定语', comparable?.title === '可比性检查 · A3' && !!comparable.sub?.startsWith('两期可比'), comparable?.title) + check('可比性检查也有编号和判定语', comparable?.title === '可比性检查 · 本节点 A3' && !!comparable.sub?.startsWith('两期可比'), comparable?.title) check('分析那几行是正常的子步骤:不当成运行出错(运行照样完成)', all.some((s) => s.kind === 'lifecycle' && s.title === '完成')) const claims = all.find((s) => s.code === 'report_analysis_claims') check('报告的分析说法重写后仍有:说成人话(几处、已在报告中标注),副标题点名判断词', claims?.title === '报告有 3 处分析说法未通过核对,已在报告中标注' @@ -170,8 +170,8 @@ await section('分析:Agent 的分析工具、分析卡的每一项', async () // 协作成员的分析:编号是成员自己的,副标题接着写是谁调用的 const team = flatten(mod.decodeRun(synthetic.analysisTeamRun(), { status: 'succeeded' })).filter((s) => s.kind === 'analysis') - check('协作成员调分析工具:编号是成员自己的,副标题写判定语和谁调用的', team[0]?.title === '变化拆解(可加) · A1' - && team[0].sub === `${synthetic.TEAM_PHRASE} · 分析员 调用` && team[1]?.title === '波动基线 · A2', team.map((s) => `${s.title}:${s.sub}`).join(' | ')) + check('协作成员调分析工具:不带编号(成员容器里的 A1 不是报告目录里的),副标题写判定语和谁调用的', team[0]?.title === '变化拆解(可加)' + && team[0].sub === `${synthetic.TEAM_PHRASE} · 分析员 调用` && team[1]?.title === '波动基线', team.map((s) => `${s.title}:${s.sub}`).join(' | ')) }) await section('合并查询', async () => { diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index 2d5f4688..738cd501 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -619,6 +619,9 @@ await section('analysis', '分析结论:判定语、具名的数、结果表 === 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) + // 那一格的值也由服务端渲染(千分位),和闭合核对里的数同一个写法:原来照原值写「2700」,同一节里 2,700 和 2700 并排 + check('……那一格的值按服务端的写法显示(千分位,和闭合核对一致)', (await inputs.nth(0).innerText()).includes('第 1 行「gmv」 = 2,700') + && !(await inputs.nth(0).innerText()).includes('= 2700'), await inputs.nth(0).innerText()) 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() @@ -634,9 +637,10 @@ await section('analysis', '分析结论:判定语、具名的数、结果表 && 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」' + // 列写结果表记的列名(「变化」),不把列 id(delta)当叫法 + check('结果表的格(-300):引用的是结果表第 1 行「变化」,那一格高亮', (await p.locator('[data-ev-analysis-cited]').innerText()) === '结果表第 1 行「变化」' && 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('……读屏写出处:分析结论 A1 · 结果表第 1 行「变化」', (await box.locator('[data-seg="s7"]').getAttribute('aria-label') ?? '').includes('分析结论 A1 · 结果表第 1 行「变化」')) check('片段接口按片段 id 取(s3、s9、s7)', ['seg:s3', 'seg:s9', 'seg:s7'].every((h) => hits.includes(h)), hits.join(',')) // 分析卡的结论:谁做的写分析卡(模板定好的分析);表格里引用结果表的格 @@ -645,6 +649,10 @@ await section('analysis', '分析结论:判定语、具名的数、结果表 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('分析卡(模板定好的分析)')) + // 「在验证什么」那一句是模板作者写的:照实写「分析卡上写的『在验证什么』」,不标成 Agent 写的假设 + const cardBody = await p.locator('[data-ev-analysis="A2"]').innerText() + check('……「在验证什么」写「分析卡上写的『在验证什么』」,不说 Agent 写的假设', cardBody.includes('分析卡上写的『在验证什么』') + && !cardBody.includes('Agent 写的假设'), cardBody.slice(-160)) 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 diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index cd4fcb0e..efc7a619 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -748,7 +748,7 @@ await section('分析:Agent 的分析工具、分析卡的每一项一行, && await page.locator('[data-step-kind="analysis"]:not([data-step-status="failed"]) svg.lucide-chart-column-stacked').count() === 5, String(await rows.count())) const line = async (i) => (await rows.nth(i).innerText().catch(() => '')).replace(/\s+/g, ' ') - check('做成的一行:标题写算子的叫法和编号,副标题是判定语', (await line(1)).includes('变化拆解(可加) · A1') + check('做成的一行:标题写算子的叫法和本节点的编号(报告目录里的编号另算),副标题是判定语', (await line(1)).includes('变化拆解(可加) · 本节点 A1') && (await line(1)).includes('主要来自华东(-300,占 88.2%)'), await line(1)) check('……标题里不出现工具名 analysis_decompose', !(await page.locator('[data-turn]').first().innerText()).includes('analysis_decompose')) await rows.nth(1).locator('button').first().click() @@ -790,7 +790,8 @@ await section('分析:Agent 的分析工具、分析卡的每一项一行, const team = await open('syn=analysis-team', { w: 1100, h: 900 }) const tl = (await team.page.locator('[data-step-kind="analysis"]').first().innerText().catch(() => '')).replace(/\s+/g, ' ') - check('协作成员的分析:编号是成员自己的,副标题接着写是谁调用的', tl.includes('变化拆解(可加) · A1') && tl.includes('分析员 调用'), tl) + check('协作成员的分析:不带编号(进不了台账,报告目录里的 A1 另算),副标题接着写是谁调用的', tl.includes('变化拆解(可加)') && !tl.includes('本节点 A1') + && tl.includes('分析员 调用'), tl) await team.page.close() }) diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index 843e4008..074d9845 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -846,6 +846,14 @@ await section('窄屏:次要按钮收进「更多」、助手栏默认收起 check('768 宽:最右的控件也在视口里、工具栏不溢出', bar.right <= bar.vw && bar.sw <= bar.cw, JSON.stringify({ right: bar.right, vw: bar.vw, sw: bar.sw, cw: bar.cw })) check('768 宽:运行和收起助手栏都露在外面', bar.names.includes('运行') && bar.names.some((n) => n.includes('助手栏')), bar.names.join(' ')) + // 改名那个小铅笔收起来了(768 宽挤不下):改名还在「切换工作流」的列表里 + check('768 宽:工具栏上看不到改名的图标(挤不下)', !bar.names.some((n) => n.includes('重命名')), bar.names.join(' ')) + await page.locator('button[title^="切换、新建"]').click() + await page.waitForTimeout(250) + check('……改名在「切换工作流」的列表里照样有', await count(page, '[role="listbox"][aria-label="工作流"] [aria-label^="重命名"]') > 0) + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + const more = page.getByRole('button', { name: '更多操作' }) check('窄屏把次要按钮收进「更多操作」', await more.count() === 1 && await more.isVisible()) await more.click()