mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
报告里照抄查询结果中文本格的名字(「冲压5线」「KX-205 线」)时,名字里的数字不再算裸数字:目录里每次查询的快照中、文本格里带数字的值(至少一个数字、至少一个不是数字的字,本身不是数也不是带单位的数),在正文里一字不差地出现、前后不紧挨着数字的那几段,查裸数字时涂白。组装和出口复核用同一条规则;只认快照里真有的值,不做模糊匹配,同一个数单独写出来照样要出处,数值格不受影响
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user