from __future__ import annotations import json from collections import Counter 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 # -------------------------------------------------------------------------- # 跑完之后的复核层。 # # 编排跑完只说明「流程走完了」,不说明「答得对」。库里能查到大量 status=succeeded # 的运行,过程中检索降级了、工具报错了、agent 步数用满了,而交到用户手上的答案 # 对此只字不提——最糟的一次(run dd9927e6)交出去的干脆是一句内部管道文案。 # # 出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上,还要配 # 口径卡节点提供受控指标集;问数据页生成的图基本都是 input → agent → output, # 走不到那条路。所以这里补一层轻的: # # 1. scan() 纯规则扫一遍事件流,零成本。干净的运行到此为止,不调模型。 # 2. review() 只在扫出信号时调一次模型,重组答案并把异常说破。 # 3. 复核模型看不到原始数据,所以它写出来的任何新数字都是凭空来的 # —— check_numbers() 机械拦截,对不上就整条退回原答案。 # -------------------------------------------------------------------------- BROKEN = "broken" # 答案不可信 DEGRADED = "degraded" # 能用,但有缺口要说明 BENIGN = "benign" # 记在轨迹里就够了,不值得为它叫一次模型 _SEVERITY_RANK = {BENIGN: 0, DEGRADED: 1, BROKEN: 2} @dataclass(frozen=True) class Signal: """一条「这次运行有什么不对」。detail 同时给模型和用户看,所以写人话。""" kind: str detail: str severity: str = DEGRADED #: agent 为什么提前收尾(engine/guards.py:steps / stall / budget_tokens / budget_usd / context)。 #: 问数据页据此决定给不给「放宽步数重跑」:只有步数用满时放宽步数才对症 reason: str | None = None def as_dict(self) -> dict[str, str]: out = {"kind": self.kind, "detail": self.detail, "severity": self.severity} return {**out, "reason": self.reason} if self.reason else out # warn 日志按 code 分类,而不是靠匹配中文消息串——消息是给人读的,随时会改, # 把复核的判断挂在它上面等于给自己埋一颗定时炸弹。各 emit 点带上 code。 # # 没有 code 的(老运行留在库里的事件)一律按 degraded 处理:宁可多说一句, # 也好过把真问题当噪音吞掉。 WARN_CODES: dict[str, tuple[str, str]] = { # 对答案内容零影响,轨迹里有就够了 "stream_fallback": ("stream_fallback", BENIGN), # 安全属性上的降级,不是答案内容的问题;轨迹里已经说清楚了 "isolation_fallback": ("isolation_fallback", BENIGN), # 答案本身就没写完 "step_limit": ("step_limit", BROKEN), "empty_completion": ("empty_completion", BROKEN), # 能用但有缺口 # 步数用尽,但收尾轮让模型基于已查到的部分收了口:答案是完整的一段话, # 只是覆盖面不全——这是缺口不是断裂,所以不判 BROKEN。 # 连带效果是不再触发自动重跑,那正是想要的:同样配置再跑一遍还撞同一堵墙 "step_limit_settled": ("step_limit_settled", DEGRADED), # 收尾轮自己挂了。影响由同时发出的 step_limit(BROKEN) 承担,这条是说清原因 "settle_failed": ("settle_failed", DEGRADED), "loop_limit": ("loop_limit", DEGRADED), "tool_args_fixed": ("tool_args_fixed", DEGRADED), "node_retry": ("node_retry", DEGRADED), "validate_retry": ("validate_retry", DEGRADED), "sandbox_reset": ("sandbox_reset", DEGRADED), "retrieve_degraded": ("retrieve_degraded", DEGRADED), # 模型把工具调用写成了文字。纠正一次好了的,答案是真查过的,只记缺口; # 没好的那次节点已经判失败,node_failed 会按 BROKEN 算 "tool_markup_leak": ("tool_markup_leak", DEGRADED), # 协作团队用完轮数、按降档交付:交来的是成员原话,不是调度者认可的结论 "team_exhausted": ("team_exhausted", BROKEN), # 调度者收了工,但最后派出去的成员失败了,成果取自更早的那份:有缺口,不是断裂 "team_last_failed": ("team_last_failed", DEGRADED), # 校验节点的修复编出了原文没有的值,这次修复被作废 "repair_invented": ("repair_invented", DEGRADED), # 报告撰写节点:写作者写了裸数字或引用了不存在的指标,被要求重写。重写好了的 # 答案是核对过的,只记缺口;没好的那次按 on_violation 失败或把违规标在报告里 "report_repair": ("report_repair", DEGRADED), # 报告撰写节点的上游没有可引用的口径卡指标:报告里的数都没有出处 "report_no_evidence": ("report_no_evidence", DEGRADED), # 证据目录超出篇幅,有的查询只列了编号和摘要:写作模型没看全证据,报告的覆盖面有缺口 "report_catalog_trimmed": ("report_catalog_trimmed", DEGRADED), # 目录有省略时写了「没有查到」这类缺失断言、重写后仍在:报告照常产出并标注,这几句结论依据不足 "report_absence_claim": ("report_absence_claim", DEGRADED), # 报告直接引用了取自遮罩列的格子、重写后仍在(「违规处置」为标注):照常产出并标注,另记一条缺口(有出具契约的 # 成果按缺口降档) "report_masked_cell": ("report_masked_cell", DEGRADED), # 口径卡的指标集、报告文档没能落进工件库:答案照常,只是点不开证据 "evidence_store_failed": ("evidence_store_failed", DEGRADED), # agent 的结构化字段:模型报的值和查询快照对不上(已按快照取值),或者出处核对不了 # (记成空值,不兜底成 0)。答案能用,缺口要说清是哪一种 "agent_field_mismatch": ("agent_field_mismatch", DEGRADED), "agent_field_unverified": ("agent_field_unverified", DEGRADED), # 结构化抽取两次都被截断(输出额度在推理阶段就用完了):字段都记空值,答案能用,缺口要说清是额度问题 "agent_field_truncated": ("agent_field_truncated", DEGRADED), # 结论句裁判(claims: judge):触顶、没跑成的句子记为未裁判,出具不能判完整——是缺口,要说明 "judge_limit": ("judge_limit", DEGRADED), "judge_failed": ("judge_failed", DEGRADED), # 裁判模型的输出到了上限被截断(推理模型的思考也算在额度里):同 judge_failed 是缺口,记成它自己的 kind——说得清 # 是输出额度的问题,该换的是模型 "judge_truncated": ("judge_truncated", DEGRADED), # 证据不支持的句子交回写作者改过一次(和 report_repair 一样只记缺口);改写稿冒出新问题没采用的, # 原稿里那几句仍是证据不支持 "report_rewrite": ("report_rewrite", DEGRADED), "report_rewrite_rejected": ("report_rewrite_rejected", DEGRADED), # 估不出裁判的金额、裁判和写作是同一个模型:配置上的提醒,答案本身没受影响 "judge_unpriced": ("judge_unpriced", BENIGN), "judge_same_model": ("judge_same_model", BENIGN), # 双盲复算、反方审稿的核查模型和被核查的模型相同:同 judge_same_model,只关乎独立性,结论本身没受影响 "dual_check_same_model": ("dual_check_same_model", BENIGN), "challenge_same_model": ("challenge_same_model", BENIGN), # 核查 agent(engine/verify_agent.py,双盲复算、反方审稿共用)估不出金额:同 judge_unpriced,配置上的提醒 "verify_unpriced": ("verify_unpriced", BENIGN), # 核查没做完、没通过:盲算触顶、两路不一致、指标无法复算;反方审稿触顶、推翻了结论、有句子没能反查、只查了 # 部分数据源。答案照常交付(出具契约另按缺口降档),是缺口不是断裂,按各自的 kind 记,界面说得清是哪一种 "dual_check_limit": ("dual_check_limit", DEGRADED), "dual_check_disagreed": ("dual_check_disagreed", DEGRADED), "dual_check_unchecked": ("dual_check_unchecked", DEGRADED), "challenge_limit": ("challenge_limit", DEGRADED), "challenge_refuted": ("challenge_refuted", DEGRADED), "challenge_unreviewed": ("challenge_unreviewed", DEGRADED), "challenge_sources_lost": ("challenge_sources_lost", DEGRADED), # 核查 agent 自己触顶、按已有结果收尾:多半和节点自己的 *_limit 说的是同一次触顶,照样是缺口,记成它自己的 kind, # 不落成笼统的 warn——步数上限(max_steps)只有它说 "verify_limit": ("verify_limit", DEGRADED), # 核查 agent 的输出到了上限被截断(推理模型的思考也算在额度里):没交成结果,由节点按无法复算、未能反查记缺口。 # 同 verify_limit,是缺口不是断裂,记成它自己的 kind:说得清是输出额度的问题,换个模型就好 "verify_truncated": ("verify_truncated", DEGRADED), # 查询区间里这张表没有数据(data/coverage.py):查询结果整列是 0,口径卡的指标取自这些列。答案照常交付(出具契约另按 # 缺口降档),是缺口不是断裂;但换一张有这段数据的表再建一次大概率就好,所以能给出重来的指示(REBUILD) "query_no_data": ("query_no_data", DEGRADED), "metric_no_data": ("metric_no_data", DEGRADED), # 分析能力一轮(00-总方案 §3.7):只写说明、不自动重来(NOTE_ONLY)。答案照常交付,缺口照实说一句就够了: # 报告里判断词没挂对分析结论、原因没有依据,重写后仍有(check 标在报告里,strict 另记出具缺口) "report_analysis_claims": ("report_analysis_claims", 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 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 # 工具参数错了、步数不够、图没校验过,都属于改一下再来一遍就行的。 RETRYABLE = {"step_limit", "tool_error", "empty_completion", "node_failed"} #: 是缺口(DEGRADED)、却值得重建一次的信号:查询区间里这张表没有数据——图选错了表,换一张有这段数据的表大概率就好。 #: 答案照常交付,复核照样可以给出「按复核重来」的指示(should_retry、chat/turn.retry_after_review 都认) REBUILD = {"query_no_data", "metric_no_data"} #: 只写说明、不自动重来的信号(00-总方案 §3.7):都是缺口(DEGRADED)、不进 RETRYABLE / REBUILD。一次运行的信号全是 #: 这几种时复核不叫模型,说明就照信号原话写(review):答案照常、报告里已经标出,叫一次模型只是把同一句话换个说法 NOTE_ONLY = frozenset({"report_analysis_claims", REFUSED_CODE, "metric_comparability_skipped", ANALYSIS_CALL_FAILED}) #: 「有 n 次分析没做成」那条信号里最多点名几次,其余写「……」 _ANALYSIS_SHOWN = 3 #: 串行工具模式下塞给未执行调用的占位说明。它漏到用户面前当过答案, #: 所以复核要认得出来。从 nodes.llm 导入会绕成循环,这里按内容识别。 _PLACEHOLDER_MARKS = ("本轮只执行了第一个工具",) def answer_text(output: Mapping[str, Any] | None) -> str: """把 output 各字段拼成答案正文。和前端 answerText 同一套规则。""" if not output: return "" parts = [ value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) for key, value in output.items() if not key.startswith("_") and value not in (None, "") ] return "\n".join(parts).strip() def _event_field(event: Any, name: str, default: Any = None) -> Any: """事件既可能是 ORM 行也可能是 dict,两种都要吃得下。""" if isinstance(event, Mapping): return event.get(name, default) return getattr(event, name, default) def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list[Signal]: """纯规则扫一遍运行事件,挑出会影响答案可信度的那些。 不调模型、不碰网络。返回空列表就意味着这次运行干净,可以直接交付。 """ signals: list[Signal] = [] seen: set[tuple[str, str]] = set() def add(kind: str, detail: str, severity: str, reason: str | None = None) -> None: if severity == BENIGN: return key = (kind, detail) if key in seen: return seen.add(key) kinds.add(kind) signals.append(Signal(kind, detail, severity, reason)) kinds: set[str] = set() rows = list(events) # 工具报错之后,同一个工具在后面又跑成了,说明自愈接住了——那是缺口不是断裂。 # 不区分的话,参数自动纠正这条路每修一次就报一次「答案不可信」。 recovered: set[str] = set() failed_at: dict[str, int] = {} #: 节点名:node.started 带着画布上的名字,报失败时写名字,不写节点 id titles: dict[str, str] = {} for i, e in enumerate(rows): etype = str(_event_field(e, "type", "")) data = _event_field(e, "data") or {} if etype == EventType.NODE_STARTED and data.get("label"): titles.setdefault(str(_event_field(e, "node_id") or ""), str(data["label"])) tool = str(data.get("tool") or "") if etype == EventType.TOOL_ERROR and tool: failed_at.setdefault(tool, i) elif etype == EventType.TOOL_END and tool and tool in failed_at: recovered.add(tool) #: 每个报告节点最后一次 report.checked:「改过一次」的信号按最终结果定档(_settled) finals = _final_checks(rows) #: 报过的节点失败 (节点 id, 原话):子工作流节点跟着子图里那一步失败时认得出是同一件事 failures: list[tuple[str, str]] = [] #: 没做成的分析:算不了的(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 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: add("tool_args_fixed", f"工具 {tool} 第一次调用失败({err}),重试后成功", DEGRADED) else: add("tool_error", f"工具 {tool} 调用失败:{err}", BROKEN) elif etype == EventType.NODE_FAILED and data.get("reason") == "rejected": # 人的驳回(engine/compiler 给被驳回的那一步的 node.failed 带 reason=rejected,总方案 X9):不是故障,不说「执行 # 失败」;也不进 RETRYABLE——重跑只会把同一件事再问一遍人。答案没走完是缺口,记 DEGRADED(没有成果时 # empty_output 另按 BROKEN 说) err = str(data.get("error") or "").strip()[:200] if any(inner.startswith(f"{node_id}/") and said == err for inner, said in failures): continue # 子工作流里那一步被驳回、子工作流节点跟着收尾:同一件事,只报那一步(同下面的执行失败) failures.append((str(node_id), err)) add("node_rejected", f"节点「{_step_name(str(node_id), titles)}」被驳回:{err}", DEGRADED) elif etype == EventType.NODE_FAILED: err = str(data.get("error") or "").strip()[:200] if any(inner.startswith(f"{node_id}/") and said == err for inner, said in failures): # 子工作流里那一步失败了,子工作流节点(「sub」)跟着失败、原话相同:同一件事,只报更具体的那一步(上面已报) continue failures.append((str(node_id), err)) add("node_failed", f"节点「{_step_name(str(node_id), titles)}」执行失败:{err}", BROKEN) elif etype == EventType.RUN_FAILED: err = str(data.get("error") or "").strip()[:200] add("run_failed", f"运行失败:{err}", BROKEN) elif etype == EventType.RETRIEVE_END: if data.get("degraded") and "retrieve_degraded" not in kinds: # knowledge.py 在这条事件**之前**已经把每条具体原因发成了 warn #("重排结果解析不出来"、"多少条向量对不上")。两边都收就是同一件事 # 报两遍,一条含糊一条具体——这里只兜没有 warn 的情形 add("retrieve_degraded", "知识库检索能力降级,可能遗漏表述不同的内容", DEGRADED) if not data.get("count"): 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 "") kind, severity = WARN_CODES.get(code, ("warn", DEGRADED)) if code in _SETTLED_BY_REPORT and _settled(code, finals.get(str(node_id))): # 首稿没过、改过一次之后最终通过了(同一个报告节点最后一次 report.checked):不是缺口,不进异常清单。 # 复核模型被别的信号叫起来时,在 notes() 里照实说「重写后已全部通过」 continue reason = data.get("reason") 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) elif any(mark in text for mark in _PLACEHOLDER_MARKS): add("placeholder_output", "交付的是一句内部流程说明,而不是对问题的回答", BROKEN) elif leaked_markup(text): # 老运行、或者绕过了节点检查的路径:成果本身就是一段没执行的工具调用标记 add("markup_output", "交付的是一段未执行的工具调用标记,而不是对问题的回答", BROKEN) return signals def _step_name(node_id: str, titles: Mapping[str, str]) -> str: """失败的那一步给人看的名字。子工作流里的(「子工作流节点 id/…」)一层层接上 node.started 记的名字:「复核子流程 › 算一下」, 不写内部 id;没记名字的那一层照写 id 的那一段。""" if "/" not in node_id: return titles.get(node_id) or node_id parts = node_id.split("/") 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 条):带满了的,后面可能还有,不当作只剩标注 _CHECKED_CAP = 20 #: 证据不支持的几种判定(engine/judge 的 CONTRADICTED 加证据不足):最终还剩这些,改写就没有改好 _UNSUPPORTED = ("contradicted", "unsupported", "insufficient") def _final_checks(rows: list[Any]) -> dict[str, dict[str, Any]]: """每个报告节点最后一次 report.checked 的内容(节点 id → data)。""" out: dict[str, dict[str, Any]] = {} for e in rows: if str(_event_field(e, "type", "")) == EventType.REPORT_CHECKED: data = _event_field(e, "data") or {} out[str(_event_field(e, "node_id") or "")] = data if isinstance(data, dict) else {} return out def _annotations(final: Mapping[str, Any]) -> list[Mapping[str, Any]] | None: """最后一次核对里剩下的违规都是只重写一次、留在报告里标注的那几类时,返回它们(没剩违规是空列表);还有别的违规返回 None。标注的是:缺失断言(absence_claim,另发 report_absence_claim)、check 下的分析说法(engine/analysis_claims,另发 report_analysis_claims)——各有自己的信号说清楚,「改过一次」是不是改好了不看它们。strict 下的分析说法是出具缺口, 不算标注。""" from app.engine.analysis_claims import CODES as ANALYSIS_CODES left = [v for v in final.get("violations") or [] if isinstance(v, Mapping)] marked = {"absence_claim", *(ANALYSIS_CODES if final.get("analysis_claims") == "check" else ())} if final.get("ok") is True and not left: return [] if not left or len(left) >= _CHECKED_CAP or any(v.get("code") not in marked for v in left): return None return left def _settled(code: str, final: Mapping[str, Any] | None) -> bool: """「改过一次」之后最终是不是好了:report_repair 看最后一次核对有没有违规(ok、violations 为空),只剩标注 (_annotations)的也算好了;report_rewrite 看最后的裁判摘要里还有没有证据不支持的句子。找不到最终结果(老运行、 节点失败没写出 report.checked)的当没好。""" if not final: return False if code == "report_repair": return _annotations(final) is not None counts = ((final.get("judge") or {}).get("counts") or {}) if isinstance(final.get("judge"), dict) else {} return bool(counts) and not any(int(counts.get(k) or 0) for k in _UNSUPPORTED) def notes(events: Iterable[Any]) -> list[str]: """不是异常、但复核模型被别的信号叫起来时该知道的事(review_prompt 的「已经处理好的」):报告首稿没过核对、重写后 已全部通过(只剩标注的照实说剩下几处,那几处各有自己的信号);裁判认为证据不支持的句子改写后都有了依据。只说处理好 了的,没好的照旧是信号(scan)。""" rows = list(events) finals = _final_checks(rows) titles: dict[str, str] = {} first: dict[tuple[str, str], dict[str, Any]] = {} times: dict[tuple[str, str], int] = {} for e in rows: etype = str(_event_field(e, "type", "")) data = _event_field(e, "data") or {} node_id = str(_event_field(e, "node_id") or "") if etype == EventType.NODE_STARTED and data.get("label"): titles.setdefault(node_id, str(data["label"])) if etype == EventType.LOG and data.get("code") in _SETTLED_BY_REPORT: key = (node_id, str(data["code"])) first.setdefault(key, data) times[key] = times.get(key, 0) + 1 out: list[str] = [] for (node_id, code), data in first.items(): if not _settled(code, finals.get(node_id)): continue name = f"报告「{titles.get(node_id) or node_id}」" count = data.get("count") if code == "report_repair": head = f"首稿有 {count} 处没过核对" if isinstance(count, int) and count > 0 else "首稿没过核对" again = f"重写 {times[(node_id, code)]} 次后" if times[(node_id, code)] > 1 else "重写后" left = _annotations(finals.get(node_id) or {}) or [] if not left: out.append(f"{name}{head},{again}已全部通过") elif isinstance(count, int) and count > len(left): said = {"absence_claim": "缺失断言"} kinds = Counter(said.get(str(v.get("code")), "分析说法") for v in left) rest = "、".join(f"{n} 处{kind}" for kind, n in kinds.items()) out.append(f"{name}{head},{again}只剩已标出的 {rest}") else: head = f"有 {count} 句结论裁判认为证据不支持" if isinstance(count, int) and count > 0 \ else "有几句结论裁判认为证据不支持" out.append(f"{name}{head},改写后都有了依据") return out def worst(signals: Iterable[Signal]) -> str: """一组信号里最重的那档。没有信号时返回空串。""" ranked = sorted(signals, key=lambda s: _SEVERITY_RANK.get(s.severity, 1), reverse=True) return ranked[0].severity if ranked else "" def should_retry(signals: Iterable[Signal]) -> bool: """这些信号里有没有「重跑一次大概率能好」的:严重的那几类(RETRYABLE),或者值得重建一次的缺口(REBUILD)。""" return any((s.kind in RETRYABLE and s.severity == BROKEN) or s.kind in REBUILD for s in signals) def check_numbers(original: str, revised: str) -> list[str]: """复核重写的答案里,有哪些数字是原答案里没有的。 复核模型手上只有原答案和一份异常清单,没有任何原始数据——它写出来的新数字 只能是凭空来的。这条是硬拦截,不是提示:叙述层无算术权限是这个项目的地基 (见 issuance.decide_tier)。 直接复用出具校验那套回指逻辑,把原答案的数字当成受控指标集。 """ controlled = [ {"id": tok.raw, "value": tok.value} for tok in extract_numbers(original) ] report = trace_numbers(revised, controlled) return [item["token"] for item in report.unmatched] REVIEW_SYSTEM = ( "你负责复核一次数据问答的结果。执行过程中检测到了异常,你的任务是让用户" "在读这个答案之前就知道它哪里不可靠。\n\n" "你会拿到:用户的问题、系统给出的原答案、这次运行的异常清单。\n\n" "铁律:\n" "1. 你看不到任何原始数据。**不许写出原答案里没有的数字**——一个都不行。" "需要改写时只能重组原答案已有的内容。\n" "2. 异常必须写进 note,不许淡化或略过。降级要明说,不能悄悄地降。\n" "3. 如果异常说明这个答案根本不可信(比如取数工具就没跑通、检索什么都没查到)," "就直说答不了、说清缺什么,不要拿一个看起来完整的答案去糊。\n" "4. note 写给用户看,一到两句中文,说人话:出了什么事、对这个答案意味着什么。" "不要复述事件名或代码。\n\n" "输出 JSON:\n" '- note:给用户的异常说明(必填)\n' '- answer:重写后的答案。原答案本身没问题、只是需要补一句说明时,留空字符串\n' '- retry:这次运行是不是「换个配置重跑一遍大概率就好了」(比如步数不够、' "工具参数写错)。问题本身没问清楚导致的,填 false" ) REVIEW_SCHEMA: dict[str, Any] = { "title": "review", "type": "object", "properties": { "note": {"type": "string", "description": "给用户的异常说明,一到两句中文"}, "answer": {"type": "string", "description": "重写后的答案;不需要改写就留空"}, "retry": {"type": "boolean", "description": "重跑一次是否大概率能好"}, }, "required": ["note"], } #: 答案带着逐段证据时追加给复核模型的话 LOCKED_NOTE = ( "\n\n这份答案带有逐段证据(报告里的每个数字都对应着核对过的出处),不能改写:" "answer 必须留空,只在 note 里说明异常。" ) def review_prompt(question: str, answer: str, signals: list[Signal], *, locked: bool = False, settled: Iterable[str] = ()) -> str: """拼给复核模型的请求。 只给信号清单,不给原始事件流:几百条事件塞进 prompt 既贵又没用,而信号 已经是规则层提炼过的结论。settled 是已经处理好的(notes():首稿没过、重写后已全部通过……): 照实告诉复核模型,免得它把「改过一次」写成「仍未全部通过、可能不准」。 """ lines = "\n".join( f"- [{'严重' if s.severity == BROKEN else '降级'}] {s.detail}" for s in signals ) done = "\n".join(f"- {n}" for n in settled) return ( f"用户的问题:\n{question or '(未记录)'}\n\n" f"系统给出的原答案:\n{answer or '(空)'}\n\n" f"检测到的异常:\n{lines}" + (f"\n\n已经处理好的(不是异常,不要写成缺口或「可能不准」):\n{done}" if done else "") + (LOCKED_NOTE if locked else "") ) @dataclass class ReviewResult: """复核结论。verdict 是对「系统实际做了什么」的陈述,不是模型的自我评价。""" verdict: str # ok | annotated | rewritten | reverted | failed note: str = "" answer: str | None = None # None 表示沿用原答案 #: 改写之前的那份。改写是有损的,原件得跟着一起落库——否则刷新之后 #: 只剩一个被重组过的答案,想对照都没得对照 original: str | None = None retry: bool = False signals: list[Signal] = None # type: ignore[assignment] severity: str = "" def as_dict(self) -> dict[str, Any]: return { "verdict": self.verdict, "note": self.note, "answer": self.answer, "original": self.original, "retry": self.retry, "severity": self.severity, "signals": [s.as_dict() for s in (self.signals or [])], } async def review( model: Any, *, question: str, answer: str, signals: list[Signal], locked: bool = False, settled: Iterable[str] = (), ) -> ReviewResult: """叫一次模型复核。调用方负责确保 signals 非空——空信号根本不该走到这里。 复核本身失败不能让整轮挂掉:答案已经在手上了,复核只是加一层说明。 所以这里把异常全兜住,最差也要把原答案原样交出去。 locked:成果带 _evidence(报告撰写节点产出、逐段对应证据文档)。这种答案只能 加说明、不能改写——改写之后界面上点开的出处,指向的是一段已经不存在的文字。 """ severity = worst(signals) rule_retry = should_retry(signals) fallback_note = ";".join(s.detail for s in signals if s.severity == BROKEN) or ";".join( s.detail for s in signals ) if signals and all(s.kind in NOTE_ONLY for s in signals): # 只写说明、不自动重来(NOTE_ONLY):不叫模型,照信号原话说明 return ReviewResult(verdict="annotated", note=fallback_note, retry=False, signals=signals, severity=severity) messages = [("system", REVIEW_SYSTEM), ("human", review_prompt(question, answer, signals, locked=locked, settled=settled))] try: raw = await model.with_structured_output(REVIEW_SCHEMA).ainvoke(messages) if not isinstance(raw, dict): raise TypeError("结构化输出没有返回对象") except Exception: # noqa: BLE001 - 不支持结构化输出的模型退回文本解析 try: from app.api.copilot import _extract_json from app.engine.state import message_text raw = _extract_json(message_text(await model.ainvoke(messages))) except Exception: # noqa: BLE001 # 模型这条路彻底不通。规则层已经知道出了什么事,照样说得出话 return ReviewResult( verdict="annotated", note=fallback_note, retry=rule_retry, signals=signals, severity=severity, ) note = str(raw.get("note") or "").strip() or fallback_note revised = str(raw.get("answer") or "").strip() # 规则层管**资格**,模型只有**否决权**。反过来(两边取并)的话,模型就能 # 越过规则强推重跑——而"检索什么都没查到"这类,重跑一遍还是查不到, # 白烧一次建图加一次跑图。模型没表态时按规则走 retry = rule_retry and bool(raw.get("retry", True)) if locked or not revised or revised == answer.strip(): return ReviewResult( verdict="annotated", note=note, retry=retry, signals=signals, severity=severity, ) invented = check_numbers(answer, revised) if invented: # 重写里冒出了原答案没有的数字。整条退回——说明还是要给的, # 但那份正文一个字都不能用 return ReviewResult( verdict="reverted", note=f"{note}\n\n(复核改写里出现了原答案中没有的数字" f"({'、'.join(invented[:5])}),已退回原答案。)", retry=retry, signals=signals, severity=severity, ) return ReviewResult( verdict="rewritten", note=note, answer=revised, original=answer, retry=retry, signals=signals, severity=severity, )