报告里照抄查询结果中文本格的名字(「冲压5线」「KX-205 线」)时,名字里的数字不再算裸数字:目录里每次查询的快照中、文本格里带数字的值(至少一个数字、至少一个不是数字的字,本身不是数也不是带单位的数),在正文里一字不差地出现、前后不紧挨着数字的那几段,查裸数字时涂白。组装和出口复核用同一条规则;只认快照里真有的值,不做模糊匹配,同一个数单独写出来照样要出处,数值格不受影响

This commit is contained in:
ThearchyHelios
2026-10-19 17:06:08 +08:00
parent 8f6a90c8c2
commit baf93ddfed
2 changed files with 123 additions and 3 deletions
+50 -3
View File
@@ -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(
@@ -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"]