From 97a37d4bd200ac4a01dc2baa69406e30bfeb43f0 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 18:08:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=A1=E5=8F=B7=E8=AE=A4?= =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B=E4=B8=80=E8=BD=AE=E7=9A=84?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=EF=BC=9A=E4=B8=80=E6=AC=A1=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=87=8C=E7=AE=97=E4=B8=8D=E4=BA=86=E7=9A=84=E5=87=A0=E6=AC=A1?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=B9=B6=E6=88=90=E4=B8=80=E6=9D=A1=E3=80=8C?= =?UTF-8?q?=E6=9C=89=20n=20=E6=AC=A1=E5=88=86=E6=9E=90=E6=B2=A1=E5=81=9A?= =?UTF-8?q?=E6=88=90=E3=80=8D=EF=BC=8C=E7=82=B9=E5=90=8D=E6=98=AF=E5=93=AA?= =?UTF-8?q?=E4=B8=80=E9=A1=B9=E3=80=81=E4=B8=BA=E4=BB=80=E4=B9=88=EF=BC=88?= =?UTF-8?q?=E5=8F=AB=E6=B3=95=E5=8F=96=E5=A5=91=E7=BA=A6=E7=9A=84=20OP=5FL?= =?UTF-8?q?ABELS=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=86=99=20analysis=5Fbaseline?= =?UTF-8?q?=20=E8=BF=99=E6=A0=B7=E7=9A=84=E5=B7=A5=E5=85=B7=E5=90=8D?= =?UTF-8?q?=EF=BC=9B=E8=80=81=E4=BA=8B=E4=BB=B6=E6=B2=A1=E5=B8=A6=E8=BF=99?= =?UTF-8?q?=E5=87=A0=E9=A1=B9=E7=9A=84=E7=85=A7=E6=97=A5=E5=BF=97=E5=8E=9F?= =?UTF-8?q?=E8=AF=9D=EF=BC=89=EF=BC=8C=E6=8E=92=E5=9C=A8=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E5=87=BA=E7=8E=B0=E7=9A=84=E4=BD=8D=E7=BD=AE=EF=BC=9B?= =?UTF-8?q?=E5=88=86=E6=9E=90=E8=B0=83=E7=94=A8=E5=86=99=E9=94=99=EF=BC=88?= =?UTF-8?q?tool.error=20=E5=B8=A6=20call=5Ferror=EF=BC=89=E5=90=8E?= =?UTF-8?q?=E6=9D=A5=E5=90=8C=E4=B8=80=E4=B8=AA=E5=88=86=E6=9E=90=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=81=9A=E6=88=90=E4=BA=86=E6=98=AF=20Agent=20?= =?UTF-8?q?=E7=85=A7=E8=AF=B4=E6=98=8E=E6=94=B9=E5=AF=B9=E4=BA=86=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=A5=20tool=5Fargs=5Ffixed=E3=80=81?= =?UTF-8?q?=E4=B8=BA=E5=AE=83=E5=8F=AB=E4=B8=80=E6=AC=A1=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=8C=E4=B8=80=E7=9B=B4=E6=B2=A1=E5=81=9A?= =?UTF-8?q?=E6=88=90=E7=9A=84=E5=B9=B6=E6=88=90=E4=B8=80=E6=9D=A1=20analys?= =?UTF-8?q?is=5Fcall=5Ffailed=EF=BC=88=E7=BC=BA=E5=8F=A3=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E5=86=99=E8=AF=B4=E6=98=8E=EF=BC=89=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=90=BD=E6=88=90=20tool=5Ferror=EF=BC=88=E4=B8=A5=E9=87=8D?= =?UTF-8?q?=E3=80=81=E5=8F=AF=E9=87=8D=E6=9D=A5=EF=BC=8C=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=86=99=E9=94=99=E7=9A=84=E5=88=86=E6=9E=90=E4=BC=9A=E8=AE=A9?= =?UTF-8?q?=E6=95=B4=E8=BD=AE=E9=97=AE=E6=95=B0=E6=8D=AE=E9=87=8D=E6=9D=A5?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=88=A4=E6=96=AD=E8=AF=8D=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E9=87=8D=E5=86=99=E5=90=8E=E4=BB=8D=E6=9C=89=E7=9A=84=E7=85=A7?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E8=8A=82=E7=82=B9=E5=8E=9F=E8=AF=9D=E8=AF=B4?= =?UTF-8?q?=E5=A4=84=E6=95=B0=E5=92=8C=E9=87=8D=E5=86=99=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=E3=80=82=E6=A0=B8=E5=AF=B9=E8=BF=87=E7=AC=AC=201=20=E6=B3=A2?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=9A=84=E6=97=A5=E5=BF=97=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=EF=BC=9Areport=5Fanalysis=5Fclaims=E3=80=81analysis=5Frefused?= =?UTF-8?q?=E3=80=81metric=5Fcomparability=5Fskipped=20=E4=B8=89=E7=A7=8D?= =?UTF-8?q?=EF=BC=8C=E9=83=BD=E5=B7=B2=E7=99=BB=E8=AE=B0=E3=80=81=E9=83=BD?= =?UTF-8?q?=E6=98=AF=E7=BC=BA=E5=8F=A3=E3=80=81=E9=83=BD=E5=9C=A8=E5=8F=AA?= =?UTF-8?q?=E5=86=99=E8=AF=B4=E6=98=8E=E7=9A=84=E4=B8=80=E7=BB=84=EF=BC=8C?= =?UTF-8?q?RETRYABLE=20/=20REBUILD=20=E4=B8=8D=E5=8A=A0=E3=80=82=E6=94=B9?= =?UTF-8?q?=E4=BA=86=E4=B8=80=E6=9D=A1=E6=97=A7=E6=B5=8B=E8=AF=95=EF=BC=9A?= =?UTF-8?q?test=5Fanalysis=5Fsignals=5Fare=5Fregistered=5Fas=5Fgaps=5Fthat?= =?UTF-8?q?=5Fonly=5Fneed=5Fa=5Fnote=20=E5=8E=9F=E6=9D=A5=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E4=B8=89=E7=A7=8D=E6=97=A5=E5=BF=97=E6=AD=A3=E5=A5=BD?= =?UTF-8?q?=E7=AD=89=E4=BA=8E=20NOTE=5FONLY=EF=BC=8CNOTE=5FONLY=20?= =?UTF-8?q?=E5=A4=9A=E4=BA=86=20analysis=5Fcall=5Ffailed=20=E8=BF=99?= =?UTF-8?q?=E7=A7=8D=E4=B8=8D=E6=98=AF=E6=97=A5=E5=BF=97=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=9A=84=E4=BF=A1=E5=8F=B7=EF=BC=8C=E6=94=B9=E6=88=90=E6=AF=94?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=AE=83=E4=B9=8B=E5=90=8E=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/review.py | 83 ++++++++++++++- backend/tests/test_review.py | 3 +- backend/tests/test_review_analysis.py | 144 ++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_review_analysis.py diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index a0387a69..74dba4ba 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -6,9 +6,11 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass from typing import Any +from app.analysis.contract import OP_LABELS, TOOL_NAMES from app.core.events import EventType from app.engine.issuance import extract_numbers, trace_numbers from app.engine.toolcalls import leaked_markup +from app.tools.analysis import CALL_ERROR_HEAD, REFUSED_CODE # -------------------------------------------------------------------------- # 跑完之后的复核层。 @@ -145,11 +147,15 @@ WARN_CODES: dict[str, tuple[str, str]] = { # 分析能力一轮(00-总方案 §3.7):只写说明、不自动重来(NOTE_ONLY)。答案照常交付,缺口照实说一句就够了: # 报告里判断词没挂对分析结论、原因没有依据,重写后仍有(check 标在报告里,strict 另记出具缺口) "report_analysis_claims": ("report_analysis_claims", DEGRADED), - # 一份分析算不了(历史不够、本期不完整、截断……):算不了本身是一条结论,写作者照实写原因 - "analysis_refused": ("analysis_refused", DEGRADED), + # 一份分析算不了(历史不够、本期不完整、截断……):算不了本身是一条结论,写作者照实写原因。一次运行里几次算不了的 + # 并成一条「有 n 次分析没做成」(_analysis_signal),不一次一条 + REFUSED_CODE: (REFUSED_CODE, DEGRADED), # 口径卡上用到两次查询的指标没做成可比性检查(探查失败、算不了):指标照常算,两期能不能直接比要人自己核对 "metric_comparability_skipped": ("metric_comparability_skipped", DEGRADED), } +#: 分析调用写错了、后来同一个分析工具一次都没做成(tool.error 带 call_error,见 scan):这几次分析没做成,同 analysis_refused +#: 是缺口不是断裂。它不是日志代码,是 scan 从工具事件里认出来的信号种类 +ANALYSIS_CALL_FAILED = "analysis_call_failed" # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 # 工具参数错了、步数不够、图没校验过,都属于改一下再来一遍就行的。 @@ -160,7 +166,9 @@ REBUILD = {"query_no_data", "metric_no_data"} #: 只写说明、不自动重来的信号(00-总方案 §3.7):都是缺口(DEGRADED)、不进 RETRYABLE / REBUILD。一次运行的信号全是 #: 这几种时复核不叫模型,说明就照信号原话写(review):答案照常、报告里已经标出,叫一次模型只是把同一句话换个说法 -NOTE_ONLY = frozenset({"report_analysis_claims", "analysis_refused", "metric_comparability_skipped"}) +NOTE_ONLY = frozenset({"report_analysis_claims", REFUSED_CODE, "metric_comparability_skipped", ANALYSIS_CALL_FAILED}) +#: 「有 n 次分析没做成」那条信号里最多点名几次,其余写「……」 +_ANALYSIS_SHOWN = 3 #: 串行工具模式下塞给未执行调用的占位说明。它漏到用户面前当过答案, #: 所以复核要认得出来。从 nodes.llm 导入会绕成循环,这里按内容识别。 @@ -229,12 +237,25 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list finals = _final_checks(rows) #: 报过的节点失败 (节点 id, 原话):子工作流节点跟着子图里那一步失败时认得出是同一件事 failures: list[tuple[str, str]] = [] - for e in rows: + #: 没做成的分析:算不了的(analysis_refused 日志)、调用写错了一直没改对的(带 call_error 的 tool.error)。各并成一条信号, + #: 排在第一次出现的位置(slots:信号种类 → (那时信号清单有多长, 第几条事件)) + refused: list[tuple[str, Mapping[str, Any]]] = [] + unfixed: list[tuple[str, Mapping[str, Any]]] = [] + slots: dict[str, tuple[int, int]] = {} + for seq, e in enumerate(rows): etype = str(_event_field(e, "type", "")) data = _event_field(e, "data") or {} node_id = _event_field(e, "node_id") or "" - if etype == EventType.TOOL_ERROR: + if etype == EventType.TOOL_ERROR and data.get("call_error"): + # 分析调用写错了(contract.CallError:列不存在、比率当可加、这次执行的分析次数用完……):不记结论,原话交回 Agent + # 照着改。后来同一个分析工具做成了,那是它改对了,不是缺口;一直没做成的,那几次分析没做成——缺口不是断裂, + # 只写说明、不重来(NOTE_ONLY):重来一遍,Agent 照样可能不做这项分析 + if str(data.get("tool") or "") not in recovered: + slots.setdefault(ANALYSIS_CALL_FAILED, (len(signals), seq)) + unfixed.append((str(node_id), data)) + + elif etype == EventType.TOOL_ERROR: tool = str(data.get("tool") or "工具") err = str(data.get("error") or "").strip()[:200] if tool in recovered: @@ -274,6 +295,10 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list q = str(data.get("query") or "").strip()[:60] add("retrieve_empty", f"知识库里没有检索到与「{q}」相关的内容", BROKEN) + elif etype == EventType.LOG and data.get("level") == "warn" and data.get("code") == REFUSED_CODE: + slots.setdefault(REFUSED_CODE, (len(signals), seq)) + refused.append((str(node_id), data)) + elif etype == EventType.LOG and data.get("level") == "warn": message = str(data.get("message") or "").strip() code = str(data.get("code") or "") @@ -286,6 +311,12 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list if message: add(kind, message[:300], severity, reason if isinstance(reason, str) and reason else None) + # 先插靠后的那条(位置一样时先插后出现的),靠前那条的位置就不受影响 + for kind in sorted(slots, key=lambda k: slots[k], reverse=True): + items = refused if kind == REFUSED_CODE else unfixed + signals.insert(slots[kind][0], Signal(kind, _analysis_signal(kind, items, titles), DEGRADED)) + kinds.add(kind) + text = answer_text(output) if not text: add("empty_output", "这次运行没有产出任何成果内容", BROKEN) @@ -307,6 +338,48 @@ def _step_name(node_id: str, titles: Mapping[str, str]) -> str: return " › ".join(titles.get("/".join(parts[:i + 1])) or parts[i] for i in range(len(parts))) +#: 分析工具名 → 算子(contract.TOOL_NAMES 反过来):说「波动基线没有做成」,不写工具名 +_TOOL_OPS = {name: op for op, name in TOOL_NAMES.items()} + + +def _analysis_who(node_id: str, data: Mapping[str, Any], titles: Mapping[str, str]) -> str | None: + """没做成的那次分析是谁做的、哪一种:「「周报分析」的分析项「wow」(波动基线)」「「数据查询」的变化拆解」「「分析团队」的 + 成员「取数员」的趋势判断」。认不出是哪一种分析(老运行、事件里没带)为 None。""" + op = data.get("op") if data.get("op") in OP_LABELS else _TOOL_OPS.get(str(data.get("tool") or "")) + if op is None: + return None + where = f"「{_step_name(node_id, titles)}」" if node_id else "" + if data.get("item"): + return f"{where}的分析项「{data['item']}」({OP_LABELS[op]})" + member = f"的成员「{data['agent']}」" if data.get("agent") else "" + return f"{where}{member}的{OP_LABELS[op]}" if where or member else OP_LABELS[op] + + +def _analysis_why(kind: str, data: Mapping[str, Any]) -> str: + """没做成的原因:算不了的照结论里的原因;调用写错的照交回 Agent 的那句(去掉「分析调用有误,没有记成结论:」的开头)。""" + if kind == REFUSED_CODE: + return str(data.get("reason") or "").strip() or "原因未知" + error = str(data.get("error") or "").strip() + return (error[len(CALL_ERROR_HEAD):] if error.startswith(CALL_ERROR_HEAD) else error).strip() or "原因未知" + + +def _analysis_signal(kind: str, items: list[tuple[str, Mapping[str, Any]]], titles: Mapping[str, str]) -> str: + """一次运行里没做成的分析并成的那一句:一次时照实说哪一项、为什么;几次时先说「有 n 次分析没做成」,再点名前几次。 + 认不出是哪一种分析的(老运行的事件)照日志原话。复核模型、只写说明的复核都照这一句说。""" + told: list[tuple[str | None, str, str]] = [] + for node_id, data in items: + told.append((_analysis_who(node_id, data, titles), _analysis_why(kind, data), + str(data.get("message") or data.get("error") or "").strip())) + verb = "没有做成" if kind == REFUSED_CODE else "的调用写错了、一直没改对,没有做成" + if len(told) == 1: + who, why, raw = told[0] + return (f"{who}{verb}:{why}" if who else raw or f"有 1 次分析没有做成:{why}")[:300] + head = f"有 {len(told)} 次分析没做成" if kind == REFUSED_CODE else f"有 {len(told)} 次分析调用写错了、一直没改对,没有做成" + named = ";".join(f"{who}:{why}" if who else raw or why for who, why, raw in told[:_ANALYSIS_SHOWN]) + more = ";……" if len(told) > _ANALYSIS_SHOWN else "" + return f"{head}。{named}{more}"[:300] + + #: 报告节点「改过一次」的信号:按同一个节点最后一次 report.checked 的结果定档(最终通过的不是缺口) _SETTLED_BY_REPORT = ("report_repair", "report_rewrite") #: report.checked 最多带几条违规(报告节点截到前 20 条):带满了的,后面可能还有,不当作只剩标注 diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index fc1c8e20..301b291e 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -494,7 +494,8 @@ def test_analysis_signals_are_registered_as_gaps_that_only_need_a_note(): assert [(s.kind, s.severity) for s in signals] == [ ("report_analysis_claims", rv.DEGRADED), ("analysis_refused", rv.DEGRADED), ("metric_comparability_skipped", rv.DEGRADED)] - assert {s.kind for s in signals} == rv.NOTE_ONLY + # NOTE_ONLY 另有一种不是日志代码的:分析调用写错了一直没改对(analysis_call_failed,见 tests/test_review_analysis.py) + assert {s.kind for s in signals} == rv.NOTE_ONLY - {rv.ANALYSIS_CALL_FAILED} assert not rv.should_retry(signals) and not rv.NOTE_ONLY & (rv.RETRYABLE | rv.REBUILD) diff --git a/backend/tests/test_review_analysis.py b/backend/tests/test_review_analysis.py new file mode 100644 index 00000000..f5b9f3f7 --- /dev/null +++ b/backend/tests/test_review_analysis.py @@ -0,0 +1,144 @@ +"""复核认分析能力一轮的信号(engine/review.py,00-总方案 §3.7):只写说明、不自动重来。 + +- 几次算不了的分析(analysis_refused)并成一条「有 n 次分析没做成」,点名是哪一项、为什么(叫法取契约的 OP_LABELS,不写工具名); +- 分析调用写错了(tool.error 带 call_error):后来同一个分析工具做成了是 Agent 改对了,不报;一直没做成的并成一条 + analysis_call_failed,同样是缺口、不重来——以前它落成 tool_error(严重、可重来),一次写错的分析会让整轮问数据重来; +- 判断词核对重写后仍有的(report_analysis_claims)照报告节点的原话说「报告有 n 处…重写 k 次仍未改掉」; +- 这几种信号都在 NOTE_ONLY 里:全是它们时复核不叫模型,照信号原话写说明。 + +事件是手写的,名字、数都是虚构的。 +""" +from __future__ import annotations + +import pytest + +from app.core.events import EventType +from app.engine import review as rv +from app.tools.analysis import CALL_ERROR_HEAD, REFUSED_CODE + +OK_OUTPUT = {"结果": "周报:销售额 45,678.5元。"} + + +def ev(etype, **data): + return {"type": str(etype), "node_id": data.pop("node_id", ""), "data": data} + + +def started(node_id, label): + return ev(EventType.NODE_STARTED, node_id=node_id, label=label) + + +def card_refused(item="wow", op="baseline", reason="历史只有 5 期,至少要 8 期"): + return ev(EventType.LOG, node_id="card", level="warn", code=REFUSED_CODE, item=item, op=op, reason_code="history_short", + reason=reason, message=f"「周报分析」的分析项「{item}」没有做成:{reason}") + + +def agent_refused(tool="analysis_decompose", reason="本期数据只到 2026-10-16,还差 2 天"): + return ev(EventType.LOG, node_id="bot", level="warn", code=REFUSED_CODE, call_id="c1", tool=tool, + reason_code="partial_target", reason=reason, message=f"{tool} 没有做成:{reason}") + + +def call_error(tool="analysis_decompose", message="列「区域」在 Q1 里没有:可用的列是 地区、周、销售额", node_id="bot"): + text = f"{CALL_ERROR_HEAD}{message}" + return ev(EventType.TOOL_ERROR, node_id=node_id, tool=tool, call_id="c2", error=text, call_error="unknown_column") + + +def tool_end(tool="analysis_decompose", node_id="bot"): + return ev(EventType.TOOL_END, node_id=node_id, tool=tool, call_id="c3") + + +TITLES = [started("card", "周报分析"), started("bot", "数据查询")] + + +def test_one_refused_analysis_names_the_item_and_the_reason(): + [signal] = rv.scan([*TITLES, agent_refused()], OK_OUTPUT) + assert (signal.kind, signal.severity) == (REFUSED_CODE, rv.DEGRADED) + # 叫法照契约写「变化拆解」,不写工具名 + assert signal.detail == "「数据查询」的变化拆解没有做成:本期数据只到 2026-10-16,还差 2 天" + assert signal.reason is None + [signal] = rv.scan([*TITLES, card_refused()], OK_OUTPUT) + assert signal.detail == "「周报分析」的分析项「wow」(波动基线)没有做成:历史只有 5 期,至少要 8 期" + + +def test_several_refused_analyses_become_one_count(): + rows = [*TITLES, card_refused(), agent_refused(), + ev(EventType.LOG, node_id="team", level="warn", code=REFUSED_CODE, agent="取数员", tool="analysis_trend", + reason="中间缺了 2026-W30", message="取数员 的 analysis_trend 没有做成:中间缺了 2026-W30"), + started("team", "分析团队"), + card_refused(item="where", op="decompose", reason="按地区只有 1 段,至少要 2 段才拆得开")] + [signal] = rv.scan(rows, OK_OUTPUT) + assert signal.kind == REFUSED_CODE + assert signal.detail.startswith("有 4 次分析没做成。「周报分析」的分析项「wow」(波动基线):历史只有 5 期,至少要 8 期;" + "「数据查询」的变化拆解:本期数据只到 2026-10-16,还差 2 天;") + assert "「分析团队」的成员「取数员」的趋势判断:中间缺了 2026-W30" in signal.detail + assert signal.detail.endswith(";……") and len(signal.detail) <= 300 + + +def test_old_events_without_the_structured_fields_keep_their_wording(): + plain = ev(EventType.LOG, level="warn", code=REFUSED_CODE, message="「周报分析」的波动基线没有做成:历史只有 5 期") + assert [s.detail for s in rv.scan([plain], OK_OUTPUT)] == ["「周报分析」的波动基线没有做成:历史只有 5 期"] + two = rv.scan([plain, ev(EventType.LOG, level="warn", code=REFUSED_CODE, message="趋势判断没有做成:中间缺期")], OK_OUTPUT) + assert [s.detail for s in two] == ["有 2 次分析没做成。「周报分析」的波动基线没有做成:历史只有 5 期;趋势判断没有做成:中间缺期"] + + +def test_the_merged_signal_keeps_its_place_among_the_others(): + skipped = ev(EventType.LOG, level="warn", code="metric_comparability_skipped", message="指标「销售额环比」没做成可比性检查") + claims = ev(EventType.LOG, level="warn", code="report_analysis_claims", message="报告有 1 处分析说法未通过核对") + kinds = [s.kind for s in rv.scan([*TITLES, claims, agent_refused(), skipped, card_refused()], OK_OUTPUT)] + assert kinds == ["report_analysis_claims", REFUSED_CODE, "metric_comparability_skipped"] + kinds = [s.kind for s in rv.scan([*TITLES, call_error(), claims, agent_refused()], OK_OUTPUT)] + assert kinds == [rv.ANALYSIS_CALL_FAILED, "report_analysis_claims", REFUSED_CODE] + + +def test_a_call_error_the_agent_fixed_is_not_a_gap(): + """Agent 照交回的说明改了参数、同一个分析工具做成了:不报(以前记成 tool_args_fixed,为一次改对的调用叫一次复核模型)。""" + assert rv.scan([*TITLES, call_error(), tool_end()], OK_OUTPUT) == [] + + +def test_a_call_error_never_fixed_is_a_note_not_a_retry(): + """一直没改对:那次分析没做成,是缺口不是断裂;不落成 tool_error(严重、可重来)——重来一遍 Agent 照样可能不做这项分析。""" + [signal] = rv.scan([*TITLES, call_error()], OK_OUTPUT) + assert (signal.kind, signal.severity) == (rv.ANALYSIS_CALL_FAILED, rv.DEGRADED) + assert signal.detail == ("「数据查询」的变化拆解的调用写错了、一直没改对,没有做成:列「区域」在 Q1 里没有:可用的列是 地区、周、" + "销售额") + assert not rv.should_retry([signal]) and rv.ANALYSIS_CALL_FAILED in rv.NOTE_ONLY + exhausted = call_error(tool="analysis_baseline", message="这个 Agent 这次执行最多做 12 次分析,已经用完:用已有的分析结论写,不再新做分析") + [signal] = rv.scan([*TITLES, call_error(), exhausted], OK_OUTPUT) + assert signal.detail.startswith("有 2 次分析调用写错了、一直没改对,没有做成。「数据查询」的变化拆解:列「区域」") + assert "「数据查询」的波动基线:这个 Agent 这次执行最多做 12 次分析,已经用完" in signal.detail + + +def test_other_failures_of_an_analysis_tool_stay_tool_errors(): + """不是调用写错(没有 call_error:快照取不回来这类)的照旧是工具失败。""" + broken = ev(EventType.TOOL_ERROR, node_id="bot", tool="analysis_decompose", error="工具执行失败:Q1 的查询快照已不存在") + assert [(s.kind, s.severity) for s in rv.scan([broken], OK_OUTPUT)] == [("tool_error", rv.BROKEN)] + + +def test_the_claims_leftover_says_how_many_are_left_after_the_rewrite(): + message = ("报告有 2 处分析说法未通过核对(1 处判断没挂分析结论、1 处原因没有依据:「主要来自」「由于」),已要求模型重写 1 次仍未" + "改掉:判断要挂对应的分析结论并照判定写,原因要有知识库原话或运行输入作依据。报告照常产出,这几处已标注") + [signal] = rv.scan([ev(EventType.LOG, node_id="write", level="warn", code="report_analysis_claims", count=2, + mode="check", message=message)], OK_OUTPUT) + assert signal.kind == "report_analysis_claims" and "报告有 2 处分析说法未通过核对" in signal.detail + assert "重写 1 次仍未改掉" in signal.detail + + +@pytest.mark.asyncio +async def test_a_run_with_only_analysis_gaps_gets_a_note_without_the_model(): + class Never: + def with_structured_output(self, *_a, **_k): + raise AssertionError("不该叫模型") + + signals = rv.scan([*TITLES, agent_refused(), card_refused(), call_error()], OK_OUTPUT) + result = await rv.review(Never(), question="上周销售额为什么下降", answer="答案", signals=signals, locked=True) + assert result.verdict == "annotated" and result.retry is False and result.severity == rv.DEGRADED + assert result.note.startswith("有 2 次分析没做成。") and "「数据查询」的变化拆解的调用写错了" in result.note + + +def test_every_new_log_code_of_the_analysis_round_is_registered(): + """第 1 波各包新加的分析日志代码都登记了,严重程度是缺口,不在重来的两组里。""" + from app.engine.nodes.metrics import COMPARABILITY_SKIPPED + + for code in (REFUSED_CODE, COMPARABILITY_SKIPPED, "report_analysis_claims"): + assert rv.WARN_CODES[code] == (code, rv.DEGRADED), code + assert code in rv.NOTE_ONLY and code not in rv.RETRYABLE | rv.REBUILD + assert rv.ANALYSIS_CALL_FAILED not in rv.RETRYABLE | rv.REBUILD