Files
AgentLab/backend/tests/test_report_fenced.py
T
ThearchyHelios 9f0e453884 整篇包在 ``markdown 里、中间再套一段一样长的 ``sql 时照样去掉外层围栏:带信息串的一行开、不带的一行收,两两配对。以前一样长的内层围栏一律不去,模型最常见的这种写法仍是 0 句结论,裁判和反方审稿没有句子可审,正式运行照样出具
规则:中间和外层同种符号的围栏行,比外层短的跳过,比外层长的不去(规格原文),一样长的按配对看——带信息串的开一个代码块,不带的收掉它;没有可收的 ``` 说明外层在这里就收了(好几个代码块),开着时又来一个 ```sql、扫到最后还开着的,都不去。去掉外层后 compose_doc 和前端每见到一行 ``` 开合一次,配好的一对正好切成一个代码块,原 docstring「见到 ``` 就收」的理由不成立,改准确了。

一样长的行只认 compose_doc 和前端认得的写法(信息串是紧跟着的一个词):``` sql 这种它们不认,算一行正文。不然它会和后面的 ``` 配成一对、去掉外层,而后面那个 ``` 在它们眼里成了开头,把结论句吞进代码块。比外层长的照旧按行首判,````sql title 这样的也不去。

外层反引号里套 ~~~、四个以上反引号的代码块照常去掉外层:compose_doc 和前端只认 ``` 围栏,这段按正文核对,和模型不包外层、直接这么写时一样,比整篇一个代码块强;docstring 写明,测试钉住。

测试:same-fence-inside 挪到去的一边,补多段内层代码块、~~~ 里套 ~~~、反引号里套 ~~~;不去的补没有可收的 ```、开着时又来 ```python、内层没收、``` sql 开头、更长的带词围栏。compose_doc 一级补 ```markdown 套 ```sql(SQL 仍是代码块,结论句 2 句)和 ~~~ 块按正文核对。端到端(正式运行,裁判 + 反方审稿 + 出具契约)加一组套 ```sql 的写法:两句都判、都审,完整出具。裁判退回改写(rewrite_once)那一稿也包在代码块里时照样去掉:改写稿被采用,第二轮判到改过的那句,以前这条路没有测试守着。每条判断各做过一次去掉、测试变红。
2026-10-06 13:32:38 +08:00

