From 4d364d385bba239d39eb77f93fbdd8000d855b86 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 15:50:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=202=20=E8=BD=AE?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E7=85=A7=E5=BD=95=E7=9A=84=E7=90=86=E7=94=B1?= =?UTF-8?q?=E4=B8=8D=E9=9C=B2=E5=86=85=E9=83=A8=E6=A0=87=E8=AE=B0=E3=80=81?= =?UTF-8?q?=E8=A3=B8=E6=95=B0=E5=AD=97=E6=B3=A8=E6=98=8E=E6=97=A0=E5=87=BA?= =?UTF-8?q?=E5=A4=84=EF=BC=8C=E5=8F=AA=E5=81=9C=E7=94=A8=E4=BA=86=E9=83=A8?= =?UTF-8?q?=E5=88=86=E6=95=B0=E6=8D=AE=E6=BA=90=E6=97=B6=E7=90=86=E7=94=B1?= =?UTF-8?q?=E5=92=8C=E6=97=B6=E9=97=B4=E7=BA=BF=E5=86=99=E6=98=8E=E6=B2=A1?= =?UTF-8?q?=E6=9F=A5=E5=93=AA=E5=87=A0=E4=B8=AA=EF=BC=8C=E6=92=9E=E4=B8=8A?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E9=87=91=E9=A2=9D=E4=B8=8A=E9=99=90=E7=9A=84?= =?UTF-8?q?=E5=87=A0=E5=8F=A5=E5=90=8C=E4=B8=80=E7=A7=8D=E8=AF=B4=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 不采信时照录反方的理由改成纯文字:解析不了的引用(⟦?v:…⟧)换成「(引用无效)」,裸数字后面注明「(无出处)」。 这种句子没有理由文档,界面按文字显示,以前内部的占位标记会原样露在审批卡上,裸数字也不标 - 一句该查的数据源只停用了一部分时照常反查,理由末尾由系统注明哪几个没查(写进理由文档:界面按文档画理由, 写法和把说明写进原文再组装出来的一模一样),时间线另记一条 challenge_sources_lost。以前只写进给 agent 的任务, 审稿记录和审批卡上的「经受住」看着就像每个源都查过了。没挂依据的句子按整份报告用到的源查,任务、理由里不再说 「这句引用的」 - 当天的核查金额用完时,派出去就撞上上限、一次模型都没调用的那句,和后面不再派出的句子用同一种说法 「已达上限(…),这句未反查」,不再一句「反查未完成:…没能交结果」、一句「这句未反查」 - 判失败的报错只推翻一句时说「请核对这句结论」 没有改的:被推翻的出具缺口读成「反方审稿「反方审稿」:反方审稿推翻了…」并归在「校验不完整」下,审批卡和节点产出 分不出根本没反查,challenge_unreviewed / challenge_sources_lost 不在期 3 约定的日志代码表里——这几处要改约定或 别的包的文件(io.py、decode.ts、NodeCard.tsx、EvidencePane.tsx、ChallengeSummary),留给编排者定 --- backend/app/engine/nodes/challenge.py | 135 ++++++++++++++++++++++---- backend/tests/test_challenge_node.py | 109 ++++++++++++++++++++- 2 files changed, 221 insertions(+), 23 deletions(-) diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index 836c3ef3..f6195010 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -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"] diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 145d1ec7..41bc16ac 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -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 去试"