"""报告直接引用查询快照:单元格 [[v:Q1.r0.amount]] 和整表 [[table:Q1 cols=a,b rows=0-4]]。 一期的报告只能引用口径卡的指标,查询结果只能挂在 [[see:]] 里当依据。问数据页那种 「查一下各区域销售额」的答案,数字全在查询结果里,没有口径卡可引——要么硬塞一张只做 搬运的口径卡,要么模型照抄数字、出处又断了。二期让报告直接引用快照里的那一格: - 值一律从快照取(取回时复验哈希),按规则渲染;模型只写位置 - 行越界、列不存在、快照取不回来、被改过,都判解析不了,原因写清楚 - 整表由系统从快照生成,每一格都是带出处的片段 - 沙箱代码节点的产出不能直接引用:那种数要进口径卡 - 受管出具没声明 cells 时,复核可以把单元格引用判成解析不了 - 流式渲染和整篇渲染逐字一致 """ from __future__ import annotations import copy import random import pytest from app.core import artifact_store from app.engine.evidence import ( CELLS_REASON, CODE_REASON, StreamRenderer, build_catalog, catalog_prompt, compose_doc, iter_segments, make_eid, render_markers, resolve_ref, verify_doc, ) # -------------------------------------------------------------------------- # 夹具:一份查询快照(形状同数据源工具落的 query_snapshot),外加一张口径卡 # -------------------------------------------------------------------------- SNAP = { "columns": ["region", "amount", "orders", "note"], "rows": [ ["华东", 18230.5, 120, "新客多"], ["华南", 12004.0, 98, None], ["华北", "9001.25", 77, "含|竖线"], # DECIMAL 列经 JSON 落盘后是字符串 ["西南", 0, 0, "多行\n备注"], ["西北", -12.5, 3, "x"], ["东北", 1, 1, "y"], ["中部", 2, 2, "z"], ], "row_count": 7, "truncated": False, "elapsed_ms": 3, "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS orders, MAX(note) AS note FROM orders GROUP BY region", "source": "shop", } # 列名本身带数字:系统生成的表头不能被判成裸数字 DIGITS = {"columns": ["销售额2025", "周"], "rows": [[1.5, "2026-W37"]], "row_count": 1, "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"} WIDE = {"columns": [f"c{i}" for i in range(13)], "rows": [list(range(13))], "row_count": 1, "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"} EMPTY = {"columns": ["amount"], "rows": [], "row_count": 0, "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"} TOOL_SNAP = "b" * 64 GONE = "f" * 64 # 台账里有、工件库里没有的快照 CARD_ART = "c" * 64 CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "artifact": CARD_ART, "metrics": [{"id": "gmv", "name": "销售额", "unit": "元", "value": 45678.5, "decimals": 1, "format": "thousands", "status": "ok", "expression": "…"}]} def _query(artifact: str, snap: dict, node: str = "fetch", call: str = "call_1") -> dict: return {"kind": "query", "node_id": node, "exec": 1, "artifact": artifact, "via": TOOL_SNAP, "call_id": call, "tool": "db_query__shop", "source": "shop", "columns": snap["columns"], "rows": len(snap["rows"]), "truncated": False} def _store(snap: dict) -> str: """按 put_json 的落盘方式写进(测试用的临时)工件库:取回走真实的 load,复验哈希。""" text = artifact_store.canonical_json(snap) artifact = artifact_store.content_hash(text) path = artifact_store._path_of(artifact) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") return artifact @pytest.fixture(scope="module") def stored() -> dict[str, str]: return {name: _store(snap) for name, snap in (("snap", SNAP), ("digits", DIGITS), ("wide", WIDE), ("empty", EMPTY))} @pytest.fixture def catalog(stored): ledger = [ {"kind": "metric_set", "node_id": "caliber", "exec": 1, "artifact": CARD_ART, "caliber": "周报口径", "version": "v2", "metrics": ["gmv"]}, _query(stored["snap"], SNAP), # Q1 {"kind": "retrieval", "node_id": "kb", "exec": 1, "artifact": "d" * 64, "source": "docs", "rows": 3}, _query(GONE, SNAP, call="call_2"), # Q2:快照取不回来 _query(stored["digits"], DIGITS, call="call_3"), # Q3 _query(stored["wide"], WIDE, call="call_4"), # Q4 _query(stored["empty"], EMPTY, call="call_5"), # Q5 {"kind": "node_output", "node_id": "calc", "exec": 1, "artifact": "e" * 64, "code_sha": "0" * 64, "role": "compute", "language": "python"}, ] return build_catalog(nodes={"caliber": CARD}, ledger=ledger, inputs={"week": "2026-W37"}) def cell_eid(artifact, row, column): return make_eid("cell", artifact, {"row": row, "column": column}) def segs(doc, kind=None): return [s for s in iter_segments(doc) if kind is None or s["kind"] == kind] def bad(doc): return {v["ref"]: v["message"] for v in doc["violations"] if v["code"] == "unresolved_ref"} # -------------------------------------------------------------------------- # 单元格 # -------------------------------------------------------------------------- def test_cell_values_come_from_the_snapshot(catalog, stored): doc = compose_doc("[[v:Q1.r0.region]]销售额 [[v:Q1.r0.amount]],订单 [[v:Q1.r0.orders]]。[[see:Q1]]", catalog) assert doc["markdown"] == "华东销售额 18,230.5,订单 120。" assert doc["violations"] == [] region, amount, orders = [s for s in segs(doc) if s.get("ref")] assert region["kind"] == "value" and amount["kind"] == "number" and orders["kind"] == "number" cite = amount["cite"] assert cite["status"] == "resolved" and cite["kind"] == "cell" and cite["role"] == "value" assert cite["alias"] == "Q1" and cite["ref"] == "Q1.r0.amount" assert cite["locator"] == {"row": 0, "column": "amount"} and cite["value"] == 18230.5 assert cite["eid"] == cell_eid(stored["snap"], 0, "amount") assert amount["state"] == "deterministic" and amount["ref"] == "v:Q1.r0.amount" assert doc["stats"]["numbers_cited"] == 2 and doc["stats"]["values"] == 1 assert doc["blocks"][0]["units"][0]["cites"] == ["Q1"] @pytest.mark.parametrize("marker, shown, value", [ ("[[v:Q1.r2.amount]]", "9,001.25", 9001.25), # DECIMAL 字符串按数渲染 ("[[v:Q1.r1.note]]", "—", None), # NULL 就是「—」,不是 0 ("[[v:Q1.r2.note]]", "含¦竖线", "含|竖线"), # 竖线换掉,免得把表格切出一格 ("[[v:Q1.r3.note]]", "多行 备注", "多行\n备注"), # 文本压成一行 ("[[v:Q1.r3.amount]]", "0", 0), ("[[v:Q1.r4.amount]]", "-12.5", -12.5), ("[[v:Q1.r0.amount|万]]", "1.82万", 18230.5), # 换算由渲染器完成 ]) def test_cell_rendering_rules(catalog, marker, shown, value): doc = compose_doc(f"值 {marker}", catalog) assert doc["violations"] == [], doc["violations"] [seg] = [s for s in segs(doc) if s.get("ref")] assert seg["text"] == shown and seg["cite"]["value"] == value def test_integer_decimal_text_renders_as_a_number(): """小数位为 0 的 DECIMAL(整数列的 SUM())落盘后是没有小数点的 "45678":照样按数渲染、按数引用。""" snap = {"columns": ["gmv", "refunds", "code", "tiny"], "rows": [["45678", "29", "00123", "1E-7"]], "row_count": 1, "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"} catalog = build_catalog(nodes={}, ledger=[_query(_store(snap), snap)]) doc = compose_doc("销售额 [[v:Q1.r0.gmv]]([[v:Q1.r0.gmv|万]]),退款 [[v:Q1.r0.refunds]] 单," "编号 [[v:Q1.r0.code]],极小值 [[v:Q1.r0.tiny]]。", catalog) assert doc["violations"] == [], doc["violations"] assert doc["markdown"] == "销售额 45,678(4.57万),退款 29 单,编号 00123,极小值 0.0000001。" gmv, wan, refunds, code, tiny = [s for s in segs(doc) if s.get("ref")] assert gmv["kind"] == "number" and gmv["cite"]["value"] == 45678 and isinstance(gmv["cite"]["value"], int) assert refunds["kind"] == "number" and refunds["cite"]["value"] == 29 assert code["kind"] == "value" and code["cite"]["value"] == "00123" # 带前导零的是编号 assert tiny["kind"] == "number" and tiny["cite"]["value"] == 1e-7 assert "gmv=45,678" in catalog_prompt(catalog) @pytest.mark.parametrize("marker, words", [ ("[[v:Q1.r7.amount]]", "只有 7 行"), ("[[v:Q1.r0.gmv]]", "没有列「gmv」"), ("[[v:Q9.r0.amount]]", "查询结果 Q9 不存在"), ("[[v:K1.r0.amount]]", "不是查询结果"), ("[[v:Q1.amount]]", "单元格引用格式无法识别"), ("[[v:Q2.r0.amount]]", "无法读取"), ("[[v:Q1.r0.region|万]]", "不是数"), ("[[v:N:calc.total]]", CODE_REASON), ("[[v:N:fetch.total]]", "不支持引用节点字段"), ]) def test_unresolvable_cells_say_why(catalog, marker, words): doc = compose_doc(f"值 {marker}。", catalog) [(ref, message)] = bad(doc).items() assert ref == marker[2:-2] and words in message, message [seg] = [s for s in segs(doc) if s.get("ref")] assert seg["state"] == "none" and seg["issue"] == "unresolved_ref" and seg["text"].startswith("⟦?v:") def test_reasons_for_people_and_rewrite_instructions_for_the_writer_are_separate(catalog): """解析不了的原因给人看(证据面板、出具横幅、节点报错),写法指令只交回写作者(模型)改写: 「单元格要写成 Q<编号>.r<行>.<列>」这类话不上界面,也不存进文档。""" from app.engine.evidence import describe_violations doc = compose_doc("值 [[v:Q1.amount]],另见 [[v:Q9.r0.amount]]。", catalog) by_ref = {v["ref"]: v for v in doc["violations"] if v["code"] == "unresolved_ref"} shape, missing = by_ref["v:Q1.amount"], by_ref["v:Q9.r0.amount"] assert shape["message"] == "引用 [[v:Q1.amount]] 无法解析:单元格引用格式无法识别" assert "Q<编号>.r<行>.<列>" in shape["for_model"] and "Q<编号>" not in shape["message"] assert "查询结果 Q9 不存在" in missing["message"] and "证据目录" in missing["for_model"] for seg in segs(doc): assert "for_model" not in (seg.get("cite") or {}), "文档里只存给人看的原因" assert "Q<编号>" not in str((seg.get("cite") or {}).get("reason") or "") written = describe_violations(doc["violations"]) assert "Q<编号>.r<行>.<列>" in written and "目录里没有 Q9" in written and "单元格引用格式无法识别" not in written def test_tampered_snapshot_is_refused(stored, tmp_path): """取回时复验哈希:工件文件被改过,单元格就解析不了,而不是把改过的数当真。""" path = artifact_store._path_of(stored["snap"]) original = path.read_text(encoding="utf-8") try: path.write_text(original.replace("18230.5", "99999.5"), encoding="utf-8") catalog = build_catalog(nodes={}, ledger=[_query(stored["snap"], SNAP)]) doc = compose_doc("值 [[v:Q1.r0.amount]]", catalog) assert "与哈希不一致" in bad(doc)["v:Q1.r0.amount"] assert "99,999.5" not in doc["markdown"] finally: path.write_text(original, encoding="utf-8") def test_code_node_outputs_are_not_citable(catalog): """沙箱算出来的数不在目录里给编号,也不能按节点字段引用:要进口径卡。""" assert catalog["N:calc"]["kind"] == "node_output" and catalog["N:calc"]["code"] is True assert not any(e.get("node_id") == "calc" for a, e in catalog.items() if a.startswith("Q")) prompt = catalog_prompt(catalog) assert "N:calc" not in prompt # -------------------------------------------------------------------------- # 整表 # -------------------------------------------------------------------------- def test_table_marker_expands_into_a_table_of_cited_cells(catalog, stored): doc = compose_doc("各区域如下:\n\n[[table:Q1 cols=region,amount rows=0-2]]\n\n华东最高。", catalog) assert doc["violations"] == [], doc["violations"] assert doc["markdown"] == ("各区域如下:\n\n| region | amount |\n| --- | --- |\n| 华东 | 18,230.5 |\n" "| 华南 | 12,004 |\n| 华北 | 9,001.25 |\n\n华东最高。") assert [b["type"] for b in doc["blocks"]] == ["paragraph", "table", "paragraph"] table = doc["blocks"][1] header = [u for u in table["units"] if u["loc"]["row"] == -1] cells = [u for u in table["units"] if u["loc"]["row"] >= 0] assert len(header) == 2 and len(cells) == 6 for unit in cells: [seg] = [s for s in unit["segments"] if s["kind"] != "structural"] row, col = unit["loc"]["row"], ["region", "amount"][unit["loc"]["col"]] assert seg["ref"] == f"v:Q1.r{row}.{col}" and seg["state"] == "deterministic" assert seg["cite"]["eid"] == cell_eid(stored["snap"], row, col) assert unit["cites"] == ["Q1"] # 模型的原文里只有那一个标记,展开是系统做的 assert doc["source"].count("[[") == 1 def test_table_defaults_to_the_first_five_rows_and_all_columns(catalog): doc = compose_doc("[[table:Q1]]", catalog) assert doc["violations"] == [] table = doc["blocks"][0] assert {u["loc"]["row"] for u in table["units"]} == {-1, 0, 1, 2, 3, 4} assert {u["loc"]["col"] for u in table["units"]} == {0, 1, 2, 3} @pytest.mark.parametrize("marker, words", [ ("[[table:Q1 rows=7-9]]", "只有 7 行,没有第 7 行"), # 起始行就越界了 ("[[table:Q1 rows=3-1]]", "rows"), ("[[table:Q1 cols=region,gmv]]", "没有列「gmv」"), ("[[table:Q1 limit=3]]", "无法识别的选项"), ("[[table:Q4]]", "超过整表上限"), ("[[table:Q5]]", "0 行"), ("[[table:Q2]]", "无法读取"), ("[[table:K1]]", "不是查询结果"), ]) def test_table_problems_are_violations(catalog, marker, words): doc = compose_doc(f"见下表:\n\n{marker}", catalog) [(ref, message)] = bad(doc).items() assert ref == marker[2:-2] and words in message, message assert "|" not in doc["markdown"] def test_table_rows_past_the_end_stop_at_the_last_row(catalog): """结束行写过了头(只有 7 行却写 rows=5-9):起始行在范围内,就截到最后一行,不加注、不算违规。部署上撞过:查询回的 行比写作者以为的少,整张表报「没有第 N 行」被打回重写。""" doc = compose_doc("[[table:Q1 cols=region rows=5-9]]", catalog) assert doc["violations"] == [], doc["violations"] assert doc["markdown"] == "| region |\n| --- |\n| 东北 |\n| 中部 |" doc = compose_doc("[[table:Q1 cols=region rows=0-25]]", catalog) # 要的比上限还多,截完只剩 7 行 assert doc["violations"] == [], doc["violations"] assert {u["loc"]["row"] for u in doc["blocks"][0]["units"]} == {-1, 0, 1, 2, 3, 4, 5, 6} def test_an_old_document_with_an_overflowing_table_still_verifies_as_it_was(catalog, monkeypatch): """改动前(2 版)组装的文档:结束行越界的整表当年判为解析不了、正文是占位。按文档记下的版本复核,照旧是解析不了, 不能因为今天截得了行,就把它判成「渲染对不上」(文档被改过)。""" from app.engine import evidence as ev monkeypatch.setattr(ev, "CELL_DISPLAY", 2) old = compose_doc("[[table:Q1 cols=region rows=5-9]]", catalog) monkeypatch.undo() assert "没有第 9 行" in bad(old)["table:Q1 cols=region rows=5-9"] and "cell_display" not in old codes = [v["code"] for v in verify_doc(old, catalog)["violations"]] assert codes == ["unresolved_ref"], codes def test_table_over_the_row_limit_is_still_a_violation(): long = {"columns": ["n"], "rows": [[f"第{i}项"] for i in range(30)], "row_count": 30, "truncated": False, "elapsed_ms": 1, "sql": "SELECT n FROM items", "source": "shop"} catalog = build_catalog(nodes={}, ledger=[_query(_store(long), long)], inputs={}) doc = compose_doc("[[table:Q1 rows=0-25]]", catalog) [(ref, message)] = bad(doc).items() assert ref == "table:Q1 rows=0-25" and "最多 20 行" in message, message def test_table_written_inline_still_becomes_a_table(catalog): doc = compose_doc("见表 [[table:Q1 cols=region rows=0-1]] 如上。", catalog) assert doc["violations"] == [] assert doc["markdown"] == "见表 \n| region |\n| --- |\n| 华东 |\n| 华南 |\n 如上。" assert [b["type"] for b in doc["blocks"]] == ["paragraph", "table", "paragraph"] def test_generated_header_digits_are_not_bare_numbers(catalog): doc = compose_doc("[[table:Q3]]", catalog) assert doc["violations"] == [], doc["violations"] # 同样的字写在正文里照样要出处 doc = compose_doc("销售额2025 是多少", catalog) assert [v["code"] for v in doc["violations"]] == ["uncited_number"] # -------------------------------------------------------------------------- # 复核 # -------------------------------------------------------------------------- def test_verify_doc_rechecks_cells_independently(catalog): doc = compose_doc("华东 [[v:Q1.r0.amount]]。\n\n[[table:Q1 cols=amount rows=0-1]]", catalog) again = verify_doc(copy.deepcopy(doc), catalog) assert again["violations"] == doc["violations"] == [] and again["stats"] == doc["stats"] forged = copy.deepcopy(doc) seg = next(s for s in iter_segments(forged) if s.get("ref") == "v:Q1.r0.amount") seg["cite"]["eid"] = cell_eid("0" * 64, 0, "amount") assert "eid_mismatch" in [v["code"] for v in verify_doc(forged, catalog)["violations"]] def test_cells_can_be_refused_by_the_checker(catalog): """受管出具没声明 cells:复核按参数把每一个单元格引用(含整表里的格)判成解析不了。""" doc = compose_doc("华东 [[v:Q1.r0.amount]],指标 [[m:gmv]]。\n\n[[table:Q1 cols=amount rows=0-1]]", catalog) assert doc["violations"] == [] checked = verify_doc(doc, catalog, cells_allowed=False) refused = [v for v in checked["violations"] if v["code"] == "unresolved_ref"] assert [v["ref"] for v in refused] == ["v:Q1.r0.amount", "v:Q1.r0.amount", "v:Q1.r1.amount"] assert all(CELLS_REASON in v["message"] for v in refused) assert resolve_ref("m:gmv", catalog, cells_allowed=False)["status"] == "resolved" # 写的时候就不许:整表标记整个解析不了,不展开 early = compose_doc("[[table:Q1 cols=amount rows=0-1]] 和 [[v:Q1.r0.amount]]", catalog, cells_allowed=False) assert set(bad(early)) == {"table:Q1 cols=amount rows=0-1", "v:Q1.r0.amount"} assert all(CELLS_REASON in m for m in bad(early).values()) # -------------------------------------------------------------------------- # 流式 = 整篇 # -------------------------------------------------------------------------- STREAMED = [ "各区域如下:\n\n[[table:Q1 cols=region,amount rows=0-2]]\n\n华东 [[v:Q1.r0.amount]] 最高。", "见表 [[table:Q1 cols=region rows=0-1]] 如上。", "[[table:Q1]]", "开头[[table:Q1 rows=0-0]]", "[[table:Q1 rows=0-0]]结尾", "[[[table:Q1 rows=0-0]]]]", "两张\n[[table:Q1 cols=region rows=0-1]][[table:Q3]]\n完", "坏的 [[table:Q1 rows=0-30]] 和 [[v:Q1.r9.amount]],好的 [[v:Q1.r2.amount|万]]。", "**加粗 [[v:Q1.r0.amount]]** 与 [[m:gmv]]", ] def _chunks(text: str, rng: random.Random) -> list[str]: out, i = [], 0 while i < len(text): step = rng.randint(1, 7) out.append(text[i:i + step]) i += step return out @pytest.mark.parametrize("text", STREAMED) def test_streaming_equals_full_rendering(catalog, text): full = render_markers(text, catalog) rng = random.Random(len(text)) for _ in range(60): renderer = StreamRenderer(catalog) streamed = "".join(renderer.feed(c) for c in _chunks(text, rng)) + renderer.flush() assert streamed == full # 一个字一个字地喂也一样 renderer = StreamRenderer(catalog) assert "".join(renderer.feed(c) for c in text) + renderer.flush() == full # 不含粗体的,流里看到的就是最终文档的正文 if "**" not in text: assert compose_doc(text, catalog)["markdown"] == full.strip() def test_streaming_with_cells_refused_matches_full(catalog): text = STREAMED[0] full = render_markers(text, catalog, cells_allowed=False) renderer = StreamRenderer(catalog, cells_allowed=False) assert "".join(renderer.feed(c) for c in text) + renderer.flush() == full assert "⟦?table:Q1" in full and "⟦?v:Q1.r0.amount⟧" in full # -------------------------------------------------------------------------- # 性质:带单元格和整表的任意原文,片段铺满正文,组装和复核一致 # -------------------------------------------------------------------------- INTEGRITY = {"segment_mismatch", "structural_text", "state_mismatch", "render_mismatch", "eid_mismatch", "bad_schema"} def _corpus(n: int) -> list[str]: rng = random.Random(20260928) alphabet = [*"我们。!?\n- *#>|`[]:0123 .,", "[[v:Q1.r0.amount]]", "[[v:Q1.r1.note]]", "[[v:Q1.r9.x]]", "[[table:Q1 cols=region rows=0-1]]", "[[table:Q3]]", "[[table:Q1 rows=0-40]]", "[[m:gmv]]", "**", "\n\n", "| a |", "[[see:Q1]]"] return ["".join(rng.choice(alphabet) for _ in range(rng.randint(1, 30))) for _ in range(n)] @pytest.mark.parametrize("raw", _corpus(250)) def test_composition_invariants_with_cells(catalog, raw): doc = compose_doc(raw, catalog) assert "".join(s["text"] for s in iter_segments(doc)) == doc["markdown"] again = verify_doc(copy.deepcopy(doc), catalog) assert again["violations"] == doc["violations"] and again["stats"] == doc["stats"] assert not INTEGRITY & {v["code"] for v in doc["violations"]}, doc["violations"] # -------------------------------------------------------------------------- # 给写作者看的目录 # -------------------------------------------------------------------------- def test_catalog_prompt_shows_query_rows_and_how_to_cite(catalog): prompt = catalog_prompt(catalog) assert "Q1" in prompt and "region, amount, orders, note" in prompt assert "[[v:Q1.r0.amount]]" in prompt and "[[table:Q1" in prompt assert "r0" in prompt and "华东" in prompt and "18,230.5" in prompt assert "Q2" in prompt and "无法读取" in prompt refused = catalog_prompt(catalog, cells_allowed=False) assert "[[v:" not in refused and "[[see:Q1]]" in refused def test_cell_rules_only_show_when_there_are_cells_to_cite(catalog): """写作规则本身还是一期那份:升级前发起的运行在升级后跑到报告节点,提示一字不差;只有口径卡的目录 也不该教写作者去引用不存在的 Q1。单元格和整表的写法跟着目录里的查询出现。""" from app.engine.evidence import MARKER_RULES assert "[[v:" not in MARKER_RULES and "[[table:" not in MARKER_RULES and "Q1" not in MARKER_RULES for kinds in (("metric", "input"), ("metric", "retrieval")): partial = {a: e for a, e in catalog.items() if e.get("kind") in kinds} prompt = catalog_prompt(partial) + MARKER_RULES assert "[[v:" not in prompt and "[[table:" not in prompt, kinds full = catalog_prompt(catalog) assert "[[v:Q1.r0.列名]]" in full and "[[table:Q1 cols=列a,列b rows=0-4]]" in full refused = catalog_prompt(catalog, cells_allowed=False) assert "[[v:" not in refused and "[[table:" not in refused