diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index d0a33404..522894d0 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -61,7 +61,7 @@ from app.data.provenance_types import DOC_PROVENANCE from app.data.tabular import UNSHAPED_NOTE from app.engine.expressions import CellError, cell_value, column_kind, locate_cell from app.engine.guards import FALLBACK_CONTEXT -from app.engine.issuance import _tolerance, extract_numbers, number_allowance +from app.engine.issuance import _CN_COUNT_UNIT, _tolerance, extract_numbers, number_allowance from app.engine.labels import option_label DOC_SCHEMA = "agentlab.report/1" @@ -2872,7 +2872,8 @@ def compose_doc( unit["segments"] = _link_entities(unit["segments"], catalog, index) all_segments = [s for block in out_blocks for unit in block["units"] for s in unit["segments"]] # 裸数字单独切成片段:前端要在那几个字底下画「无出处」 - refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + _header_masks(out_blocks, catalog) + refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + _header_masks(out_blocks, catalog) \ + + _name_masks(markdown, catalog, snaps) syntax = [tuple(s["span"]) for s in all_segments if s["kind"] == "structural"] bare = _bare_numbers(markdown, refs, allow_numbers, syntax) for block in out_blocks: @@ -2953,6 +2954,52 @@ def _header_masks(blocks: list[dict[str, Any]], catalog: dict[str, Any]) -> list return out +#: 文本格的值本身是数、或者是带单位的数(「120台」「3.5万元」「45%」「约 30 天」「205L」):不算名字(_name_masks),照抄进 +#: 正文的那个数照样要出处。单位认出具校验数量词那一套(issuance._CN_COUNT_UNIT),再加百分号、万亿千百和 1–3 个拉丁字母 +_UNIT_VALUE = re.compile( + r"^(?:约|共|近|超|逾|合计|总计|≈|~)?\s*[+-]?(?:\d[\d,]*(?:\.\d+)?|\.\d+)\s*" + r"(?:[%%‰]|[万亿千百]?(?:" + _CN_COUNT_UNIT.pattern + r"|[A-Za-z]{1,3}))?\s*$") + + +def _text_names(catalog: dict[str, Any], snaps: _Snapshots) -> set[str]: + """目录里每次查询的快照中,文本格里带数字的值(「冲压5线」「KX-205 线」):至少有一个数字、至少有一个不是数字的字, + 本身不是数、也不是带单位的数(_UNIT_VALUE)。按写作目录里显示的样子(压成一行)收。取不回的快照跳过。""" + names: set[str] = set() + for alias, entry in catalog.items(): + if not isinstance(entry, dict) or entry.get("kind") != "query": + continue + snapshot, why = snaps.get(alias, entry.get("artifact")) + if why or not isinstance(snapshot, dict): + continue + columns = [str(c) for c in snapshot.get("columns") or []] + kinds = [column_kind(snapshot, c) for c in columns] + for row in snapshot.get("rows") or []: + cells = row if isinstance(row, list) else [row.get(c) for c in columns] if isinstance(row, dict) else [] + for raw, kind in zip(cells, kinds): + if not isinstance(raw, str) or cell_value(raw, kind) is not raw: + continue + text = _clean(raw) + if any(c.isdigit() for c in text) and not text.isdigit() and not _UNIT_VALUE.match(text): + names.add(text) + return names + + +def _name_masks(markdown: str, catalog: dict[str, Any], snaps: _Snapshots) -> list[tuple[int, int]]: + """正文里一字不差地写出了查询结果中文本格的值(_text_names)的那几段:名字里的数字不是结论数字,写作者照着查询结果 + 写名字,不该因为名字里有个 5 被打回重写。只认快照里真有的值、整段一字不差,前后紧挨着数字的不算(「冲压15线」不是 + 「冲压5线」);同一个数单独写出来照样要出处。组装和复核用同一份目录,放过的范围一致。""" + out: list[tuple[int, int]] = [] + for name in _text_names(catalog, snaps) if any(c.isdigit() for c in markdown) else (): + at = markdown.find(name) + while at >= 0: + end = at + len(name) + before, after = markdown[at - 1:at], markdown[end:end + 1] + if not (before.isdigit() or before == "." or after.isdigit()): + out.append((at, end)) + at = markdown.find(name, at + 1) + return out + + def _split_bare(segments: list[dict[str, Any]], bare: list[Any]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for seg in segments: @@ -3219,7 +3266,7 @@ def verify_doc( if tiled and pos != len(markdown): violations.append(_violation("segment_mismatch", "正文末尾有一段不属于任何片段,文档可能被修改过")) - masks += _header_masks(list(doc.get("blocks") or []), catalog) + masks += _header_masks(list(doc.get("blocks") or []), catalog) + _name_masks(markdown, catalog, snaps) for token in _bare_numbers(markdown, masks, allow_numbers, syntax): owner = next(((sid, uid) for s, e, sid, uid in owners if s <= token.start < e), (None, None)) violations.append(_violation( diff --git a/backend/tests/test_report_text_cell_numbers.py b/backend/tests/test_report_text_cell_numbers.py new file mode 100644 index 00000000..b57302b1 --- /dev/null +++ b/backend/tests/test_report_text_cell_numbers.py @@ -0,0 +1,73 @@ +"""查询结果里文本格的名字(「冲压5线」「KX-205 线」)照抄进报告时,名字里的数字不算裸数字。 + +部署上撞过:查询结果的产线名里带数字,写作者照着写「冲压5线的产量 [[v:…]]」,名字里的那个 5 被当成没有出处的数字打回 +重写,重写还是同一个名字。现在:写作目录的查询结果里、文本格的值(一字不差的子串;值里至少有一个不是数字的字,本身 +不是数、也不是带单位的数)出现在正文里时,那一段不查裸数字。只认快照里真有的值,不做模糊匹配:同一个数单独写出来照样 +要出处;数值格不受影响。数据是虚构的。 +""" +from __future__ import annotations + +import pytest + +from app.core import artifact_store +from app.engine.evidence import build_catalog, compose_doc, verify_doc + +SNAP = { + "columns": ["产线", "产量", "备注"], + "column_types": {"产线": "text", "产量": "number", "备注": "text"}, + "rows": [ + ["冲压5线", 1520, "120台"], # 带单位的数:不算名字 + ["KX-205 线", 830, "45%"], + ["焊装 2 线", 64, "3.5万元"], + ], + "row_count": 3, "truncated": False, "elapsed_ms": 2, + "sql": "SELECT line AS 产线, SUM(qty) AS 产量, MAX(memo) AS 备注 FROM output GROUP BY line", + "source": "plant", +} + + +def _store(snap: dict) -> str: + 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 +def catalog(): + art = _store(SNAP) + ledger = [{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": art, "via": "b" * 64, "call_id": "call_1", + "tool": "db_query__plant", "source": "plant", "columns": SNAP["columns"], "rows": 3, "truncated": False}] + return build_catalog(nodes={}, ledger=ledger, inputs={}) + + +def _uncited(doc) -> list[str]: + return [v["text"] for v in doc["violations"] if v["code"] == "uncited_number"] + + +def test_a_number_inside_a_name_from_the_results_is_allowed(catalog): + text = "冲压5线的产量是 [[v:Q1.r0.产量]],KX-205 线是 [[v:Q1.r1.产量]],焊装 2 线是 [[v:Q1.r2.产量]]。[[see:Q1]]" + doc = compose_doc(text, catalog) + assert _uncited(doc) == [], doc["violations"] + assert verify_doc(doc, catalog)["ok"], "出口复核按同一条规则:组装时放过的,复核也放过" + + +def test_the_same_number_on_its_own_still_needs_a_citation(catalog): + doc = compose_doc("冲压5线有 5 个班组,产量 [[v:Q1.r0.产量]]。[[see:Q1]]", catalog) + assert _uncited(doc) == ["5"] + # 名字只认一字不差的:快照里没有「冲压15线」,15 照样要出处;名字前后紧挨着数字的也不算那个名字 + assert _uncited(compose_doc("冲压15线的产量 [[v:Q1.r0.产量]]。", catalog)) == ["15"] + assert _uncited(compose_doc("KX-2050 线的产量 [[v:Q1.r1.产量]]。", catalog)) == ["2050"] + assert _uncited(compose_doc("焊装 2 线以外还有 2 线。", catalog)) == ["2"] + + +def test_numbers_and_numbers_with_units_in_text_cells_are_not_names(catalog): + doc = compose_doc("备注里写着 120台、45%、3.5万元。", catalog) + assert _uncited(doc) == ["120", "45%", "3.5万"], doc["violations"] + + +def test_numeric_cells_are_unaffected(catalog): + # 产量格的 1520 是数值格:照抄成裸数字照样违规 + assert _uncited(compose_doc("冲压5线的产量是 1520。", catalog)) == ["1520"]