Files
AgentLab/backend/app/engine/nodes/analysis.py
T
ThearchyHelios 6e38df4fbf 分析卡里随数据变化的调用错误记成算不了的一项,不让卡失败(复核 R6):analysis.run 加参数 refuse_data_errors(缺省假),为真时 DATA_ERRORS 里的调用错误——上一层没做成 parent_refused、下钻的段这期没有 parent_key_missing、期次这期没有 period_not_found(变化拆解、波动基线的本期、差异检验按期比、可比性检查)、总数那一格越界或为空 row_out_of_range / headline_empty、要比的组这期没有 unknown_group / too_few_groups、要用的那一格这期为空 empty_cell(乘积拆解的乘积和因子;差异检验的计数为空原来报的是 not_count,改报 empty_cell)——记成 status refused 的结论:契约 REASONS 加 parent_refused、input_unavailable,CallError 加 reason(照实说是哪一项、哪个值,不带改法),判定语「{叫法}没有做成:原因」;params 照调用原样(label 照样消毒),取过还没记用法的快照都记进输入(Inputs.settle,角色照第一次取时的),上一层在核对它做没做成之前就记进输入,复算按它们走到同一处;阈值和判定标准同各算子自己算不了时那一份(各模块的 USED / refusal_basis,算子本身也改用它)。recompute 一律按真复现;结构性的调用错误照旧抛。分析卡运行器传真,agent 工具不传。顺手:段数不够的原因里维度叫法和汉字之间照 say 的规矩补空格
2026-10-21 15:47:30 +08:00