474 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""写作模型把整篇报告包在一个代码块里(```markdown … ```):去掉外层围栏,再交给 compose_doc 核对。
不去的话裸数字照样被标出来(代码块里的数字也查),但整篇成了一个代码块,结论句一句都没有:证据裁判、反方审稿
没有句子可审(反方审稿记 challenge_skipped / no_claims),正式运行照样出具,界面上整篇显示成代码。
- 去:去掉首尾空白后整篇正好是一个围栏代码块(``` 或 ~~~,收尾围栏和开头一字不差),信息串为空、markdown 或 md
(大小写不敏感),围栏外没有别的字;重写、裁判退回改写的稿子同样处理。正文自己的代码块(```sql … ```)可以套在
里面,和外层一样长也行
- 不去:信息串是别的语言(sql、json……)、围栏外还有字、有多个代码块、开闭围栏对不上、里面嵌着更长的同种围栏、
里面和外层一样长的围栏配不成对——这些是模型真要贴一段代码,或者分不清哪一行收尾,原样交给 compose_doc
- doc["source"] 记去掉围栏之后的正文:markdown 就是从它渲染出来的,出口契约复核取回的也是这一份文档
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额都是虚构的。
"""
from __future__ import annotations
import asyncio
import json
import re
import sqlite3
import uuid
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from sqlalchemy import delete, select
from app.core import artifact_store
from app.data import introspect
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent, Setting
from app.engine import judge as judge_mod
from app.engine import spend, verify_agent
from app.engine.evidence import iter_segments, iter_units
from app.engine.judge import JUDGE_ROLE
from app.engine.judge import SPEND_KEY as JUDGE_SPEND_KEY
from app.engine.nodes import challenge as challenge_mod
from app.engine.nodes import report as report_mod
from app.engine.runner import run_manager, verify_manifest
from app.providers.mock_model import MockChatModel
from tests.fixtures.sources import drop_source
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_(
[spend.SPEND_KEY, JUDGE_SPEND_KEY, "verify", "judge", "copilot"])))
await session.commit()
yield
await run_manager.shutdown()
# --------------------------------------------------------------------------
# 哪些去、哪些不去
# --------------------------------------------------------------------------
BODY = "## 本周概览\n\n华东销售额 [[m:gmv]],居各区域之首。[[see:m:gmv]]"
UNWRAPPED = [
pytest.param(f"```markdown\n{BODY}\n```", BODY, id="markdown"),
pytest.param(f"```\n{BODY}\n```", BODY, id="no-info"),
pytest.param(f"~~~\n{BODY}\n~~~", BODY, id="tilde"),
pytest.param(f"~~~md\n{BODY}\n~~~", BODY, id="tilde-md"),
pytest.param(f"```Markdown\n{BODY}\n```", BODY, id="case-insensitive"),
pytest.param(f"```MD\n{BODY}\n```", BODY, id="upper-md"),
pytest.param(f"``` markdown \n{BODY}\n```", BODY, id="spaced-info"),
pytest.param(f"````markdown\n{BODY}\n````", BODY, id="four-backticks"),
pytest.param(f"\n\n```markdown\n{BODY}\n```\n\n", BODY, id="blank-lines-around"),
pytest.param(f"```markdown\r\n{BODY}\r\n```\r\n".replace("\n\n", "\r\n\r\n"), BODY, id="crlf"),
pytest.param(f"```markdown\n{BODY}\n ```", BODY, id="indented-close"),
# 正文里自己的代码块套在里面:外层更长、换一种符号的,外层收在哪一行没有疑问;和外层一样长的,带信息串的一行
# (```sql)开、不带的一行收,两两配对——去掉外层后 compose_doc 和前端也是这样切的。里面的原样留下
pytest.param("````markdown\n## 口径\n\n```sql\nSELECT 1\n```\n\n结论如上。\n````",
"## 口径\n\n```sql\nSELECT 1\n```\n\n结论如上。", id="shorter-fence-inside"),
pytest.param("~~~markdown\n## 口径\n\n```sql\nSELECT 1\n```\n\n结论如上。\n~~~",
"## 口径\n\n```sql\nSELECT 1\n```\n\n结论如上。", id="other-fence-inside"),
pytest.param("```markdown\n## 口径\n\n```sql\nSELECT 1\n```\n\n结论如上。\n```",
"## 口径\n\n```sql\nSELECT 1\n```\n\n结论如上。", id="same-fence-inside"),
pytest.param("```markdown\n华东增长最快。\n\n```sql\nSELECT 1\n```\n\n华南次之。\n\n```python\nprint(2)\n```\n```",
"华东增长最快。\n\n```sql\nSELECT 1\n```\n\n华南次之。\n\n```python\nprint(2)\n```",
id="same-fence-blocks-inside"),
pytest.param("~~~md\n## 口径\n\n~~~sql\nSELECT 1\n~~~\n\n结论如上。\n~~~",
"## 口径\n\n~~~sql\nSELECT 1\n~~~\n\n结论如上。", id="same-tilde-inside"),
# 外层反引号里套 ~~~:照样去(compose_doc 不认 ~~~,这段按正文核对,见 test_a_tilde_block_inside_reads_as_prose)
pytest.param("```markdown\n## 口径\n\n~~~sql\nSELECT 1\n~~~\n\n结论如上。\n```",
"## 口径\n\n~~~sql\nSELECT 1\n~~~\n\n结论如上。", id="tilde-inside-backticks"),
]
KEPT = [
pytest.param("```sql\nSELECT region, SUM(amount) AS gmv FROM orders GROUP BY region\n```", id="sql"),
pytest.param("```python\nprint('华东')\n```", id="python"),
pytest.param('```json\n{"region": "华东"}\n```', id="json"),
pytest.param(f"```text\n{BODY}\n```", id="text"),
pytest.param(f"```markdown 周报\n{BODY}\n```", id="info-with-words"),
pytest.param(f"以下是本周报告:\n\n```markdown\n{BODY}\n```", id="preamble"),
pytest.param(f"```markdown\n{BODY}\n```\n\n以上。", id="afterword"),
pytest.param("```markdown\n## 上半\n```\n\n```markdown\n## 下半\n```", id="two-blocks"),
pytest.param(f"```markdown\n{BODY}\n~~~", id="mismatched-char"),
pytest.param(f"````markdown\n{BODY}\n```", id="shorter-close"),
pytest.param(f"```markdown\n{BODY}\n````", id="longer-close"),
pytest.param(f"```markdown\n{BODY}", id="unclosed"),
pytest.param(f"```markdown\n{BODY}\n ```", id="close-indented-as-code"),
pytest.param("```markdown\n## 口径\n\n````sql\nSELECT 1\n````\n\n结论如上。\n```", id="longer-fence-inside"),
pytest.param("```markdown\n## 口径\n\n````sql title\nSELECT 1\n\n结论如上。\n```", id="longer-fence-with-words-inside"),
# 和外层一样长的围栏配不成对:没有代码块可收的 ```(外层在这里就收了,后面是围栏外的字)、开着的代码块里又来一个
# ```python(compose_doc 会拿它当收尾)、代码块开了没收、开头写成 ``` sql(compose_doc 不认,后面那个 ``` 反倒
# 成了开头,把结论句吞进代码块)
pytest.param("```markdown\n华东增长最快。\n```\n华南次之。\n```", id="bare-fence-inside"),
pytest.param("```markdown\n## 口径\n\n```sql\nSELECT 1\n```python\nprint(2)\n```\n\n结论如上。\n```",
id="opener-inside-open-block"),
pytest.param("```markdown\n## 口径\n\n```sql\nSELECT 1\n\n结论如上。\n```", id="inner-block-left-open"),
pytest.param("```markdown\n## 口径\n\n``` sql\nSELECT 1\n```\n\n结论如上。\n```", id="inner-opener-unreadable"),
pytest.param("```markdown```", id="one-line"),
pytest.param(BODY, id="plain"),
pytest.param("", id="empty"),
]
@pytest.mark.parametrize("raw, body", UNWRAPPED)
def test_a_report_wrapped_in_one_markdown_block_is_unwrapped(raw, body):
assert report_mod._unfenced(raw) == body
@pytest.mark.parametrize("raw", KEPT)
def test_anything_else_is_handed_over_untouched(raw):
assert report_mod._unfenced(raw) is raw, "不去的一个字都不动:compose_doc 收到的和以前一样,文档哈希也不变"
def test_the_unwrapped_body_reads_as_sentences():
"""去掉围栏后切出的是标题和结论句,不再是一个代码块;裸数字照样被标出来。"""
from app.engine.evidence import compose_doc
raw = "```markdown\n## 本周概览\n\n华东销售额 1500,居各区域之首。华南销售额超过华东。\n```"
wrapped = compose_doc(raw, {}, node_id="write")
assert [b["type"] for b in wrapped["blocks"]] == ["code"] and wrapped["stats"]["claims"] == 0, "背景:不去时整篇是代码"
doc = compose_doc(report_mod._unfenced(raw), {}, node_id="write")
assert [b["type"] for b in doc["blocks"]] == ["heading", "paragraph"]
assert doc["stats"]["claims"] == 2
assert [(v["code"], v["text"]) for v in doc["violations"]] == [("uncited_number", "1500")]
assert doc["source"] == "## 本周概览\n\n华东销售额 1500,居各区域之首。华南销售额超过华东。"
def test_a_code_block_inside_stays_a_code_block():
"""模型常见的写法:```markdown 里再套一段 ```sql,内外围栏一样长。去掉外层后,compose_doc 每见到一行 ``` 开合一次,
```sql … ``` 正好切成一个代码块,前后两句是结论;不去的话外层被第一个 ``` 收掉,两句结论都落在代码块里。"""
from app.engine.evidence import compose_doc
sql = "```sql\nSELECT region, SUM(amount) FROM sales GROUP BY region\n```"
body = f"## 本周概览\n\n华东销售额 1500,居各区域之首。\n\n{sql}\n\n华南销售额超过华东。"
raw = f"```markdown\n{body}\n```"
wrapped = compose_doc(raw, {}, node_id="write")
assert [b["type"] for b in wrapped["blocks"]] == ["code", "paragraph", "code"], "背景:不去时结论句都在代码块里"
assert wrapped["stats"]["claims"] == 0
doc = compose_doc(report_mod._unfenced(raw), {}, node_id="write")
assert [(b["type"], b.get("lang")) for b in doc["blocks"]] == [
("heading", None), ("paragraph", None), ("code", "sql"), ("paragraph", None)]
assert doc["stats"]["claims"] == 2 and doc["source"] == body
assert [(v["code"], v["text"]) for v in doc["violations"]] == [("uncited_number", "1500")]
def test_a_tilde_block_inside_reads_as_prose():
"""外层反引号里套一段 ~~~sql:照样去掉外层,真正的结论句才有得审。compose_doc 和前端的切块只认 ``` 围栏,这段 ~~~
按正文切,和模型不包外层、直接这么写时一样:SQL 当一段话核对(会送去裁判、反方审稿),数字照样查。
compose_doc 认得 ~~~ 以后这里跟着改。"""
from app.engine.evidence import compose_doc
raw = "```markdown\n## 口径\n\n~~~sql\nSELECT region FROM sales WHERE amount > 1000\n~~~\n\n华东销售额超过华南。\n```"
doc = compose_doc(report_mod._unfenced(raw), {}, node_id="write")
assert [b["type"] for b in doc["blocks"]] == ["heading", "paragraph", "paragraph"]
md = doc["markdown"]
claims = [md[u["span"][0]:u["span"][1]] for _, u in iter_units(doc) if u["kind"] == "claim"]
assert "华东销售额超过华南。" in claims
assert [(v["code"], v["text"]) for v in doc["violations"]] == [("uncited_number", "1000")]
# --------------------------------------------------------------------------
# 报告节点:写作、重写都经过这一步
# --------------------------------------------------------------------------
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
KPI = "{'gmv': 45678.5, 'orders': 1234}"
METRICS = [
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"},
{"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.orders"},
]
CLEAN = "## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]],订单 [[m:orders]]。[[see:m:gmv,m:orders]]"
RENDERED = "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,订单 1,234单。"
#: 一个裸数字:写作者自己抄了一遍销售额
BARE = "## 本周概览\n\n本周销售额 45678 元,订单 [[m:orders]],较上周有所增长。[[see:m:orders]]"
def weekly(**report) -> dict:
"""输入 → 取数 → 口径卡 → 报告 → 成果。"""
return chain(
node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]),
node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"),
node("caliber", "metrics", caliber="周报口径", caliber_version="v1", metrics=METRICS),
node("write", "report", instructions="为 {{ input.week }} 写周报", **report),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
)
def script(monkeypatch, *replies) -> list[list]:
"""写作模型依次回这几段,用完了一直回最后一段。返回每次调用收到的消息。"""
seen: list[list] = []
def decide(model, messages):
seen.append(list(messages))
return AIMessage(content=replies[min(len(seen), len(replies)) - 1])
monkeypatch.setattr(MockChatModel, "_decide", decide)
return seen
async def finish(graph, *, run_class="exploratory", options=None) -> Run:
run = await run_manager.start(graph=graph, input_payload={}, run_class=run_class, options=options)
for _ in range(600):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run.id)
# 状态先提交、封存随后才写:终态要等 manifest_seq 落下来再看
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
await run_manager.wait_idle(run.id)
return row
raise AssertionError(f"运行 {run.id} 没跑完:{row.status}")
async def events(run_id: str, etype: str, node_id: str | None = None) -> list[RunEvent]:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype)
if node_id:
q = q.where(RunEvent.node_id == node_id)
return list((await session.execute(q.order_by(RunEvent.seq))).scalars())
async def logs(run_id: str, code: str) -> list[dict]:
return [e.data for e in await events(run_id, "log") if e.data.get("code") == code]
async def report_of(run_id: str) -> tuple[dict, dict]:
"""(report.checked 的载荷, 取回的文档)。"""
[checked] = await events(run_id, "report.checked", "write")
return checked.data, artifact_store.load(checked.data["doc_artifact"])
async def output_of(run_id: str, node_id: str) -> dict:
finished = await events(run_id, "node.finished", node_id)
return artifact_store.load(finished[-1].data["artifact"])
def fenced(text: str, info: str = "markdown", fence: str = "```") -> str:
return f"{fence}{info}\n{text}\n{fence}"
@pytest.mark.parametrize("fence, info", [("```", "markdown"), ("```", ""), ("~~~", "md")])
async def test_the_node_checks_the_body_inside_the_fence(monkeypatch, fence, info):
"""裸数字照样标出、引用照常渲染;文档里没有代码块,source 是去掉围栏之后的正文。"""
script(monkeypatch, fenced(BARE, info, fence))
row = await finish(weekly(max_repairs=0))
assert row.status == "succeeded", row.error
checked, doc = await report_of(row.id)
assert [b["type"] for b in doc["blocks"]] == ["heading", "paragraph"]
assert doc["stats"]["claims"] == 1 and doc["stats"]["headings"] == 1
assert doc["source"] == BARE
assert doc["markdown"] == "## 本周概览\n\n本周销售额 45678 元,订单 1,234单,较上周有所增长。"
assert "```" not in doc["markdown"] and "~~~" not in doc["markdown"]
bare = [(s["text"], s["state"]) for s in iter_segments(doc) if s.get("issue") == "uncited_number"]
assert bare == [("45678", "none")], "去掉的只是围栏:自己写的数字照样标成没有出处"
cited = [(s["text"], s["cite"]["locator"].get("metric")) for s in iter_segments(doc) if s["kind"] == "number"
and s.get("cite")]
assert cited == [("1,234单", "orders")]
assert [v["code"] for v in checked["violations"]] == ["uncited_number"] and checked["ok"] is False
assert row.output["周报"] == doc["markdown"], "交给下游的是渲染后的正文,不带围栏"
async def test_rewrites_are_unwrapped_too(monkeypatch):
"""第一稿带裸数字、重写稿干净,两稿都包在代码块里:重写一次就通过,封存的是去掉围栏的重写稿。"""
seen = script(monkeypatch, fenced(BARE), fenced(CLEAN))
row = await finish(weekly(on_violation="fail"), run_class="formal")
assert row.status == "succeeded", row.error
assert len(seen) == 2, "只为裸数字重写一次,围栏本身不算违规"
repair = seen[1][-1]
assert isinstance(repair, HumanMessage) and "45678" in str(repair.content)
[warn] = await logs(row.id, "report_repair")
assert "1 处" in warn["message"] and "45678" in warn["message"]
checked, doc = await report_of(row.id)
assert checked["repairs"] == 1 and checked["ok"] is True and doc["violations"] == []
assert doc["source"] == CLEAN and doc["markdown"] == RENDERED
assert [b["type"] for b in doc["blocks"]] == ["heading", "paragraph"] and doc["stats"]["claims"] == 1
@pytest.mark.parametrize("raw", [
pytest.param("```sql\nSELECT region, SUM(amount) AS gmv FROM orders GROUP BY region\n```", id="sql"),
pytest.param(f"以下是本周报告:\n\n{fenced(CLEAN)}", id="preamble"),
pytest.param(f"{fenced('## 上半')}\n\n{fenced('## 下半')}", id="two-blocks"),
])
async def test_the_node_keeps_other_code_blocks_as_written(monkeypatch, raw):
script(monkeypatch, raw)
row = await finish(weekly(max_repairs=0))
assert row.status == "succeeded", row.error
_, doc = await report_of(row.id)
assert doc["source"] == raw
assert "code" in [b["type"] for b in doc["blocks"]]
# --------------------------------------------------------------------------
# 端到端:裁判和反方审稿有句子可审
# --------------------------------------------------------------------------
PRICED = "claude-sonnet-5"
QUERY = "SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales GROUP BY region ORDER BY 销售额 DESC"
NET = ("SELECT region AS 区域, SUM(amount) AS 净销售额 FROM sales WHERE refunded = 0 GROUP BY region "
"ORDER BY 净销售额 DESC")
TEXT = "华东销售额 [[v:Q1.r0.销售额]],居各区域之首。[[see:Q1]]华南销售额超过华北。[[see:Q1]]"
EAST, SOUTH = "华东销售额 1,500,居各区域之首。", "华南销售额超过华北。"
SQL = "```sql\nSELECT region AS 区域, SUM(amount) AS 销售额 FROM sales GROUP BY region\n```"
#: 两句结论中间贴一段 SQL,外面再包 ```markdown:内外围栏一样长
NESTED = f"华东销售额 [[v:Q1.r0.销售额]],居各区域之首。[[see:Q1]]\n\n{SQL}\n\n华南销售额超过华北。[[see:Q1]]"
#: 裁判退回后的改写稿:只改了华南那句,补上数字
REWRITTEN = "华东销售额 [[v:Q1.r0.销售额]],居各区域之首。[[see:Q1]]华南销售额 [[v:Q1.r1.销售额]],高于华北。[[see:Q1]]"
NEW_SOUTH = "华南销售额 900,高于华北。"
@pytest.fixture
async def shop(tmp_path):
"""只读的手工源:华东两笔(其中一笔 300 已退款)、华南一笔 900、华北一笔 400。返回源名。"""
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript(
"CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, refunded INTEGER);"
"INSERT INTO sales VALUES (1, '华东', 1200, 0), (2, '华东', 300, 1), (3, '华南', 900, 0), (4, '华北', 400, 0);")
db.commit()
db.close()
name = f"fenced_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options={})
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name
await drop_source(source_id)
class Models:
"""写作者、裁判、反查 agent 三份剧本。写作者依次回 writer 里的几段(用完了一直回最后一段);裁判按句子里的字给判定
(verdicts:{句中片段: 判定},没对上的都判「证据支持」);反查 agent 每句先查一次扣除退款后的销售额,再交「经受住」。
writes 记下写作者每次收到的消息,judges 记下裁判收到的请求,reviewed 记下反查 agent 审过的句子。"""
def __init__(self, monkeypatch, source: str, *writer: str, verdicts: dict[str, str] | None = None):
self.writes: list[list] = []
self.judges: list[str] = []
self.reviewed: list[str] = []
verdicts = verdicts or {}
def decide(model, messages):
system = str(messages[0].content)
if JUDGE_ROLE in system:
request = str(messages[-1].content)
self.judges.append(request)
items = []
for uid, line in re.findall(r"^\[(u\d+)\]([^\n]*)$", request, re.M):
status = next((s for frag, s in verdicts.items() if frag in line), "supported")
items.append({"unit": uid, "verdict": status, "used": [],
"rationale": "与证据一致。" if status == "supported" else "证据不足以支持这句话。"})
return AIMessage(content=json.dumps({"items": items}, ensure_ascii=False))
if challenge_mod.CHALLENGE_SYSTEM in system:
claim = next((c for c in (EAST, SOUTH) if c in str(messages[1].content)), "")
if model.response_format:
self.reviewed.append(claim)
return AIMessage(content=json.dumps({"verdict": "stands", "rationale": "没有找到反例。"},
ensure_ascii=False))
if not any(isinstance(m, ToolMessage) for m in messages):
call = {"name": f"db_query__{source}", "args": {"sql": NET}, "id": f"q-{len(self.reviewed)}"}
return AIMessage(content="查一下。", tool_calls=[call])
return AIMessage(content="查完了。")
if report_mod.ROLE in system:
self.writes.append(list(messages))
return AIMessage(content=writer[min(len(self.writes), len(writer)) - 1])
raise AssertionError(f"没想到的模型调用:{system[:60]}")
monkeypatch.setattr(MockChatModel, "_decide", decide)
async def priced(session, spec):
return MockChatModel(model_name=PRICED), PRICED
monkeypatch.setattr(judge_mod, "get_chat_model", priced)
monkeypatch.setattr(verify_agent, "get_chat_model", priced)
def audited(source: str, *, challenge: bool = True, **report) -> dict:
"""取数 → 报告(逐句裁判,report 是另加的节点配置)→ 反方审稿(challenge=False 时不要)→ 成果(引用模式的出具
契约,按报告文档复核)。"""
return chain(
node("start", "input"),
node("fetch", "tool", tool=f"db_query__{source}", args={"sql": QUERY}),
node("write", "report", "写周报", instructions="写周报", claims="judge", **report),
*([node("chal", "challenge", "审稿", report="write")] if challenge else []),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract={"report_from": "write"}),
)
def judged(doc: dict) -> dict[str, str]:
"""{句子原文: 裁判的判定},只列判过的句子(代码块不送裁判)。"""
md = doc["markdown"]
return {md[u["span"][0]:u["span"][1]]: u["verdict"]["status"] for _, u in iter_units(doc) if u.get("verdict")}
@pytest.mark.parametrize("body, markdown, blocks", [
pytest.param(TEXT, EAST + SOUTH, [("paragraph", None)], id="plain"),
pytest.param(NESTED, f"{EAST}\n\n{SQL}\n\n{SOUTH}", [("paragraph", None), ("code", "sql"), ("paragraph", None)],
id="sql-inside"),
])
async def test_the_judge_and_the_challenge_get_sentences_to_review(monkeypatch, shop, body, markdown, blocks):
models = Models(monkeypatch, shop, fenced(body))
row = await finish(audited(shop), run_class="formal")
assert row.status == "succeeded", row.error
checked, doc = await report_of(row.id)
assert doc["source"] == body and doc["markdown"] == markdown
assert [(b["type"], b.get("lang")) for b in doc["blocks"]] == blocks, "贴的那段 SQL 仍是代码块"
assert doc["stats"]["claims"] == 2
# 单元格引用照常解析:渲染成快照里的值,带着出处
[cell] = [s for s in iter_segments(doc) if s.get("cite")]
assert (cell["text"], cell["state"], cell["cite"]["kind"]) == ("1,500", "deterministic", "cell")
# 裁判:两句都送去判了,判定写进文档
[request] = models.judges
assert EAST in request and SOUTH in request
assert judged(doc) == {EAST: "supported", SOUTH: "supported"}
assert checked["judge"]["counts"]["supported"] == 2 and checked["judge"]["complete"] is True
# 反方审稿:两句都审了,不是「没有可审的结论句」
assert await logs(row.id, "challenge_skipped") == []
assert sorted(models.reviewed) == sorted([EAST, SOUTH])
out = await output_of(row.id, "chal")
assert out["counts"] == {"stands": 2, "weakened": 0, "refuted": 0, "inconclusive": 0}
assert sorted(i["text"] for i in out["items"]) == sorted([EAST, SOUTH])
# 出口契约复核取回的是同一份文档:数字都有出处、两句都判过、没有被推翻的,完整出具
issuance = row.output["_issuance"]
assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance
assert (await verify_manifest(row.id))["ok"] is True
async def test_the_rewrite_the_judge_asks_for_is_unwrapped_too(monkeypatch, shop):
"""rewrite_once:裁判把华南那句判证据不足,退回写作者改写,改写稿也包在 ```markdown 里——照样去掉围栏,改写稿被采用,
第二轮裁判判到的是改过的那句。改写稿要是没去围栏,整篇是一个代码块,第二轮一句可判的都没有。"""
models = Models(monkeypatch, shop, fenced(TEXT), fenced(REWRITTEN), verdicts={SOUTH: "insufficient"})
row = await finish(audited(shop, challenge=False, judge={"rewrite_once": True}), run_class="formal")
assert row.status == "succeeded", row.error
assert len(models.writes) == 2 and len(models.judges) == 2
assert SOUTH in str(models.writes[1][-1].content), "退回去的是判作证据不足的那句"
assert NEW_SOUTH in models.judges[1] and EAST not in models.judges[1], "第二轮只判改过的句子"
checked, doc = await report_of(row.id)
rewrite = checked["judge"]["rewrite"]
assert rewrite["applied"] is True and rewrite["sentences"] == [SOUTH]
assert doc["source"] == REWRITTEN and doc["markdown"] == EAST + NEW_SOUTH
assert [b["type"] for b in doc["blocks"]] == ["paragraph"] and doc["stats"]["claims"] == 2
assert judged(doc) == {EAST: "supported", NEW_SOUTH: "supported"}
assert row.output["周报"] == EAST + NEW_SOUTH