diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 67e5a199..fc4cf9ec 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -104,6 +104,7 @@ from app.db.base import SessionLocal from app.db.models import DataSource, Run, RunEvent, Workflow from app.engine import analysis_claims, direct_select, recompute from app.engine.evidence import ( + CELL_DISPLAY, ENTITY_KINDS, GUESS_NOTE, GUESS_SCHEMA, @@ -116,6 +117,7 @@ from app.engine.evidence import ( analysis_recheck, build_catalog, closest_entities, + doc_cell_display, find_segment, guess_sources, input_eid, @@ -126,6 +128,7 @@ from app.engine.evidence import ( query_entry_fields, render_cell, render_metric, + resolve_ref, schema_column, schema_table, sql_tables, @@ -1041,17 +1044,22 @@ def _analysis_cited(cite: dict[str, Any], result: dict[str, Any] | None) -> dict return {"what": "phrase"} -def _analysis_rendered(cite: dict[str, Any], result: dict[str, Any]) -> str | None: - """按封存的结论重新渲染这一处引用(判定语按文本格、具名标量和结果表的格按单元格的规则)。渲染不出来是 None。""" - try: - cited = _analysis_cited(cite, result) - if cited["what"] == "phrase": - return render_cell(result.get("phrase"), kind="text") if isinstance(result.get("phrase"), str) else None - if cited["what"] == "value": - return render_cell(cited["raw"], cite.get("conv")) - return render_cell(cited["raw"], cite.get("conv"), plain=cite.get("plain") is True) - except RenderError: - return None +def _analysis_rendered(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], result: dict[str, Any], + doc: dict[str, Any]) -> str | None: + """按封存的结论把这一处引用重新解析一遍,交回渲染出来的字(渲染不出来是 None)。和组装、出口复核同一个函数 + (evidence.resolve_ref,按文档记下的那一版单元格显示规则):判定语按文本格,具名标量按单元格的规则,结果表的格照结果表 + 记的列类型(文本码值 "1203" 不加千分位)、显示规则(ISO 日期时间的 T 换成空格)、标识列不分千位。片段上没记引用的 + (口径卡指标上的可比性、分析范围里的编号)按判定语渲染。""" + ref = seg.get("ref") + if not isinstance(ref, str) or not ref.strip(): + phrase = result.get("phrase") + try: + return render_cell(phrase, kind="text") if isinstance(phrase, str) else None + except RenderError: + return None + alias = str(cite.get("alias") or entry.get("alias") or "") + again = resolve_ref(ref, {alias: {**entry, "result": result}}, display=doc_cell_display(doc) or CELL_DISPLAY) + return again.get("rendered") if again.get("status") == "resolved" else None async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], doc: dict[str, Any], @@ -1082,7 +1090,7 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict in_ledger = sealed.trusted and (str(entry.get("node_id") or ""), artifact) in sealed.ledger \ if chained is None else sealed.trusted and chained eid = make_eid(LEDGER_KIND, artifact or None, {}) - rendered = _analysis_rendered(cite, result) if result is not None else None + rendered = _analysis_rendered(seg, cite, entry, result, doc) if result is not None else None judged: dict[str, Any] = { "step": "analysis", "alias": alias, "op": entry.get("op"), "mode": entry.get("mode"), "method_label": method_label(entry.get("op"), entry.get("mode")), diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index c77667e0..41496690 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -2106,8 +2106,11 @@ def resolve_support(ref: str, catalog: dict[str, Any], *, loader: Loader | _Snap def resolve_ref(ref: str, catalog: dict[str, Any], *, cells_allowed: bool = True, - loader: Loader | _Snapshots | None = None) -> dict[str, Any]: - """片段上记的 ref(m:gmv|万、i:week、v:Q1.r0.amount)→ Citation。证据接口点开片段时用它重新解析。""" + loader: Loader | _Snapshots | None = None, display: int | None = None) -> dict[str, Any]: + """片段上记的 ref(m:gmv|万、i:week、v:Q1.r0.amount)→ Citation。证据接口点开片段时用它重新解析。display 是按哪一版 + 单元格显示规则渲染(文档记下的那一版,doc_cell_display;复核按它),不给按 loader 的(缺省当前版)。""" + if display is not None: + loader = _snapshots(loader, display=display) return resolve_marker(_parse_ref(ref), catalog, cells_allowed=cells_allowed, loader=loader) diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py index 93b3b867..3b946a09 100644 --- a/backend/tests/test_analysis_evidence_api.py +++ b/backend/tests/test_analysis_evidence_api.py @@ -178,6 +178,51 @@ async def test_named_values_and_result_cells_point_at_what_they_cite(client): assert cell["note"] == "该值由系统从分析结论的结果表中取出、按固定规则显示,未经模型转写" +#: 按门店拆的快照:门店码是文本("1203"),渲染时不加千分位 +STORES: dict[str, Any] = { + "columns": ["store", "week", "gmv"], "column_types": {"store": "text", "week": "text", "gmv": "number"}, + "rows": [["1203", "W41", 820], ["1203", "W42", 700], ["1305", "W41", 600], ["1305", "W42", 560], + ["2201", "W41", 700], ["2201", "W42", 666]], + "sql": "SELECT store, week, SUM(gmv) AS gmv FROM orders GROUP BY store, week ORDER BY store, week", + "source": "shop", "truncated": False, "row_count": 6, +} +#: 按时段拆的快照:时段是 ISO 日期时间(带 T),文档按 2 版显示规则写成空格 +SLOTS: dict[str, Any] = { + "columns": ["slot", "week", "gmv"], "column_types": {"slot": "text", "week": "text", "gmv": "number"}, + "rows": [["2026-10-12T08:00:00", "W41", 820], ["2026-10-12T08:00:00", "W42", 700], + ["2026-10-12T20:00:00", "W41", 600], ["2026-10-12T20:00:00", "W42", 560]], + "sql": "SELECT slot, week, SUM(gmv) AS gmv FROM orders GROUP BY slot, week ORDER BY slot, week", + "source": "shop", "truncated": False, "row_count": 4, +} + + +@pytest.mark.parametrize("snapshot, by, shown", [ + (STORES, "store", "1203"), + (SLOTS, "slot", "2026-10-12 08:00:00"), +]) +async def test_a_result_cell_rerenders_the_way_it_was_composed(client, monkeypatch, snapshot, by, shown): + """证据接口按组装时同一套规则重新渲染结果表的格(evidence.resolve_ref,按文档记下的单元格显示规则):文本码值照结果表 + 记的列类型不加千分位,ISO 日期时间的 T 换成空格。以前不看列类型和显示规则,渲染成「1,203」「2026-10-12T08:00:00」, + render_ok 为假,证据面板和导出页报假的「与按结论渲染的结果不一致」。""" + import tests.test_analysis_evidence_api as here + + monkeypatch.setattr(here, "SNAPSHOT", snapshot) + q = await artifact_store.put_json(snapshot, kind="query_snapshot") + result = await analyzed({"Q2": (snapshot, q)}, by=[by]) + ref = f"v:A1.r0.{by}" + made = await sealed_run(f"{by} [[{ref}]] 变化 [[v:A1.r0.delta]]。[[see:A1]]", result=result) + segment = next(s for s in iter_segments(made["doc"]) if s.get("ref") == ref) + assert segment["text"] == shown + 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]} + # 导出页的数据块是同一条链 + data = data_block((await client.get(f"/api/runs/{made['run']}/report.html")).text) + chain = data["chains"][data["refs"][segment["id"]]] + assert chain[0]["step"] == "analysis" and chain[0]["render_ok"] is True + + async def test_the_recompute_gets_the_sealed_inputs_by_hash(client, monkeypatch): """复算的 load 交工件原内容(不加出处、不改列的类型),只认封存范围内交回过的查询;别的一律当取不回。""" seen: dict[str, Any] = {}