170 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分析卡(节点类型 analysis,docs/design/analysis-round/00-总方案.md §3.2):对上游查询节点的结果固定做几项确定性分析。
**模板定骨架。** 做哪几项、每项分析哪个上游查询节点、用哪种方法,写在节点上(items),随版本固定;阈值来自发起时的设置
快照,卡上可以写覆盖(overrides,同样随版本固定)。问数据里要看了数才知道拆哪个维度的,交给 Agent 的分析工具——两个入口
共用一套算子(app.analysis.run)、一种结论形状、一个证据种类。
**读工件库里那份完整结果。** 照合并查询的做法(engine/nodes/merge.py)从 state.nodes 取上游节点交回的查询快照 id,按 id
取回快照(取回时复验哈希),而且只认这次运行台账里 kind query 的那几份:别的工具拼一个同形的 JSON,里面的快照不是平台
落的。遮罩列(数据源设的、按 SQL 认出改了名字的)不当维度、不当度量,和证据面板同一套认法(app/tools/analysis.py)。
**产出。** nodes.<卡>.results.<项> = {status, verdict, direction, values, phrase, artifact}:verdict 是判定码(outside_band、
driver……),下游条件分支能按它走(周报「超出常规波动就转人工」)。每项的整份结论落成工件,台账记一条 kind analysis
(contract.ledger_entry,带 item),报告的写作目录编成 A{n};每项另发一条 analysis.end(运行详情、运行对比按项对齐)。
**一项算不了不让卡失败。** 算不了(历史不够、本期不完整、截断……)就是这一项的结论(status refused),照实记、发一条
analysis_refused 提醒,接着做下一项。模板定死了调用,这期的数据里没有要用的那一项(上一层没做成、下钻的段这期没有、期次
这期没有、总数那一格越界或为空、要比的组这期没有,analysis.DATA_ERRORS)不是写错了,同样记成算不了的一项
(run 的 refuse_data_errors)。结构性的调用错误(列不存在、遮罩列、比率当可加、重复行、参数写错……)是模板的毛病:整张卡
失败,说清是哪一项、怎么改。
分析的结果不作口径卡的输入(D16):口径卡读 nodes.<卡>.results 的,按追不到原始数据处理(受管级发布时另有提示)。
"""
from __future__ import annotations
import asyncio
from typing import Any
from pydantic import ValidationError
from app.analysis import contract
from app.analysis.contract import AnalysisResult, CallError, InputSnapshot
from app.core import artifact_store
from app.core.errors import describe_exception
from app.core.events import EventType
from app.engine.context import NodeContext, NodeError
from app.engine.evidence import ledger_enabled, next_exec
from app.engine.labels import ANALYSIS_OP_LABEL
from app.engine.nodes.merge import _FAILED_PREFIXES, _input_payload
from app.engine.state import GraphState
from app.tools.analysis import (
REFUSED_CODE, context_of, hypothesis_of, input_snapshot, masked_of, run_snapshot, settings_of, store, tool_context,
)
def card_view(result: AnalysisResult, artifact: str) -> dict[str, Any]:
"""一项结论在节点产出里的样子(nodes.<卡>.results.<项>):状态、判定码、方向、具名标量、判定语、结论工件。"""
verdict = result.get("verdict") or {}
return {"status": result.get("status"), "verdict": verdict.get("code"), "direction": verdict.get("direction"),
"values": dict(result.get("values") or {}), "phrase": result.get("phrase") or "", "artifact": artifact}
async def run_analysis(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
from app.engine.schema import analysis_issues, analysis_refs
# 配置的规矩和画图时同一套(schema.analysis_issues):发起前的校验已经挡过,这里兜底,写错了照样说清是哪一项
if errors := [i for i in analysis_issues(ctx.run.spec, ctx.node) if i.level == "error"]:
raise NodeError(ctx.node.id, errors[0].message)
items: list[dict[str, Any]] = ctx.config["items"]
settings = await settings_of(run_snapshot(ctx.run), ctx.config.get("overrides") or None)
queries = [e for e in state.get("evidence") or [] if isinstance(e, dict) and e.get("kind") == "query"]
inputs: dict[str, InputSnapshot] = {}
for item in items:
for _, node_id in analysis_refs(item):
if node_id not in inputs:
inputs[node_id] = await _load_input(state, ctx, node_id, queries)
context = await context_of(list(inputs.values()), tool_ctx=tool_context(ctx.run, ctx.node.id, ctx.emit))
import app.analysis as analysis_ops # 按模块取 run:测试换替身时只换模块上的这一个函数
exec_no = next_exec(state, ctx.node.id)
done: dict[str, AnalysisResult] = {}
results: dict[str, Any] = {}
entries: list[dict[str, Any]] = []
for item in items:
item_id, op = item["id"], item["op"]
who = f"分析项「{item_id}」({ANALYSIS_OP_LABEL[op]})"
request = _request(item, state, ctx, who)
try:
# 下钻(parent)只认这张卡里排在前面、已经做完的项:done 里此刻只有它们(算不了的也在,下钻那一项照实记成算不了)
result = await analysis_ops.run(request, inputs.get, settings=settings, context=context, parents=done.get,
refuse_data_errors=True)
except CallError as e:
raise NodeError(ctx.node.id, f"{who}:{e.message}。请在分析卡中改正这一项") from None
except Exception as e: # noqa: BLE001 - 算子本身出错:说清是哪一项,原因照实
raise NodeError(ctx.node.id, f"{who}执行出错:{describe_exception(e)}") from e
try:
artifact = await store(result, run_id=ctx.run.run_id, node_id=ctx.node.id)
except Exception as e: # noqa: BLE001 - 存不下就没有出处可言,不能带着一份引用不了的结论往下走
raise NodeError(ctx.node.id, f"{who}的结论没能存下来(工件库写入失败),下游无法引用它。"
"请稍后点「继续运行」重试", retryable=True) from e
done[item_id] = result
results[item_id] = card_view(result, artifact)
entries.append(contract.ledger_entry(result, node_id=ctx.node.id, exec_no=exec_no, artifact=artifact,
item=item_id))
verdict = result.get("verdict") or {}
ctx.emit(EventType.ANALYSIS_END, item=item_id, op=op, mode=result.get("mode"), status=result.get("status"),
verdict=verdict.get("code"), direction=verdict.get("direction"), phrase=result.get("phrase") or "",
reason_code=result.get("reason_code"), analysis_artifact=artifact)
if result.get("status") == "refused":
# 算不了是一条结论,不让卡失败;照实提醒一句(结构化字段给界面读,不让它从 message 里解析中文)
ctx.emit(EventType.LOG, level="warn", code=REFUSED_CODE, item=item_id, op=op,
reason_code=result.get("reason_code"), reason=result.get("reason"),
message=f"「{ctx.node.title}」的{who}没有做成:{result.get('reason') or '原因未知'}")
output: dict[str, Any] = {"results": results}
updates: dict[str, Any] = {"nodes": {ctx.node.id: output}}
if ledger_enabled(ctx.run) and entries:
updates["evidence"] = entries
var_name = str(ctx.config.get("assign_to") or "").strip()
if var_name:
updates["vars"] = {var_name: output}
return updates
def _request(item: dict[str, Any], state: GraphState, ctx: NodeContext, who: str) -> contract.AnalysisRequest:
"""一项 → 一次分析的请求。参数里的 {{ }} 先按当前状态渲染(按运行输入选期次);主输入(from)就是 agent 工具的
query。参数按 agent 工具同一份参数模型校验(tools/builtin/analysis.ARGS),写错了说清是哪一项、它接受什么。"""
from app.tools.builtin.analysis import ARGS, request_of
from app.tools.registry import describe_args
op = item["op"]
params = ctx.render(dict(item.get("params") or {}), state)
if isinstance(item.get("from"), str) and item["from"].strip():
params["query"] = item["from"].strip()
if op in contract.MODES:
params["mode"] = item.get("mode")
if hypothesis := hypothesis_of(item.get("hypothesis")):
params["hypothesis"] = hypothesis
try:
args = ARGS[op].model_validate(params)
except ValidationError as e:
raise NodeError(ctx.node.id, f"{who}的{describe_args(ARGS[op], params, e)}") from None
return request_of(op, args)
async def _load_input(state: GraphState, ctx: NodeContext, node_id: str,
queries: list[dict[str, Any]]) -> InputSnapshot:
"""上游一个查询节点这次交回的查询快照 → 算子的一份输入(含遮罩列)。只认这次运行台账里 kind query 的快照。"""
target = ctx.run.spec.node_map()[node_id] # 节点在不在、在不在上游、是不是查询节点,执行前已经校验过
who = f"输入「{target.title}」"
value = (state.get("nodes") or {}).get(node_id)
if value is None:
raise NodeError(ctx.node.id, f"{who}还没有运行结果:请确认它连在分析卡之前")
if isinstance(value, dict) and value.get("skipped"):
raise NodeError(ctx.node.id, f"{who}满足跳过条件被跳过了,没有查询结果可以分析")
if isinstance(value, dict) and value.get("failed"):
raise NodeError(ctx.node.id, f"{who}执行失败,没有查询结果可以分析:{value.get('error') or '原因未知'}")
if isinstance(value, str) and value.startswith(_FAILED_PREFIXES):
raise NodeError(ctx.node.id, f"{who}的查询没有成功,无法分析:{value[:300]}")
payload = _input_payload(value)
artifact = payload.get("artifact") if payload else None
if not isinstance(artifact, str) or not artifact:
raise NodeError(ctx.node.id, f"{who}的结果不是存成快照的查询结果。分析卡只能分析数据库查询(调用数据库查询工具的"
"「调用工具」节点)或「合并查询」的结果")
if not any(e.get("artifact") == artifact for e in queries):
raise NodeError(ctx.node.id, f"{who}交回的不是这次运行里平台执行的查询(台账里没有这份查询快照),不能分析。"
"请改选调用数据库查询工具的「调用工具」节点或「合并查询」")
try:
content = await asyncio.to_thread(artifact_store.load, artifact)
except ValueError:
raise NodeError(ctx.node.id, f"{who}的查询快照与哈希不一致,疑似被修改,不能分析") from None
if not isinstance(content, dict):
raise NodeError(ctx.node.id, f"{who}的查询快照已不存在,无法分析。请重新运行它")
return input_snapshot(content, ref=node_id, artifact=artifact, node_id=node_id,
masked=await masked_of(content, artifact))
__all__ = ["REFUSED_CODE", "card_view", "run_analysis"]