反方审稿复核修正(第 2 轮):照录的理由不露内部标记、裸数字注明无出处,只停用了部分数据源时理由和时间线写明没查哪几个,撞上每日金额上限的几句同一种说法

- 不采信时照录反方的理由改成纯文字:解析不了的引用(⟦?v:…⟧)换成「(引用无效)」,裸数字后面注明「(无出处)」。
  这种句子没有理由文档,界面按文字显示,以前内部的占位标记会原样露在审批卡上,裸数字也不标
- 一句该查的数据源只停用了一部分时照常反查,理由末尾由系统注明哪几个没查(写进理由文档:界面按文档画理由,
  写法和把说明写进原文再组装出来的一模一样),时间线另记一条 challenge_sources_lost。以前只写进给 agent 的任务,
  审稿记录和审批卡上的「经受住」看着就像每个源都查过了。没挂依据的句子按整份报告用到的源查,任务、理由里不再说
  「这句引用的」
- 当天的核查金额用完时,派出去就撞上上限、一次模型都没调用的那句,和后面不再派出的句子用同一种说法
  「已达上限(…),这句未反查」,不再一句「反查未完成:…没能交结果」、一句「这句未反查」
- 判失败的报错只推翻一句时说「请核对这句结论」

没有改的:被推翻的出具缺口读成「反方审稿「反方审稿」:反方审稿推翻了…」并归在「校验不完整」下,审批卡和节点产出
分不出根本没反查,challenge_unreviewed / challenge_sources_lost 不在期 3 约定的日志代码表里——这几处要改约定或
别的包的文件(io.py、decode.ts、NodeCard.tsx、EvidencePane.tsx、ChallengeSummary),留给编排者定
This commit is contained in:
ThearchyHelios
2026-10-05 15:50:20 +08:00
parent 4ceac6b74c
commit 4d364d385b
2 changed files with 221 additions and 23 deletions
+114 -21
View File
@@ -11,12 +11,15 @@
的 Q1 分开),要它先想清楚「如果这句不成立,数据里会看到什么」,写只读查询去找,最后交结论(经受住 / 被削弱 /
被推翻 / 无法判断)和理由。理由里的数字只能引用它自己查询的格子,裸数字由 compose_doc 标出来
3. 出处:一条查询都没执行成功的,判什么都记为无法判断;判「被推翻」的,理由里还得引用到自己的查询结果——它要让
正式出具降档,审稿记录里得查得到凭什么。不采信时系统写明原因,反方原本的结论和理由照录在后面。这句引用的数据源
都不存在或已停用、核查模型本身用不了时不派 agent(模型用不了的,后面的句子照同一个原因记,不再逐句去建模型)
正式出具降档,审稿记录里得查得到凭什么。不采信时系统写明原因,反方原本的结论和理由照录在后面(纯文字:解析
不了的引用写成「(引用无效)」,裸数字注明「(无出处)」)。这句该查的数据源都不存在或已停用、核查模型本身用
不了时不派 agent(模型用不了的,后面的句子照同一个原因记,不再逐句去建模型);只停用了一部分的照常派,理由
末尾由系统注明哪几个没查(写进理由文档,见 with_note)
4. 记录:每句的结论、理由(渲染后的正文和组装好的文档)、查询快照、用量写进审稿记录(challenge_artifact),随运行
封存。查询照常发 tool.end(role: verify,带查询快照,可下钻);台账只记 kind challenge,绝不记 query——核查的
查询进了写作目录,下游的写作者会把它们当成可以引用的证据。有句子没能反查的另记一条 challenge_unreviewed,
不然时间线上只有一句「审了 N 句」,看不出根本没审成
查询进了写作目录,下游的写作者会把它们当成可以引用的证据。有句子没能反查的另记一条 challenge_unreviewed、
只查了部分数据源的另记一条 challenge_sources_lost,不然时间线上只有一句「审了 N 句」,看不出根本没审成、
没审全(这两个代码还不在期 3 约定的日志代码表里,界面认不得时照原话显示)
5. 处置:被推翻的按 on_refuted 处置。degrade(默认)在产出里写 issuance_gaps,成果节点的出具契约照单收进缺口、
出具降档(nodes/io.py 的 _upstream_issuance_gaps,所以反方审稿要接在成果节点上游,画布校验会提醒);fail 判节点
失败;flag 只记录。产出和审批卡里的 on_refuted 是实际生效的那一种(只有 degrade / flag,见 _finish)。审批卡
@@ -74,6 +77,11 @@ _CLAIM_LIMITS = ("max_queries", "timeout_s", "daily_max_usd")
_RANK = {"refuted": 0, "weakened": 1, "inconclusive": 2, "stands": 3}
#: 系统改判时照录反方原本的结论用的叫法(界面的叫法见 lib/terms.ts 的 CHALLENGE_TEXT.verdict)
_VERDICT_LABEL = {"stands": "经受住", "weakened": "被削弱", "refuted": "被推翻", "inconclusive": "无法判断"}
#: 照录反方的理由时:解析不了的引用换成的字、裸数字后面加的注(见 _said)
_INVALID_REF = "(引用无效)"
_UNSOURCED = "(无出处)"
#: 解析不了的引用在渲染后的正文里的占位(evidence.placeholder),连同两边隔开它的一个空格
_PLACEHOLDER = re.compile(r" ?⟦\?[^⟧]*⟧ ?")
#: 接着跑时 checkpoint 里缓存的审稿最多翻几条去找这一份配置的(每条都是按先前某一份配置审的,对不上就往后翻)
_STALE_LIMIT = 16
#: 同一次执行里审完的结果放在 RunContext.extra 的这个键下:{(节点, 第几次执行, 输入指纹): 审稿结果}。重试沿用它
@@ -343,9 +351,9 @@ def _claim_of(cand: judging.Candidate, outcome: VerifyOutcome) -> tuple[dict[str
rationale = str(outcome.result.get(RATIONALE) or "")
if not outcome.queries:
why = "反查没有执行成功任何查询"
return _unfounded(cand, outcome, verdict, rationale, why), why
return _unfounded(cand, outcome, verdict, why), why
if verdict == "refuted" and not _cites(outcome.rationale):
return _unfounded(cand, outcome, verdict, rationale, "理由没有引用任何查询结果"), None
return _unfounded(cand, outcome, verdict, "理由没有引用到任何查询结果"), None
return _claim(cand, verdict, rationale, doc=outcome.rationale, queries=outcome.queries, usage=outcome.usage), None
@@ -354,15 +362,75 @@ def _cites(doc: dict[str, Any] | None) -> bool:
return isinstance(doc, dict) and any(unit.get("cites") for _, unit in iter_units(doc))
def _unfounded(cand: judging.Candidate, outcome: VerifyOutcome, verdict: str, rationale: str,
why: str) -> dict[str, Any]:
"""没有出处的结论:记为无法判断,系统写明原因,反方原本的结论和理由照录在后面。rationale_doc 为 None:这段话是
系统说的,界面照 rationale 显示,不拿反方那份文档冒充理由。查过的照记。"""
def _unfounded(cand: judging.Candidate, outcome: VerifyOutcome, verdict: str, why: str) -> dict[str, Any]:
"""没有出处的结论:记为无法判断,系统写明原因,反方原本的结论和理由照录在后面(照录的写法见 _said)。
rationale_doc 为 None:这段话是系统说的,界面照 rationale 显示,不拿反方那份文档冒充理由。查过的照记。"""
taken = f"反方给出的「{_VERDICT_LABEL[verdict]}」不予采信,记为无法判断" if verdict != "inconclusive" else "记为无法判断"
said = f"反方的理由:{rationale.strip()}" if rationale.strip() else "反方没有给出理由"
rationale = _said(outcome.rationale, str((outcome.result or {}).get(RATIONALE) or "")).strip()
said = f"反方的理由:{rationale}" if rationale else "反方没有给出理由"
return _claim(cand, "inconclusive", f"{why},{taken}。{said}", queries=outcome.queries, usage=outcome.usage)
def _said(doc: dict[str, Any] | None, rendered: str) -> str:
"""反方的理由照录成纯文字。界面没有文档可画、按文字显示,文档里标出来的东西得写成字:解析不了的引用渲染成内部
的占位(⟦?v:Q1.r9.列⟧),换成「(引用无效)」,连同两边隔开它的空格;裸数字后面注明「(无出处)」。按片段在正文
里的偏移改(片段互不重叠,从后往前改偏移不乱);文档和正文对不上时只换占位。"""
if not isinstance(doc, dict) or doc.get("markdown") != rendered:
return _PLACEHOLDER.sub(_INVALID_REF, rendered)
edits: list[tuple[int, int, str]] = []
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
span = seg.get("span")
if not (isinstance(span, list) and len(span) == 2 and all(isinstance(x, int) for x in span)):
continue
if seg.get("issue") == "uncited_number":
edits.append((span[1], span[1], _UNSOURCED))
elif seg.get("issue") == "unresolved_ref" and str(seg.get("text") or "").startswith("⟦?"):
edits.append((span[0], span[1], _INVALID_REF))
text = rendered
for start, end, new in sorted(edits, reverse=True):
text = text[:start] + new + text[end:]
return text.replace(f" {_INVALID_REF}", _INVALID_REF).replace(f"{_INVALID_REF} ", _INVALID_REF)
def with_note(doc: dict[str, Any], note: str) -> dict[str, Any]:
"""理由文档末尾另起一段,写系统的说明(界面按文档画理由,说明只写进 rationale 正文的话看不到)。
照 compose_doc 的样子接:段与段之间的空行是这一句开头的结构片段,说明是一句连接性的话,块、句、片段的编号接着
往下数,统计跟着加——和把说明写进原文再组装出来的一模一样。只是说明不挂出处、不查裸数字(是系统的话,数据源的
名字里也可能有数字),source(反方的原文)不动。传进来的文档不改,返回新的一份。"""
markdown = str(doc.get("markdown") or "")
blocks = list(doc.get("blocks") or [])
units = [u for b in blocks if isinstance(b, dict) for u in b.get("units") or []]
first = sum(len(u.get("segments") or []) for u in units if isinstance(u, dict))
gap = "\n\n" if markdown else ""
start = len(markdown) + len(gap)
pieces = [("structural", gap, len(markdown))] if gap else []
pieces.append(("text", note, start))
segments = [{"kind": kind, "text": text, "state": "neutral", "span": [at, at + len(text)], "id": f"s{first + i}"}
for i, (kind, text, at) in enumerate(pieces)]
unit = {"id": f"u{len(units)}", "kind": "connective", "span": [start, start + len(note)], "cites": [], "see": [],
"segments": segments}
out = {**doc, "markdown": markdown + gap + note,
"blocks": [*blocks, {"type": "paragraph", "units": [unit], "id": f"b{len(blocks)}"}]}
if isinstance(doc.get("stats"), dict):
out["stats"] = stats = dict(doc["stats"])
for key, more in (("units", 1), ("segments", len(segments)), ("connective", 1)):
if isinstance(stats.get(key), int):
stats[key] += more
return out
def _noted(claim: dict[str, Any], note: str) -> dict[str, Any]:
"""审稿记录里的一句,理由末尾加上系统的说明:有理由文档的写进文档(with_note),正文照文档;没有的接在正文后面。"""
doc = claim.get("rationale_doc")
if isinstance(doc, dict):
doc = with_note(doc, note)
return {**claim, "rationale": doc["markdown"], "rationale_doc": doc}
said = str(claim.get("rationale") or "").rstrip()
return {**claim, "rationale": f"{said}\n\n{note}" if said else note}
async def _usable_sources(names: Iterable[str]) -> set[str]:
"""这几个数据源里现在能查的:存在、没有停用(核查建工具时也只认这样的,tools/datasource.build_datasource_tools)。
报告写完到审稿之间源被停用了,这句就没有可查的源:不派 agent,免得白建一次模型、白花一次钱。"""
@@ -446,13 +514,19 @@ class _Review:
return f"每句时长上限 {self.timeout_s:g} 秒"
return f"全局每日核查金额上限 {_usd(self.daily_max_usd)}"
def _task(self, cand: judging.Candidate, sources: list[str], lost: list[str]) -> str:
def _capped(self) -> str:
"""当天的核查金额用完、这句一次模型都没调用时的理由:派出去就撞上的和后面不再派出的,是同一回事。"""
return f"已达上限({self._limit_words('daily_max_usd')}),这句未反查"
def _task(self, cand: judging.Candidate, sources: list[str], lost: list[str], *, own: bool) -> str:
"""交给核查 agent 的任务:这句结论、它挂的证据摘录(遮罩照旧,原报告的编号标明,见 mark_original)、可以查的
数据源;这句引用的源有停用了的,也告诉它查不了哪几个,免得它把查不到当成没有反例。"""
数据源;该查的源有停用了的,也告诉它查不了哪几个,免得它把查不到当成没有反例。own:源是这句自己引用的,
不是没挂依据、按整份报告用到的源查的。"""
request = judging.prepare(self.doc, self.catalog, units=[cand.unit], loader=self.fetch, masked=self.masks)
aliases = dict.fromkeys(a for c in request.cands for a in c.aliases if a in request.excerpts)
evidence = "\n\n".join(mark_original(request.excerpts[a], a, self.queries) for a in aliases)
gone = f"(这句还引用了{_quoted(lost)},已不存在或已停用,无法查询)" if lost else ""
gone = f"({'这句还引用了' if own else '这份报告还用到了'}{_quoted(lost)},已不存在或已停用,无法查询)" \
if lost else ""
return CHALLENGE_TASK_PROMPT.format(
report=self.title, section=f",所在小节「{cand.section}」" if cand.section else "", claim=cand.text,
evidence=evidence or "(这句没有挂依据)", sources="、".join(sources) + gone)
@@ -475,6 +549,7 @@ class _Review:
broke = False # 全局每日金额用完了:后面的句子不再派 agent
broken: str | None = None # 核查模型本身用不了:后面的句子照这个原因记,不再逐句去建模型
unreviewed: list[str] = [] # 没能反查的句子各自的原因(触顶的由 challenge_limit 说,不在这里)
partial: list[list[str]] = [] # 反查了、但该查的源有停用了的句子:各自没查成的源
def unreached(cand: judging.Candidate, rationale: str, why: str) -> None:
claims.append(_claim(cand, "inconclusive", rationale))
@@ -483,26 +558,30 @@ class _Review:
for cand in self.picked:
if broke:
hit["daily_max_usd"][1] += 1
claims.append(_claim(cand, "inconclusive", f"已达上限({self._limit_words('daily_max_usd')}),这句未反查"))
claims.append(_claim(cand, "inconclusive", self._capped()))
continue
if broken is not None:
unreached(cand, f"反查未完成:{broken}", broken)
continue
try:
sources = claim_sources(cand.aliases, self.catalog, self.fetch) or everywhere
own = claim_sources(cand.aliases, self.catalog, self.fetch)
sources = own or everywhere
live = [s for s in sources if s in usable]
task_text = self._task(cand, live, [s for s in sources if s not in usable]) if live else ""
lost = [s for s in sources if s not in usable]
task_text = self._task(cand, live, lost, own=bool(own)) if live else ""
except Exception as e: # noqa: BLE001 - 一句的摘录出了意外(坏形状的工件),只让这一句没审成
why = f"整理证据摘录时出错({describe_exception(e)})"
unreached(cand, f"反查未完成:{why}", why)
continue
# 没挂依据的句子查的是整份报告用到的源,不是「这句引用的」
where = "这句引用的" if own else "这份报告用到的"
if not sources:
why = "这份报告引用的证据中没有数据库查询,没有可以反查的数据源"
unreached(cand, f"{why},这句未反查", why)
continue
if not live:
why = f"数据源{_quoted(sources)}不存在或已停用"
unreached(cand, f"这句引用的{why},没有可以反查的数据源,这句未反查", why)
why = f"数据源{_quoted(sources)}已不存在或已停用"
unreached(cand, f"{where}{why},没有可以反查的数据源,这句未反查", why)
continue
outcome = await run_verify_agent(
spec=self.spec, system=CHALLENGE_SYSTEM, task=task_text, sources=live,
@@ -518,6 +597,14 @@ class _Review:
message=f"反方模型与报告的写作模型相同(「{outcome.model}」),难以发现写作模型自身的错误。"
f"请在设置的「核查」一节选择另一个模型,或在节点的「{CHALLENGE_LABEL['model']}」中指定")
claim, why = _claim_of(cand, outcome)
if outcome.limit == "daily_max_usd" and outcome.result is None and not outcome.usage.get("calls"):
# 派出去就撞上每日金额上限、一次模型都没调用:和后面不再派出的句子是同一回事,同一种说法
claim = _claim(cand, "inconclusive", self._capped(), queries=outcome.queries, usage=outcome.usage)
elif lost and why is None:
# 反查了,可该查的源有停用了的:判断照常采信,理由末尾写明哪几个没查(只写在任务里的话,审稿记录和
# 审批卡上的「经受住」看着就像每个源都查过了)
claim = _noted(claim, f"注:{where}数据源{_quoted(lost)}已不存在或已停用,未能查询,以上判断不含其中的数据。")
partial.append(lost)
claims.append(claim)
usage = _add_usage(usage, outcome.usage)
if outcome.limit in _CLAIM_LIMITS:
@@ -543,6 +630,11 @@ class _Review:
ctx.emit(EventType.LOG, level="warn", code="challenge_unreviewed", claims_skipped=len(unreviewed),
reason=unreviewed[0],
message=f"反方审稿有 {len(unreviewed)} 句结论未能反查,已记为无法判断:{unreviewed[0]}{more}")
if partial:
names = list(dict.fromkeys(s for gone in partial for s in gone))
ctx.emit(EventType.LOG, level="warn", code="challenge_sources_lost", claims=len(partial), sources=names,
message=f"反方审稿有 {len(partial)} 句结论只查了部分数据源:{_quoted(names)}已不存在或已停用,"
f"未能查询,{'这句' if len(partial) == 1 else '这几句'}的判断不含其中的数据")
# 真正查过的和没查成的分开说:「无法判断 2 句」可能是数据不够,也可能是一条查询都没执行成功
idle = sum(1 for c in claims if not c["queries"])
ctx.emit(EventType.LOG, level="info", code="challenge_done", counts=counts, artifact=artifact,
@@ -577,7 +669,8 @@ def _finish(ctx: NodeContext, state: GraphState, outcome: dict[str, Any], *, on_
refuted = [c for c in claims if c.get("verdict") == "refuted"]
if on_refuted == "fail":
if refuted and ctx.node.config.get("on_error") != "continue":
raise NodeError(ctx.node.id, f"{_refuted_words(refuted)}。请核对这几句结论;如只需记录,可将"
which = "这句" if len(refuted) == 1 else "这几句"
raise NodeError(ctx.node.id, f"{_refuted_words(refuted)}。请核对{which}结论;如只需记录,可将"
f"「{CHALLENGE_LABEL['on_refuted']}」改为「{CHALLENGE_ON_REFUTED_LABEL['flag']}」")
on_refuted = "degrade" if refuted else "flag"
result: dict[str, Any] = {"challenge_artifact": outcome.get("artifact"), "counts": outcome.get("counts") or {},
@@ -618,4 +711,4 @@ def challenge_summary(spec: GraphSpec, outputs: dict[str, Any], report_id: str)
__all__ = ["CHALLENGE_SYSTEM", "ITEMS_MAX", "VERDICTS", "challenge_summary", "claim_sources", "mark_original",
"pick_claims", "pick_items", "run_challenge"]
"pick_claims", "pick_items", "run_challenge", "with_note"]
+107 -2
View File
@@ -5,7 +5,8 @@
(judge.prepare,遮罩照旧);理由里的数字只能引用它自己的查询格子,裸数字被标出来
- 结论四选一:经受住 / 被削弱 / 被推翻 / 无法判断;完整记录写进 challenge_artifact,随运行封存;台账只记 kind challenge,
它的查询不进任何报告的写作目录
- 结论要有出处:零查询的判什么都记为无法判断,推翻还得引用到自己的查询结果;没有可查的源、模型用不了时不派 agent
- 结论要有出处:零查询的判什么都记为无法判断,推翻还得引用到自己的查询结果,照录反方的理由时不露出内部标记、
裸数字注明无出处;没有可查的源、模型用不了时不派 agent,只停用了一部分源的照常反查,理由和时间线写明哪几个没查
- 被推翻的:on_refuted 为 degrade(默认)时写 issuance_gaps,出具降档;fail 判节点失败;flag 只记录。产出和审批卡里
的 on_refuted 写实际生效的那一种
- 上限:句数、每句查询数、每句时长、全局每日金额(都按发起时的设置快照);全局关闭、报告没有可审的结论句时直通
@@ -330,6 +331,7 @@ async def test_flag_only_records_and_fail_stops_the_run(monkeypatch, shop):
assert failed.status == "failed"
assert failed.error_node_id == "chal"
assert "反方审稿推翻了 1 句结论" in failed.error and SOUTH in failed.error
assert "请核对这句结论" in failed.error, "只推翻了一句,不说「这几句」"
assert "「结论被推翻时」改为「只标注」" in failed.error, "报错里点的选项名和检查器下拉里的一致"
# 判失败之前审稿记录已经存下、时间线照常有完成那一条
[done] = await logs(failed.id, "challenge_done")
@@ -412,6 +414,26 @@ async def test_a_refutation_that_cites_none_of_its_queries_is_not_taken(monkeypa
assert await logs(row.id, "challenge_unreviewed") == [], "查过了,不算没能反查"
@pytest.mark.parametrize("queries, said, quoted", [
([NET], "扣除退款后华南为 [[v:Q1.r99.净销售额]]。", "扣除退款后华南为(引用无效)。"),
([NET], "扣除退款后华南为 [[v:Q2.r1.净销售额]] 元。", "扣除退款后华南为(引用无效)元。"),
([], "华南为 [[v:Q1.r1.净销售额]],不成立。", "华南为(引用无效),不成立。"),
([NET], "扣除退款后华南为 900,低于华东 1200。", "扣除退款后华南为 900(无出处),低于华东 1200(无出处)。"),
])
async def test_an_unfounded_rationale_is_quoted_in_plain_words(monkeypatch, shop, queries, said, quoted):
"""不采信时照录反方的理由,界面按纯文字显示(没有理由文档):解析不了的引用不能把内部的占位标记露出来,换成
「(引用无效)」;裸数字逐个注明「(无出处)」——文档里它们会被标出来,照录成文字时也得标。"""
plays = {"华南销售额超过华东": Play(queries, "refuted", said),
"居各区域之首": Play([REFUNDS], "stands", "没有找到反例。")}
Models(monkeypatch, shop, plays=plays)
row = await finish(graph(shop), run_class="exploratory")
assert row.status == "succeeded", row.error
south = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])["claims"][1]
assert south["verdict"] == "inconclusive" and south["rationale_doc"] is None
assert south["rationale"].endswith(f"「被推翻」不予采信,记为无法判断。反方的理由:{quoted}"), south["rationale"]
assert "⟦" not in south["rationale"] and "[[" not in south["rationale"]
async def test_claims_whose_sources_are_gone_are_not_sent_out(monkeypatch, shop):
"""报告写完、审稿开始之前数据源被停用:这句没有可查的源,不建模型、不花钱,照实记无法判断并点出是哪个源。"""
real = report_mod._store
@@ -437,6 +459,87 @@ async def test_claims_whose_sources_are_gone_are_not_sent_out(monkeypatch, shop)
assert warn["claims_skipped"] == 2 and f"「{shop}」" in warn["reason"]
async def test_a_claim_with_part_of_its_sources_gone_says_so(monkeypatch, tmp_path, shop):
"""两个数据源的报告,审稿开始前停用了其中一个:引用两个源的那句、没挂依据的那句都只能在剩下的源里查。判断照常
采信,但理由末尾由系统写明哪个源没查(理由文档里也有:界面按文档画),时间线上另记一条——不然「经受住」看着像
两个源都查过了。"""
other, other_id = await _source(tmp_path)
try:
text = ("华东销售额 [[v:Q1.r0.销售额]],退款只有 [[v:Q2.r0.退款笔数]] 笔。[[see:Q1]][[see:Q2]]"
"增长主要来自华东。")
plays = {"退款只有": Play([NET], "stands", "没有找到反例。"),
"增长主要来自": Play([NET], "weakened", "华东净销售额为 [[v:Q1.r0.净销售额]],增长说过了头。")}
real = report_mod._store
async def store_then_disable(doc, ctx):
out = await real(doc, ctx)
async with SessionLocal() as session:
row = await session.get(DataSource, other_id)
row.enabled = False
await session.commit()
return out
monkeypatch.setattr(report_mod, "_store", store_then_disable)
models = Models(monkeypatch, shop, writer=(text,), plays=plays)
spec = chain(
node("start", "input"),
node("fetch", "tool", tool=f"db_query__{shop}", args={"sql": QUERY}),
node("fetch2", "tool", tool=f"db_query__{other}", args={"sql": REFUNDS}),
node("write", "report", "写周报", instructions="写周报"),
node("chal", "challenge", "审稿"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
)
row = await finish(spec, run_class="exploratory")
assert row.status == "succeeded", row.error
assert models.reviewed() == ["退款只有", "增长主要来自"]
assert models.tools["退款只有"] == {f"db_schema__{shop}", f"db_query__{shop}"}, "停用的源不给工具"
task = models.tasks()["退款只有"]
assert f"「{other}」" in task and "已不存在或已停用" in task, "给 agent 的任务里也说了,免得它把查不到当成没有反例"
assert "这份报告还用到了" in models.tasks()["增长主要来自"], "没挂依据的那句不说「这句引用了」"
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
both, grow = record["claims"]
assert (both["verdict"], grow["verdict"]) == ("stands", "weakened"), "查过、有出处的判断照常采信"
assert both["rationale"] == (f"没有找到反例。\n\n注:这句引用的数据源「{other}」已不存在或已停用,未能查询,"
"以上判断不含其中的数据。")
assert grow["rationale"] == (f"华东净销售额为 1,200,增长说过了头。\n\n注:这份报告用到的数据源「{other}」"
"已不存在或已停用,未能查询,以上判断不含其中的数据。")
for claim in (both, grow):
doc = claim["rationale_doc"]
assert doc["markdown"] == claim["rationale"], "界面按文档画:说明要在文档里"
segments = [s for _, u in iter_units(doc) for s in u["segments"]]
assert "".join(s["text"] for s in segments) == doc["markdown"]
assert [s["id"] for s in segments] == [f"s{i}" for i in range(len(segments))]
*_, note = iter_units(doc)
assert note[1]["kind"] == "connective" and note[1]["cites"] == [], "说明不是反方的结论,也不挂出处"
cited = [s for _, u in iter_units(grow["rationale_doc"]) for s in u["segments"] if s.get("cite")]
assert [s["text"] for s in cited] == ["1,200"], "反方自己的引用照旧解析"
[lost] = await logs(row.id, "challenge_sources_lost")
assert lost["level"] == "warn" and lost["claims"] == 2 and lost["sources"] == [other]
assert f"「{other}」" in lost["message"] and "2 句" in lost["message"]
assert await logs(row.id, "challenge_unreviewed") == [], "两句都查过了,不算没能反查"
finally:
await engines.invalidate(other_id)
def test_the_note_reads_like_one_more_paragraph_of_the_rationale():
"""理由末尾的系统说明照 compose_doc 的样子接上:和把说明写进原文再组装出来的块、句、片段、统计一模一样(片段的
偏移连着、编号接着往下数),只是反方的原文(source)不动、传进来的那份文档不改。"""
from app.engine.evidence import compose_doc
said = "华南只有 900,低于华东。\n\n- 华北为 [[v:Q1.r2.销售额]]"
note = "注:这句引用的数据源「仓库」已不存在或已停用,未能查询,以上判断不含其中的数据。"
doc = compose_doc(said, {}, node_id="chal")
before = json.dumps(doc, ensure_ascii=False, sort_keys=True)
noted = challenge_mod.with_note(doc, note)
again = compose_doc(f"{said}\n\n{note}", {}, node_id="chal")
assert noted["markdown"] == again["markdown"] and noted["blocks"] == again["blocks"]
assert noted["stats"] == again["stats"]
assert noted["source"] == doc["source"] == said
assert json.dumps(doc, ensure_ascii=False, sort_keys=True) == before
# --------------------------------------------------------------------------
# 理由里的裸数字
# --------------------------------------------------------------------------
@@ -563,7 +666,9 @@ async def test_the_daily_cap_stops_the_remaining_claims(monkeypatch, shop):
out = await output_of(row.id, "chal")
assert out["counts"] == {"stands": 0, "weakened": 0, "refuted": 0, "inconclusive": 2}
record = artifact_store.load(out["challenge_artifact"])
assert all("$2.00" in c["rationale"] for c in record["claims"])
words = "已达上限(全局每日核查金额上限 $2.00),这句未反查"
assert [c["rationale"] for c in record["claims"]] == [words, words], \
"第一句派出去就撞上上限、一次模型都没调用,和后面不再派出的句子是同一回事,同一种说法"
[limit] = await logs(row.id, "challenge_limit")
assert limit["limit"] == "daily_max_usd" and limit["claims_skipped"] == 2
assert len(await logs(row.id, "verify_limit")) == 1, "第一句撞上每日上限之后,后面的句子不再派 agent 去试"