复核信号认分析能力一轮的缺口:一次运行里算不了的几次分析并成一条「有 n 次分析没做成」,点名是哪一项、为什么(叫法取契约的 OP_LABELS,不再写 analysis_baseline 这样的工具名;老事件没带这几项的照日志原话),排在第一次出现的位置;分析调用写错(tool.error 带 call_error)后来同一个分析工具做成了是 Agent 照说明改对了,不再报 tool_args_fixed、为它叫一次复核模型,一直没做成的并成一条 analysis_call_failed(缺口,只写说明),不再落成 tool_error(严重、可重来,一次写错的分析会让整轮问数据重来);判断词核对重写后仍有的照报告节点原话说处数和重写次数。核对过第 1 波新增的日志代码:report_analysis_claims、analysis_refused、metric_comparability_skipped 三种,都已登记、都是缺口、都在只写说明的一组,RETRYABLE / REBUILD 不加。改了一条旧测试:test_analysis_signals_are_registered_as_gaps_that_only_need_a_note 原来断言三种日志正好等于 NOTE_ONLY,NOTE_ONLY 多了 analysis_call_failed 这种不是日志代码的信号,改成比去掉它之后的

This commit is contained in:
ThearchyHelios
2026-10-21 18:08:48 +08:00
parent 8a81fa767e
commit 97a37d4bd2
3 changed files with 224 additions and 6 deletions
+78 -5
View File
@@ -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 条):带满了的,后面可能还有,不当作只剩标注
+2 -1
View File
@@ -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)
+144
View File
@@ -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