合入 W1-c 入口:五个分析工具与方法注入、分析卡、口径卡可比性、运行对比按项对齐、设置与快照、使用者白名单

This commit is contained in:
ThearchyHelios
2026-10-21 13:59:06 +08:00
31 changed files with 3246 additions and 54 deletions
+61
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.analysis import contract as analysis_contract
from app.api import health
from app.api.coded import PUBLIC_URL_ENV_LOCKED, CodedHTTPException
from app.core import public_url
@@ -872,6 +873,59 @@ async def eval_settings(session: AsyncSession) -> dict[str, Any]:
return eval_values(row.value if row else None)
# --------------------------------------------------------------------------
# 分析:判定用的阈值和次数上限(docs/design/analysis-round/01-算子与判定.md §10)。键、缺省、取值范围、叫法、校验都在
# app/analysis/contract.py 一处:设置页、发起时的快照(run.options["analysis"],engine/launch.py)、分析卡上的覆盖按同一张表。
# agent 改不了这些:分析工具的参数里没有阈值。搭建者只读,管理员写(整个设置接口的规矩)
# --------------------------------------------------------------------------
#: 设置里 analysis 这一组(contract.SETTINGS_KEY)
ANALYSIS_GROUP = analysis_contract.SETTINGS_KEY
#: 每一项的叫法(设置页、报错都用它,句子里不裸写键名)
ANALYSIS_SETTING_LABEL = analysis_contract.LABELS
def analysis_problem(value: Any) -> str | None:
"""设置页写进来的 analysis 这一组有什么问题(不认识的键、类型、范围、两两之间的大小),没问题返回 None。写进来的这一组
整个替换存着的(同别的组),缺的项按缺省,所以两两之间的大小也按缺省补齐了比。"""
if not isinstance(value, dict):
return f"「分析」设置格式有误,需要是一个对象,当前为「{value}」"
for key in value:
if key not in analysis_contract.DEFAULTS:
return (f"「分析」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{ANALYSIS_SETTING_LABEL[k]}」" for k in analysis_contract.DEFAULTS))
return analysis_contract.settings_problem(value)
def _analysis_item_ok(key: str, value: Any) -> bool:
"""一项本身对不对(类型、范围),不看和别的项的大小:存着的一组逐项认,两两之间的另看。"""
kind, low, high = analysis_contract.RANGES[key]
if isinstance(value, bool) or not isinstance(value, (int, float)) or (kind is int and not isinstance(value, int)):
return False
return low <= value <= high
def analysis_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)analysis 一组 → 补齐缺省;存坏了的项按缺省。两两之间的大小对不上的(「明显超出」不大于
「超出常规波动」、最多用几期历史小于至少几期)那一对都按缺省:只换掉一项,可能换出另一种对不上。"""
stored = stored if isinstance(stored, dict) else {}
out = dict(analysis_contract.DEFAULTS)
for key in analysis_contract.DEFAULTS:
if key in stored and _analysis_item_ok(key, stored[key]):
out[key] = float(stored[key]) if analysis_contract.RANGES[key][0] is float else stored[key]
for pair in (("k_warn", "k_alert"), ("min_history", "max_history")):
if analysis_contract.settings_problem({k: out[k] for k in pair}) is not None:
out.update({k: analysis_contract.DEFAULTS[k] for k in pair})
return out
async def analysis_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 analysis 一组,没存过的补缺省。发起运行时快照这一份(engine/launch.py),没有快照的运行由分析工具、分析卡、
口径卡的可比性检查现读(app/tools/analysis.py)。"""
row = await session.get(Setting, ANALYSIS_GROUP)
return analysis_values(row.value if row else None)
# --------------------------------------------------------------------------
# 邮件服务(notify/mail.py,04 细案 B2):只有管理员能改(整个设置接口的改动都要管理员),口令加密存、读的时候只说配没配
# --------------------------------------------------------------------------
@@ -954,6 +1008,8 @@ DEFAULT_SETTINGS: dict[str, Any] = {
LEDGER_GROUP: ledger_values(None),
# 评测:预算的全站缺省(题集上可以改)、保留几次明细、结论裁判的缺省开关
EVAL_GROUP: eval_values(None),
# 分析:判定用的阈值、一次运行最多分析几次、最多下钻几层(app/analysis/contract.py)
ANALYSIS_GROUP: analysis_values(None),
# 邮件服务(SMTP):通知渠道的邮件格式用它发信。缺省关
MAIL_GROUP: _mail_public(None),
# 运行并发(app/cluster/slots.py):全站同时运行的上限,交互、应用、定时、系统四类各自的上限和优先级。多进程时几个进程合起来算
@@ -1024,6 +1080,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP))
merged[LEDGER_GROUP] = ledger_values(stored.get(LEDGER_GROUP))
merged[EVAL_GROUP] = eval_values(stored.get(EVAL_GROUP))
merged[ANALYSIS_GROUP] = analysis_values(stored.get(ANALYSIS_GROUP))
merged[CONCURRENCY_GROUP] = concurrency_values(stored.get(CONCURRENCY_GROUP))
# 口令不回(password 恒为 null),只说配没配(password_set)
merged[MAIL_GROUP] = _mail_public(stored.get(MAIL_GROUP))
@@ -1136,6 +1193,10 @@ async def put_settings(
if problem := eval_problem(payload.values[EVAL_GROUP]):
raise HTTPException(422, problem)
payload.values[EVAL_GROUP] = eval_values(payload.values[EVAL_GROUP])
if ANALYSIS_GROUP in payload.values:
if problem := analysis_problem(payload.values[ANALYSIS_GROUP]):
raise HTTPException(422, problem)
payload.values[ANALYSIS_GROUP] = analysis_values(payload.values[ANALYSIS_GROUP])
if CONCURRENCY_GROUP in payload.values:
from app.cluster import slots
+7
View File
@@ -47,6 +47,13 @@ def describe_version(version: Any, *, workflow_name: str) -> str:
else f"口径卡{title},{caliber},指标转抄自别处的口径卡")
lines += [f"- {node_id}.{m['id']} {m.get('name') or ''}{('(' + m['unit'] + ')') if m.get('unit') else ''}"
for m in metrics]
elif node["type"] == "analysis":
from app.engine.labels import ANALYSIS_OP_LABEL
items = [i for i in cfg.get("items") or [] if isinstance(i, dict) and i.get("id")]
lines.append(f"分析卡{title},分析项 {len(items)} 个:" if items else f"分析卡{title},还没有分析项")
lines += [f"- {node_id}.results.{i['id']} {ANALYSIS_OP_LABEL.get(str(i.get('op')), str(i.get('op') or ''))}"
for i in items]
elif node["type"] == "report":
lines.append(f"报告撰写{title}")
elif node["type"] in ("tool", "merge"):
+11 -7
View File
@@ -4,13 +4,14 @@
start_run 先查来源和运行类别)。图本身再按这里收窄,理由是同一条:对 user 角色来说遮罩就是边界(A7 第 6 条),它能
碰的只有「读数据、整理、撰写」这一类,不能执行代码、不能调外部系统、不能写任何东西:
- 节点只许:输入、模型、Agent、报告、输出、知识库检索、数据整形、条件分支、口径卡、合并查询、调用工具。代码、协作、
- 节点只许:输入、模型、Agent、报告、输出、知识库检索、数据整形、条件分支、口径卡、合并查询、分析卡、调用工具。代码、协作、
子工作流、循环、人工审批、记忆写入、校验、双盲复算、反方审稿一律不许——后几种不是危险,而是问数据用不到,多放一种
就多一处要单独核对遮罩的地方;
- 工具(Agent 绑的、调用工具节点调的同一份白名单,engine/launch.tool_bindings 一并列出)只许数据源工具(db_query__ /
db_schema__,查询结果按 MASK_RESULTS_OPTION 在工具边界遮)和只读的内置工具(计算、当前时间、JSON 取值、知识库检索、
记忆读取)。自定义工具、MCP、文件、网页、HTTP、代码执行、平台的写入能力(records_append 这类)一律不许;模板拼出来的
工具名({{ vars.tool }})发起时不知道是谁,也不许。调用工具节点放行的理由:它和 Agent 调同一个查询工具等价——同一个
记忆读取、分析)。分析工具不查库,只读本节点查过、在工具边界遮过的结果,遮罩列不当维度、度量(app/tools/analysis.py);
分析卡读上游查询节点的快照,同一套遮罩。自定义工具、MCP、文件、网页、HTTP、代码执行、平台的写入能力(records_append
这类)一律不许;模板拼出来的工具名({{ vars.tool }})发起时不知道是谁,也不许。调用工具节点放行的理由:它和 Agent 调同一个查询工具等价——同一个
ToolContext 带着遮罩、只读两个开关(engine/nodes/tools.py 的 _tool_ctx),参数由人写死反而比模型挑的更好核对;
周期看板、异常处置这些模板的取数节点就是它(个人应用和问数据同一份白名单,blueprints/personal.policy_problem);
- 数据源查询一律只读:可写源上的写语句由守卫拒掉,不交给审批(api/runs.py、个人应用刷新、个人蓝图试运行给运行打上
@@ -28,11 +29,14 @@ from __future__ import annotations
from typing import Any
from app.analysis.contract import TOOL_NAMES
#: 问数据运行里 user 角色能用的节点类型(engine/schema.py 的 NodeType 取值)
CHAT_NODE_TYPES = frozenset({"input", "llm", "agent", "report", "output", "retrieve", "transform", "branch",
"metrics", "merge", "tool"})
#: Agent 能绑的只读内置工具(tools/builtin/)
CHAT_BUILTIN_TOOLS = frozenset({"calculator", "current_time", "json_query", "kb_search", "memory_recall"})
"metrics", "merge", "analysis", "tool"})
#: Agent 能绑的只读内置工具(tools/builtin/):五个分析工具也在内(contract.TOOL_NAMES,不查库)
CHAT_BUILTIN_TOOLS = frozenset({"calculator", "current_time", "json_query", "kb_search", "memory_recall",
*TOOL_NAMES.values()})
#: 运行时改记忆范围、知识库的配置键(engine/nodes 的 ctx.cfg("memory_scope") / ctx.cfg("collection"),图级 defaults 同样生效)
SCOPE_KEYS = ("memory_scope", "collection")
SCOPE_REFUSED = "没有权限:使用者在问数据中不能指定记忆范围和知识库,使用的是管理员设好的默认值"
@@ -90,7 +94,7 @@ def chat_graph_problem(graph: Any, *, where: str = "问数据") -> str | None:
return f"没有权限:{who}的工具名由模板拼成,发起前无法确定是哪个工具,使用者在{where}中不能这样绑定工具"
if not _tool_allowed(name):
return (f"没有权限:{who}绑定了工具「{name}」。使用者在{where}中只能使用数据源查询和只读的内置工具"
"(计算、当前时间、JSON 取值、知识库检索、记忆读取)")
"(计算、当前时间、JSON 取值、知识库检索、记忆读取、分析)")
return None
+3
View File
@@ -51,6 +51,9 @@ class EventType(StrEnum):
# 合并查询做完了:哪几个输入(别名、节点、行数、数据源)、哪条合并 SQL、得到几行、有哪些警告。
# 运行详情据此展示这一步;完整结果在 query_artifact 指向的查询快照里
MERGE_END = "merge.end"
# 分析卡里的一项做完了(engine/nodes/analysis.py):哪一项、什么分析、状态、判定、判定语、结论工件(analysis_artifact)。
# 运行详情据此展示这一步,运行对比按项对齐两次的判定;整份结论在工件里。agent 分析工具的结论记在 tool.end 上
ANALYSIS_END = "analysis.end"
SANDBOX_START = "sandbox.start"
SANDBOX_END = "sandbox.end"
+21 -5
View File
@@ -150,6 +150,8 @@ def template_refs(spec: Any, source: str, table: str, tables: Iterable[str] | No
- Agent 节点、协作成员:绑定了 db_query__<源> → possible(每个成员各一项,带 member)。
- 合并查询:输入(别名 → 节点)里有用到这张表的 → 取输入里最强的那一级,via 写经由哪些输入。合并的合并
一路追到底;成环(校验会报错的图)时停下。
- 分析卡:各项的输入(主输入、两份查询、总数两格落在的节点)里有用到这张表的,同合并查询(目录里度量类型、业务日期
列改了,分析的判定可能跟着变),via 写经由哪些输入(alias 是那一项的标识)。
"""
wanted = name_key(table)
known = list(tables) if tables is not None else None
@@ -175,16 +177,15 @@ def template_refs(spec: Any, source: str, table: str, tables: Iterable[str] | No
resolved: dict[str, dict[str, Any] | None] = {}
def through(node_id: str, trail: frozenset[str]) -> dict[str, Any] | None:
"""合并查询节点经由输入用到这张表的样子;用不到返回 None。"""
"""合并查询、分析卡经由输入用到这张表的样子;用不到返回 None。"""
if node_id in resolved:
return resolved[node_id]
node = by_id.get(node_id)
if node is None or _kind(node) != "merge" or node_id in trail:
if node is None or _kind(node) not in ("merge", "analysis") or node_id in trail:
return own.get(node_id)
via: list[dict[str, Any]] = []
best: str | None = None
inputs = (node.config or {}).get("inputs")
for alias, target in (inputs.items() if isinstance(inputs, Mapping) else []):
for alias, target in _inputs_of(node):
if not isinstance(target, str):
continue
hit = own.get(target) or through(target, trail | {node_id})
@@ -198,13 +199,28 @@ def template_refs(spec: Any, source: str, table: str, tables: Iterable[str] | No
for node in nodes:
if node.id in own:
out.append(own[node.id])
elif _kind(node) == "merge" and (hit := through(node.id, frozenset())):
elif _kind(node) in ("merge", "analysis") and (hit := through(node.id, frozenset())):
out.append(hit)
order = {n.id: i for i, n in enumerate(nodes)}
out.sort(key=lambda r: order.get(r["node_id"], 0))
return out
def _inputs_of(node: Any) -> list[tuple[str, Any]]:
"""合并查询、分析卡的输入:[(别名, 节点 id)]。合并查询是 inputs 的别名;分析卡是各项的标识(一项引用几个节点就几条,
schema.analysis_refs)。"""
config = node.config or {}
if _kind(node) == "merge":
inputs = config.get("inputs")
return list(inputs.items()) if isinstance(inputs, Mapping) else []
from app.engine.schema import analysis_refs
items = config.get("items")
found = [(str(item.get("id") or ""), target) for item in (items if isinstance(items, list) else [])
if isinstance(item, Mapping) for _, target in analysis_refs(dict(item))]
return list(dict.fromkeys(found))
async def _published(session: AsyncSession) -> list[tuple[Any, Any]]:
"""每个发布过的模板和它当前的已发布版本:[(Workflow, WorkflowVersion)]。"""
from app.db.models import Workflow, WorkflowVersion
+57 -1
View File
@@ -233,6 +233,10 @@ def compare(base: Side, target: Side) -> dict[str, Any]:
- queries:每对查询一项 {node_id, node_title, ordinal, structure, base_sql, target_sql, literals: [{base,
target}](只列不同的字面量), base_rows, target_rows, base_truncated, target_truncated, base_artifact,
target_artifact, differences: [结构上差在哪], problem?(快照读不出来、被改过)}
- analyses:分析卡每一项一条 {node_id, title, item, op, mode, base, target(各自 {status, verdict, direction,
phrase, artifact} 或 None), change}:两次运行的分析卡按节点和项的标识对齐(analysis.end 事件,循环里执行了几次的取
最后一次),change 是 same(判定和判定语都一样)/ verdict(状态或判定变了)/ phrase(判定一样、判定语里的数变了)/
added / removed。只列出两次的判定,不拆变化来源
- notes:整体的说明:连线、默认设置、运行输入的变化,口径卡执行了几次、读不出来,遮罩
"""
return _Compare(base, target).result()
@@ -330,6 +334,38 @@ def _queries(side: Side) -> list[_Query]:
return out
def _analysis_items(side: Side) -> dict[str, tuple[dict[str, dict[str, Any]], int]]:
"""封存范围内每张分析卡各项最后一次的结论(analysis.end,engine/nodes/analysis.py 每项一条):{卡: ({项: 事件数据},
执行了几次)},项按记下的先后。循环里执行了几次的,同一项后一次的盖过前一次。"""
out: dict[str, tuple[dict[str, dict[str, Any]], int]] = {}
for _, etype, node_id, data in side.events:
if not node_id or not isinstance(data, dict):
continue
if etype == EventType.ANALYSIS_END and isinstance(data.get("item"), str):
items, runs = out.setdefault(node_id, ({}, 0))
items[data["item"]] = data
elif etype == EventType.NODE_FINISHED and node_id in out:
items, runs = out[node_id]
out[node_id] = (items, runs + 1)
return out
def _analysis_side(data: dict[str, Any] | None) -> dict[str, Any] | None:
if data is None:
return None
return {"status": data.get("status"), "verdict": data.get("verdict"), "direction": data.get("direction"),
"phrase": data.get("phrase") or "", "artifact": data.get("analysis_artifact")}
def _analysis_change(base: dict[str, Any] | None, target: dict[str, Any] | None) -> str:
if base is None or target is None:
return ADDED if base is None else REMOVED
if (base.get("status"), base.get("verdict"), base.get("direction")) != \
(target.get("status"), target.get("verdict"), target.get("direction")):
return "verdict"
return "same" if (base.get("phrase") or "") == (target.get("phrase") or "") else "phrase"
def _outputs(side: Side) -> dict[str, tuple[Any, ...]]:
"""每个节点在封存范围内交出的产出工件(node.finished 的 artifact),按执行的先后。工件按内容寻址:两次运行的同一个
节点交出的一模一样,工件 id 就一样。产出没落成工件的记 None,核对不了。"""
@@ -803,8 +839,9 @@ class _Compare:
summary = {name: 0 for name in CLASSES}
for m in metrics:
summary[m["classification"]] += 1
analyses = self._analyses()
return {"target": self.target.run, "base": self.base.run, "summary": summary, "template": self.template,
"metrics": metrics, "queries": queries, "notes": list(dict.fromkeys(self.notes))}
"metrics": metrics, "analyses": analyses, "queries": queries, "notes": list(dict.fromkeys(self.notes))}
def _title_of(self, node: dict[str, Any]) -> str:
return str(_data(node).get("label") or node.get("id"))
@@ -1395,6 +1432,25 @@ class _Compare:
return {"base_version": self.base.run.get("version"), "target_version": self.target.run.get("version"),
"same_graph": not nodes and same_edges and same_defaults, "nodes": nodes}, changed, inherited
# ---------------------------------------------------------------- 分析卡
def _analyses(self) -> list[dict[str, Any]]:
"""两次运行的分析卡按节点、项的标识对齐(最小可用:列出两次的判定和判定语,不拆变化来源)。"""
before, after = _analysis_items(self.base), _analysis_items(self.target)
out: list[dict[str, Any]] = []
for node_id in [*after, *(n for n in before if n not in after)]:
b, t = before.get(node_id, ({}, 0)), after.get(node_id, ({}, 0))
for side, (_, runs) in (("基准运行", b), ("这次运行", t)):
if runs > 1:
self.notes.append(f"分析卡「{self.title(node_id)}」在{side}中执行了 {runs} 次,按最后一次对比")
for item in [*t[0], *(i for i in b[0] if i not in t[0])]:
bi, ti = b[0].get(item), t[0].get(item)
meta = ti or bi or {}
out.append({"node_id": node_id, "title": self.title(node_id), "item": item, "op": meta.get("op"),
"mode": meta.get("mode"), "base": _analysis_side(bi), "target": _analysis_side(ti),
"change": _analysis_change(bi, ti)})
return out
# ---------------------------------------------------------------- 指标
def _metrics(self) -> list[dict[str, Any]]:
+2 -1
View File
@@ -13,7 +13,7 @@ from app.core.events import EventType
from app.engine.context import NodeContext, NodeError, NodeRejected, RunContext
from app.engine.expressions import open_template_scope, template_scope
from app.engine.nodes import (
challenge, control, dual_check, human, io, knowledge, llm, merge, metrics, multi, report, tools,
analysis, challenge, control, dual_check, human, io, knowledge, llm, merge, metrics, multi, report, tools,
)
from app.engine.schema import (
GATE_TYPES, REJECT_HANDLE, GraphNode, GraphSpec, NodeType, approve_mode, back_edges, innermost_loops,
@@ -42,6 +42,7 @@ RUNNERS: dict[NodeType, NodeRunner] = {
NodeType.VALIDATE: human.run_validate,
NodeType.MEMORY: knowledge.run_memory,
NodeType.METRICS: metrics.run_metrics,
NodeType.ANALYSIS: analysis.run_analysis,
NodeType.RETRIEVE: knowledge.run_retrieve,
NodeType.REPORT: report.run_report,
NodeType.DUAL_CHECK: dual_check.run_dual_check,
+39 -3
View File
@@ -214,8 +214,9 @@ def _lint_sql(spec: GraphSpec, flag: Callable[..., None], checkers: Mapping[str,
sql_level=check.level)
#: validate 里和门禁 G4、G5 说同一件事的两条提示(schema.evidence_issues)
_REPEATS = frozenset({"evidence.caliber_compute_input", "evidence.cite_fields_off"})
#: validate 里和门禁 G4、G5 说同一件事的两条提示(schema.evidence_issues),和受管级分析卡输入那条(governed.analysis_input)
#: 说同一件事的画布提醒(schema.analysis_issues 对调用别的工具的「调用工具」节点的那条)
_REPEATS = frozenset({"evidence.caliber_compute_input", "evidence.cite_fields_off", "analysis.input_invalid"})
def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[Any]:
@@ -231,6 +232,7 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A
cited = {i.node_id for i in gate if i.code == "governed.caliber_agent_cite_fields"}
compute = {i.node_id for i in gate if i.code == "governed.caliber_compute_input"}
covered = {("evidence.cite_fields_off", nid, "output_schema") for nid in cited}
covered |= {("analysis.input_invalid", i.node_id, i.field) for i in gate if i.code == "governed.analysis_input"}
for card in spec.nodes:
if card.type == NodeType.METRICS and compute:
covered |= {("evidence.caliber_compute_input", card.id, f"metrics[{i}].expression")
@@ -344,7 +346,8 @@ REPORT_POLICY_ALSO: dict[str, tuple[str, ...]] = {"claims": (CLAIMS_JUDGE,)}
JUDGE_UNLIMITED = "不设上限,费用只受句数、时长上限和每日上限约束"
#: 产出本身就是证据、或者由系统确定地算出来的节点。成果字段取它们的值,不算「别的节点写的字」
_TRACEABLE = (NodeType.REPORT, NodeType.METRICS, NodeType.INPUT, NodeType.TOOL, NodeType.RETRIEVE, NodeType.MERGE)
_TRACEABLE = (NodeType.REPORT, NodeType.METRICS, NodeType.INPUT, NodeType.TOOL, NodeType.RETRIEVE, NodeType.MERGE,
NodeType.ANALYSIS)
_WRITERS = (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR)
#: 往对话里写消息的节点(emit_message 没关时),「最后一条消息」就是它们里最后跑的那个写的
_EMITTERS = (*_WRITERS, NodeType.REPORT)
@@ -710,6 +713,39 @@ def _lint_evidence(spec: GraphSpec, flag: Callable[..., None], *, strict: bool)
flag(f"{_who(feeder)}为{to}提供数据:「模型调用」节点不调用工具,写出的数字没有出处。请改由 Agent"
"(配置「结构化输出 Schema」并开启「按出处核对字段」)或「调用工具」节点取数",
code="governed.caliber_model_input", node_id=feeder.id, hard=True)
elif feeder.type == NodeType.ANALYSIS:
# D16:分析是叙述的依据,不是口径的原料。口径卡读它的结果按「追不到原始数据」处理,这里只提示
flag(f"{_who(feeder)}的结果提供给了{to}:分析结论不作口径卡的输入,口径卡读到的数追不到原始数据。"
"指标请在口径卡里按格从查询结果取数,分析结论在报告里直接引用",
code="governed.caliber_analysis_input", node_id=feeder.id)
# 受管级的分析卡:输入要在发布时就确定是平台执行的查询(调用数据库查询工具的「调用工具」节点、合并查询)
if strict:
_lint_analysis_inputs(spec, flag)
def _lint_analysis_inputs(spec: GraphSpec, flag: Callable[..., None]) -> None:
"""受管级别的分析卡,每项的输入(主输入、两份查询、总数两格落在的节点)都要是调用数据库查询工具的「调用工具」节点
或「合并查询」。画布校验(schema.analysis_issues)对调用别的工具的「调用工具」节点只提醒(声明了表格的端点读取运行时
也交回查询快照,静态看不出),已发布级别照那条提醒;受管出具要发布时就能确定分析的是平台执行的查询,按错误挡,
画布那条同义的提醒不再列(_not_repeated)。"""
from app.engine.schema import _produces_query, analysis_refs
nodes = spec.node_map()
for card in spec.nodes:
if card.type != NodeType.ANALYSIS or not isinstance(card.config.get("items"), list):
continue
for i, item in enumerate(card.config["items"]):
if not isinstance(item, dict):
continue
for field, target_id in analysis_refs(item):
target = nodes.get(target_id)
if target is None or target.type != NodeType.TOOL or _produces_query(target):
continue # 不存在、不是查询节点的,画布校验已经按错误报了
flag(f"{_who(card)}的分析项「{item.get('id')}」分析的是{_who(target)}的结果,它调用的不是数据库查询工具:"
"受管级别要求分析卡的输入是调用数据库查询工具的「调用工具」节点或「合并查询」,发布时就能确定分析的"
"是平台执行的查询。请改选这样的节点",
code="governed.analysis_input", node_id=card.id, hard=True, field=f"items[{i}].{field}")
async def unresolved_caliber_upgrades(
+10 -1
View File
@@ -21,7 +21,7 @@ TYPE_LABEL: dict[str, str] = {
"supervisor": "多 Agent 协作", "tool": "调用工具", "code": "沙箱代码", "merge": "合并查询",
"branch": "条件分支", "loop": "循环", "subgraph": "子工作流",
"memory": "长期记忆", "retrieve": "知识检索", "transform": "数据整形",
"human": "人工审批", "validate": "结构校验", "metrics": "口径卡", "report": "报告撰写",
"human": "人工审批", "validate": "结构校验", "metrics": "口径卡", "analysis": "分析卡", "report": "报告撰写",
"dual_check": "双盲复算", "challenge": "反方审稿",
}
@@ -99,10 +99,19 @@ _FIELD_BY_TYPE: dict[str, dict[str, str]] = {
"stop_on_reject": "驳回即终止运行"},
"validate": {"source": "待校验内容", "fail_fast": "校验失败即中断"},
"merge": {"inputs": "输入", "sql": "合并 SQL"},
"analysis": {"items": "分析项", "overrides": "阈值覆盖"},
"dual_check": DUAL_CHECK_FIELD_LABEL,
"challenge": CHALLENGE_LABEL,
}
#: 分析卡一项里的分析方法、模式的叫法(app/analysis/contract.py 的 OPS、MODES;前端分析卡的表单照这一份)
ANALYSIS_OP_LABEL: dict[str, str] = {"decompose": "变化拆解", "baseline": "波动基线", "trend": "趋势",
"differ": "差异检验", "comparable": "可比性检查"}
ANALYSIS_MODE_LABEL: dict[str, dict[str, str]] = {
"decompose": {"additive": "可加(按维度算贡献)", "ratio": "比率(拆结构与自身)", "factor": "乘积(拆因子)"},
"differ": {"rate": "比率", "mean": "均值", "rows": "逐行的值"},
}
#: 出具契约里的键(canvas/issues.ts 的 CONTRACT_KEYS、SUB_KEYS)
CONTRACT_KEY_LABEL: dict[str, str] = {
"metrics_from": "指标来自", "report_from": "报告来自", "required": "必需指标", "expected": "期望指标",
+41 -3
View File
@@ -87,7 +87,8 @@ async def launch_run(
参数和 POST /api/runs 的请求体一一对应(input 叫 input_payload);actor 是记在运行上的发起人。workflow_name 是没挂在
工作流上的探索运行记在运行上的名字(蓝图的试运行写蓝图名),不给就是「未保存的工作流」;挂在工作流上的用工作流的名字。
schedule_id 是发起它的定时任务,options 是整次运行沿用的选项快照,都原样交给 run_manager.start;图里有双盲复算、
反方审稿节点时 options 另加核查设置的快照(_with_verify_snapshot)。
反方审稿节点时 options 另加核查设置的快照(_with_verify_snapshot),有分析卡、或 Agent / 协作成员绑了分析工具时另加
分析设置的快照(_with_analysis_snapshot)。
"""
# 没挂在工作流上(画布没保存就发起、问数据当场搭的)。老数据里是「临时图」,前端两种都认
name = workflow_name or "未保存的工作流"
@@ -182,6 +183,7 @@ async def launch_run(
raise LaunchRefused(400, "没有要运行的内容:请求中既没有工作流 ID,也没有工作流内容")
await _refuse_missing_tools(session, graph)
options = await _with_verify_snapshot(session, graph, options)
options = await _with_analysis_snapshot(session, graph, options)
options, capability_changes = await _with_capability_snapshot(
session, graph, options, published=published_capabilities)
@@ -311,7 +313,43 @@ async def _with_verify_snapshot(session: AsyncSession, graph: dict[str, Any],
async def _has_verify_nodes(session: AsyncSession, spec: GraphSpec) -> bool:
"""图里有没有双盲复算、反方审稿节点,子工作流里的也算。
"""图里有没有双盲复算、反方审稿节点,子工作流里的也算。"""
return await _any_graph(session, spec, lambda graph: any(node.type in _VERIFY_NODES for node in graph.nodes))
async def _with_analysis_snapshot(session: AsyncSession, graph: dict[str, Any],
options: dict[str, Any] | None) -> dict[str, Any] | None:
"""图里(含子工作流)有分析卡、或 Agent / 协作成员绑了分析工具时,把分析设置(api/settings.py 的 analysis 一组:
判定用的阈值、次数上限)快照进 options["analysis"](docs/design/analysis-round/00-总方案.md §3.10)。
道理同 _with_verify_snapshot:分析在运行中途读阈值,审批停下期间有人改了设置,恢复后的那一段就按另一套阈值判,正式
运行也说不清当时按什么规则判的「主要来自」。分析工具、分析卡只读这一份(app/tools/analysis.py 的 run_snapshot),
没有快照的现读设置。别的图 options 原样不动,run.started 和以前逐字相同;调用方已经给了 options["analysis"] 的照它的。
"""
from app.api.settings import ANALYSIS_GROUP, analysis_settings
if isinstance(options, dict) and ANALYSIS_GROUP in options:
return options
try:
spec = GraphSpec.model_validate(graph)
except Exception: # noqa: BLE001 - 图本身不成形交给 run_manager.start 报
return options
if not await _any_graph(session, spec, _analyzes):
return options
return {**(options or {}), ANALYSIS_GROUP: await analysis_settings(session)}
def _analyzes(graph: GraphSpec) -> bool:
"""这一张图(不含子工作流)会用到分析设置:有分析卡,或者有绑了分析工具的 Agent、协作成员(tool_bindings 的取法,
节点上没写就取图级 defaults)。"""
from app.tools.analysis import is_analysis_tool
return any(node.type == NodeType.ANALYSIS for node in graph.nodes) \
or any(is_analysis_tool(name) for _, name in tool_bindings(graph))
async def _any_graph(session: AsyncSession, spec: GraphSpec, test: Any) -> bool:
"""这张图或它(一层层)套着的子工作流里,有没有一张让 test(图) 为真。
子工作流照运行时的取法读(同 runner._bound_sources):固定了版本的读那一版,没固定的读工作流现在的图。读不到、
图不成形的跳过——那个节点运行时自己会报错。同一个工作流(同一版)只读一次,互相引用也不会转圈。
@@ -320,7 +358,7 @@ async def _has_verify_nodes(session: AsyncSession, spec: GraphSpec) -> bool:
queue: list[GraphSpec] = [spec]
while queue:
graph = queue.pop(0)
if any(node.type in _VERIFY_NODES for node in graph.nodes):
if test(graph):
return True
for node in graph.nodes:
if node.type != NodeType.SUBGRAPH:
+165
View File
@@ -0,0 +1,165 @@
"""分析卡(节点类型 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 提醒,接着做下一项。调用写错(列不存在、比率当可加……)是模板的毛病:整张卡失败,说清是哪一项、怎么改。
分析的结果不作口径卡的输入(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)
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"]
+57 -6
View File
@@ -10,6 +10,7 @@ from langchain_core.tools import BaseTool
from langgraph.func import task
from sqlalchemy import select
from app.analysis.contract import CallError, ledger_entry
from app.core import artifact_store
from app.core.config import settings
from app.core.errors import describe_exception, not_configured, raw_detail
@@ -49,6 +50,17 @@ from app.providers.factory import (
)
from app.engine.sql_problems import event_checks
from app.data import catalog_view
from app.tools.analysis import (
REFUSED_CODE as ANALYSIS_REFUSED,
SCOPE_KEY as ANALYSIS_SCOPE_KEY,
AnalysisScope,
call_error_text,
input_snapshot,
is_analysis_tool,
payload_of,
reply_text,
run_snapshot,
)
from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions
from app.tools.registry import (
ToolArgsError,
@@ -57,6 +69,7 @@ from app.tools.registry import (
args_model_of,
build_tools,
describe_args,
guides_text,
prepare_args,
)
from app.tools.trust import asks_trustable, ask_gate, call_policy, node_task, trust_key_of
@@ -110,7 +123,10 @@ async def _load_skills(names: list[str]) -> str:
return "# 适用的方法论\n\n" + "\n\n".join(blocks)
async def _build_messages(state: GraphState, ctx: NodeContext) -> list[BaseMessage]:
async def _build_messages(state: GraphState, ctx: NodeContext, tools: list[BaseTool] | None = None
) -> list[BaseMessage]:
"""发给模型的消息。tools 是这个节点绑定的工具:它们带的方法说明(ToolSpec.guide,如分析方法)接在 Skill 后面,同一份
只注入一次。模型调用节点没有工具,不注入;没有带说明的工具时 system 和以前一字不差。"""
tctx = template_context(state)
messages: list[BaseMessage] = []
@@ -121,6 +137,8 @@ async def _build_messages(state: GraphState, ctx: NodeContext) -> list[BaseMessa
skill_text = await _load_skills(ctx.cfg("skills", []) or [])
if skill_text:
system_parts.append(skill_text)
if guide := guides_text(tools):
system_parts.append(guide)
if system_parts:
messages.append(SystemMessage(content="\n\n".join(system_parts)))
@@ -328,7 +346,9 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
# --------------------------------------------------------------------------
async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]:
async def _resolve_tools(ctx: NodeContext, state: GraphState, scope: AnalysisScope | None = None) -> list[BaseTool]:
"""建这个节点绑的工具。scope 是本节点的分析容器(绑了分析工具才有):经 ToolContext.extra 交给分析工具,工具建在
查询之前,查一次往里放一次(_record_call)。"""
names = ctx.cfg("tools", []) or []
# 遮罩、只读的运行:记忆范围、知识库只认运行定下的,模型在参数里挑的也不算(tools/registry.ToolContext.fixed_scope)
fixed = run_scope_fixed(ctx.run)
@@ -346,6 +366,7 @@ async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]:
catalog_view=catalog_view.of_run(ctx.run),
# 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
emit=ctx.emit,
extra={ANALYSIS_SCOPE_KEY: scope} if scope is not None else {},
)
async with SessionLocal() as session:
try:
@@ -425,14 +446,17 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
except ProviderNotConfigured as e:
raise NodeError(ctx.node.id, not_configured(e)) from e
tools = await _resolve_tools(ctx, state)
# 绑了分析工具才建分析容器:本节点查过的结果、做过的分析都放在这里(app/tools/analysis.py),编号和交回模型的一致
scope = (AnalysisScope(node_id=ctx.node.id, snapshot=run_snapshot(ctx.run))
if any(is_analysis_tool(n) for n in ctx.cfg("tools", []) or []) else None)
tools = await _resolve_tools(ctx, state, scope)
tool_map = {t.name: t for t in tools}
# 默认串行:一轮只调一个工具,拿到结果再想下一步。并行更快,但一批工具
# 中间没有任何新的思考——第二个调用是照着"还没看到第一个结果"时的判断发出来的
parallel_tools = bool(ctx.cfg("parallel_tools", False))
model = bind_tools_safely(base_model, tools, parallel=parallel_tools)
messages = await _build_messages(state, ctx)
messages = await _build_messages(state, ctx, tools)
# 护栏(engine/guards.py)。None 是升级前发起的运行:默认 12 步、没有别的护栏,和以前一样
guard = _guard(ctx, model_id)
# 证据台账、工具结果前的编号、cite_fields:升级前发起的运行一样都不加(见 evidence.ledger_enabled)
@@ -493,6 +517,10 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
# 到点就收回控制权,驱动的收尾在后台做完。模型拿到的是"等了多久、为什么放弃"
content, ok = str(e), False
extra = {"error": str(e), "timed_out": True}
except CallError as e:
# 分析调用写错了(列不存在、列被遮罩、分析次数用完……,细案 01 §8.1):不记结论、不进台账,原话交回模型照着改
content, ok = call_error_text(e), False
extra = {"error": content[:300], "call_error": e.code}
except Exception as e: # noqa: BLE001 - 工具失败要喂回模型,让它自己纠错
content = f"工具执行失败:{describe_exception(e)}"
schema = args_model_of(tool)
@@ -522,6 +550,8 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
query = query_entry_fields(content) if evidence_on and ok and is_platform_query(name) else None
if query and not await snapshot_owned(query["artifact"], ctx.run.run_id, name):
query = None
# 分析工具交回的结论工件:同查询快照,记进 tool.end(封存范围内),证据接口、时间线从这里认这一步的结论
analysis = payload_of(content) if ok and is_analysis_tool(name) else None
ctx.emit(
EventType.TOOL_END if ok else EventType.TOOL_ERROR,
tool=name,
@@ -530,12 +560,18 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
preview=content[:2000],
artifact=snapshot_id,
**({"query_artifact": query["artifact"]} if query else {}),
**({"analysis_artifact": analysis["artifact"]} if analysis else {}),
# 查询当时数据源的表结构快照:和查询快照一样落在封存范围内,证据接口从这里认它
**({"schema_artifact": query["schema_artifact"]} if query and query.get("schema_artifact") else {}),
# 这次查询的 SQL 检查结果单独带上:preview 截到前 2000 个字符,checks 通常在截掉的那段里
**({"checks": checks} if ok and name.startswith(QUERY_PREFIX) and (checks := event_checks(content)) else {}),
**extra,
)
if analysis is not None and analysis["result"].get("status") == "refused":
# 算不了是一条结论(照常交回模型、进台账),照实提醒一句。发在 task 里:重放取 checkpoint,不会再发一遍
ctx.emit(EventType.LOG, level="warn", code=ANALYSIS_REFUSED, call_id=call_id, tool=name,
reason_code=analysis["result"].get("reason_code"), reason=analysis["result"].get("reason"),
message=f"{name} 没有做成:{analysis['result'].get('reason') or '原因未知'}")
outcome: dict[str, Any] = {"content": content, "ok": ok, "duration_ms": elapsed}
if evidence_on:
outcome.update(snapshot=snapshot_id, query_artifact=query["artifact"] if query else None)
@@ -731,7 +767,7 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
if evidence_on:
# 编号只取决于 tool_step 的返回值(重放时取自 checkpoint),重放出来的还是这一套
content = _record_call(ctx.node.id, exec_no, call_id, name, args, outcome, record,
queries, entries)
queries, entries, scope)
transcript.append(record)
messages.append(ToolMessage(content=content, tool_call_id=call_id))
if guard:
@@ -870,12 +906,16 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
def _record_call(node_id: str, exec_no: int, call_id: str, name: str, args: dict[str, Any],
outcome: dict[str, Any], record: dict[str, Any], queries: list[dict[str, Any]],
entries: list[dict[str, Any]]) -> str:
entries: list[dict[str, Any]], scope: AnalysisScope | None = None) -> str:
"""把一次执行过的工具调用记进台账、调用记录和本节点的查询清单,返回给模型看的内容。
查库成功的结果前面加一行「【证据 Q1】」:模型在循环里就知道每次查询的编号,cite_fields
抽取时照这个编号写出处。编号只在本节点内有效;报告目录里的 Q1… 按全局台账另编,两边
靠工件 id 对应,不靠编号。
绑了分析工具的节点(scope):每次查询另往分析容器里放一份输入快照(编号同上);分析工具交回的结论记一条 kind analysis
的台账(contract.ledger_entry,带 call_id),编号 An,交回模型的换成「【分析 An】」那一段(app/tools/analysis.py)。
编号同样只取决于 tool_step 的返回值,重放出来的还是这一套。
"""
content = outcome["content"]
record["call_id"] = call_id
@@ -884,6 +924,15 @@ def _record_call(node_id: str, exec_no: int, call_id: str, name: str, args: dict
record["via"] = snapshot
entries.append({"kind": "tool", "node_id": node_id, "exec": exec_no, "artifact": snapshot,
"call_id": call_id, "tool": name})
if scope is not None and outcome.get("ok") and is_analysis_tool(name):
found = payload_of(content)
if found is None:
return content
alias = scope.add_result(found["result"], found["artifact"])
record.update(alias=alias, analysis_artifact=found["artifact"])
entries.append(ledger_entry(found["result"], node_id=node_id, exec_no=exec_no, artifact=found["artifact"],
call_id=call_id))
return reply_text(alias, found["result"])
if not artifact or not is_platform_query(name):
return content
try:
@@ -909,6 +958,8 @@ def _record_call(node_id: str, exec_no: int, call_id: str, name: str, args: dict
queries.append({"alias": alias, "call_id": call_id, "tool": name, "sql": str(args.get("sql") or ""),
"artifact": artifact, "via": snapshot, "columns": fields["columns"],
"rows": data.get("rows") or [], "truncated": fields["truncated"]})
if scope is not None:
scope.add_query(alias, input_snapshot(data, ref=alias, artifact=artifact, node_id=node_id))
return f"【证据 {alias}】\n{content}"
+164 -1
View File
@@ -55,6 +55,9 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
取数的那条 SQL 对照数据目录查出 error 级问题的(查询快照的 checks,见 data/sqlcheck.py:一对多关联后重复计算、
存量跨期加总),同样值照算,标 sql_check_failed 并写明原因,出具契约据此降档(issuance.sql_check_gaps)。
一个指标的输入落在两份不同的查询快照上、两份 SQL 同形只差字面量(本周对上周)时,自动做一次可比性检查(分析算子
comparable),结论落成工件、记在指标的 comparability 上(_comparability)。做不成不挡口径卡,照实记一条日志。
"""
definitions = ctx.cfg("metrics", []) or []
caliber_raw, caliber_version = ctx.cfg("caliber", "") or ctx.node.title, str(ctx.cfg("caliber_version", "") or "v1")
@@ -84,6 +87,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
failed_by: dict[str, list[QueryProblem]] = {}
#: 查询快照工件 id → 快照本身(读输入的格落在哪一列「查询区间里这张表没有数据」,_no_data_inputs)
snapshots: dict[str, Any] = {}
#: 可比性检查:同一对查询快照只查一次(几个指标都用本周、上周两份快照时),和做检查要用的几样(_Comparing)
comparing = _Comparing()
for definition in definitions:
metric_id = str(definition.get("id") or "").strip()
expr = str(definition.get("expression") or "").strip()
@@ -167,6 +172,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
no_data: dict[str, Any] = {}
if lacking := _no_data_inputs(inputs, carried, snapshots):
no_data = {"no_data": True, "no_data_reason": _no_data_reason(lacking), "no_data_sources": lacking}
comparability = await _comparability(metric_id, str(definition.get("name") or metric_id), inputs, ctx,
comparing, snapshots)
metrics.append(
{
"id": metric_id,
@@ -192,6 +199,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
**sql_check,
# 只在输入取自「查询区间里这张表没有数据」的列时出现,同上
**no_data,
# 只在输入落在两份同形、只差字面量的查询快照上、可比性检查做成了时出现(contract.MetricComparability)
**({"comparability": comparability} if comparability else {}),
}
)
@@ -462,6 +471,160 @@ def _rendered(value: Any, definition: dict[str, Any], display: dict[str, Any]) -
return MISSING
# --------------------------------------------------------------------------
# 可比性检查(docs/design/analysis-round/00-总方案.md §3.4 第 3 条)
# --------------------------------------------------------------------------
#: 可比性检查没做成(探查失败、算子出错、还没有算子……)时那条日志的 code
COMPARABILITY_SKIPPED = "metric_comparability_skipped"
#: 那条日志的说法
COMPARABILITY_SKIPPED_TEXT = "指标「{name}」用到的两次查询没做成可比性检查:{why}。指标照常计算,两期能不能直接比请自行核对"
_DAY = re.compile(r"^(\d{4})-?(\d{2})-?(\d{2})(?:[ T].*)?$")
class _Comparing:
"""一张口径卡执行时做可比性检查记下的:同一对快照的结论(没做成的记 None,不再试)、数据源的类型(规范化 SQL 按
它的方言解析)。阈值、运行期依赖到真要检查时才读。"""
def __init__(self) -> None:
self.done: dict[tuple[str, str], dict[str, Any] | None] = {}
self.kinds: dict[str, str | None] = {}
async def _comparability(metric_id: str, name: str, inputs: list[dict[str, Any]], ctx: NodeContext,
comparing: _Comparing, snapshots: dict[str, Any]) -> dict[str, Any] | None:
"""指标的可比性(contract.MetricComparability:结论工件、判定、判定语、原因)。只在这种时候做:输入里取自查询快照的格
(cell() 取的格、按位置取的格、Agent 按出处交来的字段)恰好落在两份不同的快照上,两份 SQL 同形、只差字面量,而且查的是
同一个数据源——本周对上周。SQL 结构不同的(退款额 / 销售额)不是同一个量的两期,不查。
哪份是基期:两边第一处不同的字面量是日期(或同样写法的期次)的,早的是基期;认不出的,表达式里先出现的那份算本期
(「(本周 - 上周) / 上周」的写法)。
做不成(快照读不出来、探查失败、算子出错、算不了、还没有算子)不挡口径卡:照实记一条日志(COMPARABILITY_SKIPPED),
这个指标不写 comparability。同一对快照只查一次。"""
from app.engine.lineage import SNAPSHOT_VIAS
artifacts = list(dict.fromkeys(str(i["artifact"]) for i in inputs if i.get("via") in SNAPSHOT_VIAS
and isinstance(i.get("artifact"), str) and i["artifact"]))
if len(artifacts) != 2:
return None
pair = (artifacts[0], artifacts[1])
key = (min(pair), max(pair)) # 本周减上周、上周减本周用的是同一对快照
if key in comparing.done:
return comparing.done[key]
found: dict[str, Any] | None = None
try:
found = await _check_comparable(pair, inputs, ctx, comparing, snapshots)
except _NotPeriods:
found = None
except Exception as e: # noqa: BLE001 - 检查是附加的,不能挡住口径卡
why = e.message if isinstance(e, _Skipped) else describe_exception(e)
ctx.emit(EventType.LOG, level="warn", code=COMPARABILITY_SKIPPED, metric=metric_id, metric_name=name,
reason=why, message=COMPARABILITY_SKIPPED_TEXT.format(name=name, why=why))
comparing.done[key] = found
return found
class _NotPeriods(Exception):
"""两份快照不是同一个量的两期(SQL 结构不同、数据源不同、字面量一样、没有 SQL):不查,也不记日志。"""
class _Skipped(Exception):
"""该查、却做不成(结论算不了、判定认不出):记一条日志,message 是给人看的原因。"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
async def _check_comparable(pair: tuple[str, str], inputs: list[dict[str, Any]], ctx: NodeContext,
comparing: _Comparing, snapshots: dict[str, Any]) -> dict[str, Any]:
import app.analysis as analysis_ops # 按模块取 run:测试换替身时只换模块上的这一个函数
from app.analysis.contract import VERDICTS
from app.data.sql_semantics import normalize_sql
from app.tools.analysis import (
context_of, input_snapshot, masked_of, run_snapshot, settings_of, store, tool_context,
)
contents = []
for artifact in pair:
if artifact not in snapshots:
try:
snapshots[artifact] = artifact_store.load(artifact)
except (ValueError, OSError):
snapshots[artifact] = None
if not isinstance(snapshots[artifact], dict):
raise _Skipped("查询快照读不出来")
contents.append(snapshots[artifact])
sqls = [c.get("sql") for c in contents]
sources = [str(c.get("source") or "") for c in contents]
if not all(isinstance(q, str) and q.strip() for q in sqls) or sources[0] != sources[1]:
raise _NotPeriods()
dialect = await _dialect_of(sources[0], comparing)
norms = [normalize_sql(q, dialect) for q in sqls]
if not norms[0].same_shape(norms[1]) or norms[0].literals == norms[1].literals:
raise _NotPeriods()
first, second = _period_order(norms[0].literals, norms[1].literals)
order = (pair[first], pair[second]) # (基期, 本期)
refs: dict[str, str] = {}
for role, artifact in zip(("base", "target"), order):
producer = next((str(i.get("node_id") or "") for i in inputs if i.get("artifact") == artifact), "")
refs[artifact] = producer if producer and producer not in refs.values() else f"{producer or 'query'}·{role}"
snaps = {}
for artifact in order:
snaps[refs[artifact]] = input_snapshot(snapshots[artifact], ref=refs[artifact], artifact=artifact,
node_id=refs[artifact].split("·")[0],
masked=await masked_of(snapshots[artifact], artifact))
settings = await settings_of(run_snapshot(ctx.run))
context = await context_of(list(snaps.values()), tool_ctx=tool_context(ctx.run, ctx.node.id, ctx.emit))
request = {"op": "comparable", "mode": None,
"params": {"base_query": refs[order[0]], "target_query": refs[order[1]]}}
result = await analysis_ops.run(request, snaps.get, settings=settings, context=context)
verdict = (result.get("verdict") or {}).get("code")
if result.get("status") != "ok" or verdict not in VERDICTS["comparable"]:
raise _Skipped(str(result.get("reason") or "检查没有给出判定"))
artifact = await store(result, run_id=ctx.run.run_id, node_id=ctx.node.id)
# 原因的拆法只有算子那一处(analysis.comparable_reasons,按检查表的说明拆)
return {"artifact": artifact, "verdict": result["verdict"]["code"], "phrase": result["phrase"],
"reasons": analysis_ops.comparable_reasons(result)}
async def _dialect_of(source: str, comparing: _Comparing) -> str | None:
"""数据源的类型(规范化 SQL 按它的方言解析);合并查询在 SQLite 里执行。认不出为 None(通用读法)。"""
from app.engine.merge_query import MERGE_SOURCE
if source == MERGE_SOURCE:
return "sqlite"
if source not in comparing.kinds:
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import DataSource
async with SessionLocal() as session:
comparing.kinds[source] = (await session.execute(
select(DataSource.kind).where(DataSource.name == source))).scalar_one_or_none()
return comparing.kinds[source]
def _period_order(a: tuple[str, ...], b: tuple[str, ...]) -> tuple[int, int]:
"""两份同形 SQL 的字面量 → (基期的下标, 本期的下标)。第一处不同、两边都认得出的日期(YYYY-MM-DD、YYYYMMDD,带不带
时间)或同样长短的期次写法(2026-W41、202610)定先后,早的是基期;都认不出的,表达式里先出现的那份(下标 0)是本期。"""
for x, y in zip(a, b):
if x == y:
continue
left, right = x.strip("'\""), y.strip("'\"")
dx, dy = _DAY.match(left), _DAY.match(right)
if dx and dy:
if dx.groups() != dy.groups():
return (0, 1) if dx.groups() < dy.groups() else (1, 0)
continue
if len(left) == len(right) and re.fullmatch(r"[\dW\-/Qq]+", left) and re.fullmatch(r"[\dW\-/Qq]+", right):
return (0, 1) if left < right else (1, 0)
return 1, 0
#: 清单里输入取自「查询区间里这张表没有数据」的列的指标后面那句:直接读清单的模型节点不能把 0 当成真的是 0
LISTED_NO_DATA = "(这段时间表里没有数据:不要当成 0 解读)"
#: 指标的 no_data_reason 打头那半句(后面接查询记下的说明)
@@ -694,7 +857,7 @@ _REF_HEAD = re.compile(r"^(vars|nodes|input)\.([A-Za-z_一-鿿][\w\-一-鿿]*)(.
_VIA = {"input": "input", "code": "code", "agent": "agent", "llm": "llm", "tool": "tool",
"transform": "transform", "metrics": "metric", "retrieve": "retrieval", "loop": "loop",
"human": "human", "validate": "validate", "supervisor": "team", "subgraph": "subgraph",
"memory": "memory", "merge": "merge"}
"memory": "memory", "merge": "merge", "analysis": "analysis"}
def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext,
+55 -2
View File
@@ -9,6 +9,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, Tool
from langgraph.errors import GraphRecursionError
from langgraph.func import task
from app.analysis.contract import CallError
from app.core.config import settings
from app.core.errors import describe_exception, raw_detail
from app.core.events import EventType
@@ -32,12 +33,24 @@ from app.providers import catalog
from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model
from app.engine.sql_problems import event_checks
from app.data import catalog_view
from app.tools.analysis import (
REFUSED_CODE as ANALYSIS_REFUSED,
SCOPE_KEY as ANALYSIS_SCOPE_KEY,
AnalysisScope,
call_error_text,
input_snapshot,
is_analysis_tool,
payload_of,
reply_text,
run_snapshot,
)
from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions
from app.tools.registry import (
ToolArgsError,
ToolContext,
args_model_of,
build_tools,
guides_text,
prepare_args,
)
from app.tools.trust import ask_gate, call_policy
@@ -302,6 +315,10 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
async def _member(name: str, instruction: str, progress: str,
tally: dict[str, Any]) -> dict[str, Any]:
cfg = agent_map[name]
# 绑了分析工具的成员:这一次执行自己的分析容器(同 Agent 节点)。查询结果照 Agent 的写法编成「【证据 Qn】」,分析结论
# 编成「【分析 An】」;协作节点不记证据台账,这些编号只给成员自己接着分析、下钻用
scope = (AnalysisScope(node_id=ctx.node.id, snapshot=run_snapshot(ctx.run))
if any(is_analysis_tool(t) for t in cfg.get("tools", []) or []) else None)
async with SessionLocal() as session:
model, model_id = await get_chat_model(
session,
@@ -325,13 +342,17 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
catalog_view=catalog_view.of_run(ctx.run),
# 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
emit=ctx.emit,
extra={ANALYSIS_SCOPE_KEY: scope} if scope is not None else {},
)
tools = await build_tools(cfg.get("tools", []) or [], tool_ctx, session=session)
bound = bind_tools_safely(model, tools, parallel=False)
tool_map = {t.name: t for t in tools}
# 绑定的工具带的方法说明(分析方法)接在成员的 system 后面,同一份只注入一次(同 Agent 节点的 _build_messages)
guide = guides_text(tools)
system = cfg.get("system") or f"你是 {name}。"
messages: list[Any] = [
SystemMessage(content=cfg.get("system") or f"你是 {name}。"),
SystemMessage(content=f"{system}\n\n{guide}" if guide else system),
HumanMessage(content=f"团队目标:{goal}\n\n已有进展:\n{progress or '(无)'}\n\n你的任务:{instruction}"),
]
calls: list[dict[str, Any]] = []
@@ -426,16 +447,29 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
except ToolTimeout as e:
content = str(e)
failure = {"error": content, "timed_out": True}
except CallError as e:
# 分析调用写错了:不记结论,原话交回成员照着改(同 Agent 节点)
content = call_error_text(e)
failure = {"error": content[:300], "call_error": e.code}
except Exception as e: # noqa: BLE001
content = f"工具失败:{describe_exception(e)}"
failure = {"error": content, "detail": raw_detail(e)}
# 查库的 SQL 检查结果单独带上:preview 截到前 1500 个字符,checks 通常在截掉的那段里
checks = event_checks(content) if not failure and tname.startswith(QUERY_PREFIX) else []
analysis = payload_of(content) if scope is not None and not failure and is_analysis_tool(tname) else None
ctx.emit(EventType.TOOL_ERROR if failure else EventType.TOOL_END, tool=tname,
agent=name, call_id=cid, preview=content[:1500],
duration_ms=int((time.perf_counter() - started) * 1000), **failure,
**({"checks": checks} if checks else {}))
**({"checks": checks} if checks else {}),
**({"analysis_artifact": analysis["artifact"]} if analysis else {}))
if analysis is not None and analysis["result"].get("status") == "refused":
ctx.emit(EventType.LOG, level="warn", code=ANALYSIS_REFUSED, agent=name, call_id=cid, tool=tname,
reason_code=analysis["result"].get("reason_code"),
reason=analysis["result"].get("reason"),
message=f"{name} 的 {tname} 没有做成:{analysis['result'].get('reason') or '原因未知'}")
calls.append({"tool": tname, "args": targs, "result": content[:2000]})
if scope is not None and not failure and tname in tool_map:
content = await _numbered(scope, tname, content, analysis, ctx.run.run_id, ctx.node.id)
messages.append(ToolMessage(content=content, tool_call_id=cid))
# 步数用完了:最后一步一定是要了工具的,模型从没拿到过"说结论"的那一轮。
@@ -611,6 +645,25 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
return updates
async def _numbered(scope: AnalysisScope, tool: str, content: str, analysis: dict[str, Any] | None, run_id: str,
node_id: str) -> str:
"""绑了分析工具的协作成员:一次成功的工具调用交回成员的内容。数据源查询(平台执行的,端点读取另核工件是这次运行落下
的)编号「【证据 Qn】」并放进分析容器;分析结论编号「【分析 An】」、换成给模型看的那一段。别的工具原样。"""
from app.capabilities.registry import is_platform_query, snapshot_owned
from app.engine.evidence import query_entry_fields
if analysis is not None:
return reply_text(scope.add_result(analysis["result"], analysis["artifact"]), analysis["result"])
if not is_platform_query(tool):
return content
fields = query_entry_fields(content)
if fields is None or not await snapshot_owned(fields["artifact"], run_id, tool):
return content
alias = f"Q{len(scope.queries) + 1}"
scope.add_query(alias, input_snapshot(json.loads(content), ref=alias, artifact=fields["artifact"], node_id=node_id))
return f"【证据 {alias}】\n{content}"
# --------------------------------------------------------------------------
# 子图:把另一张工作流当成一个节点
# --------------------------------------------------------------------------
+176
View File
@@ -43,6 +43,8 @@ class NodeType(StrEnum):
HUMAN = "human" # 人工介入
VALIDATE = "validate" # 结构化校验 + 自动重试
METRICS = "metrics" # 口径卡:受控指标集,叙述层唯一合法的数字来源
# 分析卡:对上游查询快照做固定的几项确定性分析(拆解、基线、趋势、差异、可比性),和口径卡平级(engine/nodes/analysis.py)
ANALYSIS = "analysis"
REPORT = "report" # 报告撰写:模型只写引用标记,数字由系统从口径卡取出来渲染
# 下面两种目前只是占位(执行器原样放行),各自的工作包补上真正的逻辑
DUAL_CHECK = "dual_check" # 双盲复算:另起一路独立算一遍,和原结果比对
@@ -561,6 +563,13 @@ def _check_dual_check(node: GraphNode, spec: GraphSpec, result: ValidationResult
"definitions", "dual_check.definitions_invalid")
def _analysis_tools() -> frozenset[str]:
"""五个分析工具的名字(contract.TOOL_NAMES)。"""
from app.analysis.contract import TOOL_NAMES
return frozenset(TOOL_NAMES.values())
def _produces_query(node: GraphNode) -> bool:
"""这个节点的产出是一份存成快照的查询结果:调用数据库查询工具的「调用工具」节点,或另一个合并查询。"""
if node.type == NodeType.MERGE:
@@ -625,6 +634,167 @@ def _check_merge(node: GraphNode, spec: GraphSpec, result: ValidationResult) ->
result.add(problem, node_id=node.id, field="sql", code="merge.sql_invalid")
#: 分析卡一项的标识:要能写进取值链(nodes.<卡>.results.<标识>),条件分支按它取判定
ANALYSIS_ITEM_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
#: 分析卡一项里写上游节点 id 的参数:基期、本期是两份查询时各选一个(主输入写在 from,对应 agent 工具的 query)
ANALYSIS_NODE_PARAMS = ("base_query", "target_query")
#: 一项里写成「节点 id.r行号.列名」的格(总数 headline 的两格)
ANALYSIS_CELL = re.compile(r"^([A-Za-z_][\w-]*)\.r(\d+)\.(.+)$")
def analysis_refs(item: dict[str, Any]) -> list[tuple[str, str]]:
"""分析卡一项引用的上游节点:[(这一项里的字段路径, 节点 id)],按 from、base_query、target_query、总数两格的先后。
写错了的(不是字符串、格的写法不对)不在里面,由 analysis_issues 报。"""
params = item.get("params") if isinstance(item.get("params"), dict) else {}
out: list[tuple[str, str]] = []
if isinstance(item.get("from"), str) and item["from"].strip():
out.append(("from", item["from"].strip()))
out += [(f"params.{k}", params[k].strip()) for k in ANALYSIS_NODE_PARAMS
if isinstance(params.get(k), str) and params[k].strip()]
headline = params.get("headline")
for side in ("base", "target"):
cell = ANALYSIS_CELL.match(headline[side].strip()) \
if isinstance(headline, dict) and isinstance(headline.get(side), str) else None
if cell is not None:
out.append((f"params.headline.{side}", cell.group(1)))
return out
def _analysis_input(spec: GraphSpec, target_id: str, upstream: set[str]) -> tuple[str, str] | None:
"""分析卡一项的一个输入有什么问题:(级别, 半句说明),没问题为 None。规则同合并查询:只认能稳定取到一份查询快照的
上游节点。调用的不是数据库查询工具的「调用工具」节点只提醒:登记里声明了表格的端点读取运行时也交回查询快照,静态看
不出来;交回的不是平台执行的查询,运行到这一项就失败(受管级别发布时按错误挡,engine/governance.py)。"""
target = spec.node_map().get(target_id)
if target is None:
return "error", f"选择的输入「{target_id}」在工作流中不存在"
if target.type == NodeType.AGENT:
return "error", (f"选择的输入「{target.title}」是 Agent:Agent 可能查询多次,无法确定分析哪一次的结果。"
"请把要分析的那条查询单独放进「调用工具」节点(选择数据库查询工具),再把它选作输入;"
"要 Agent 看了数再决定拆哪个维度的,给它绑分析工具")
if target.type not in (NodeType.TOOL, NodeType.MERGE):
return "error", (f"选择的输入「{target.title}」是「{type_label(target.type)}」节点,不产出查询结果。"
"输入只能是调用数据库查询工具的「调用工具」节点,或「合并查询」节点")
if target_id not in upstream:
return "error", f"选择的输入「{target.title}」不在分析卡的上游:分析时它还没有运行。请把它连到分析卡之前"
if not _produces_query(target):
return "warning", (f"选择的输入「{target.title}」调用的不是数据库查询工具:运行时它交回的要是平台执行的查询结果"
"才能分析,否则这一项会失败")
return None
def analysis_issues(spec: GraphSpec, node: GraphNode) -> list[ValidationIssue]:
"""分析卡的配置有什么问题(画图时的校验和执行时同一套,engine/nodes/analysis.py 执行前先过一遍,模板写错了早暴露)。
- items:至少一项;每项的标识合法、不重复;分析方法(op)、模式(mode)是契约里的;输入(from,或两份查询时的
base_query、target_query,总数两格落在的节点)指向上游的「调用工具」/「合并查询」节点
- params:只能写这种分析的参数(和 agent 工具的参数同一份定义,tools/builtin/analysis.py),不能写阈值——阈值统一
写在 overrides(阈值覆盖)里,按 contract.settings_problem 校验
- parent(下钻):指向这张卡里排在它前面的一项变化拆解
"""
from app.analysis import contract
from app.engine.labels import ANALYSIS_MODE_LABEL, ANALYSIS_OP_LABEL
from app.tools.builtin.analysis import param_names
out: list[ValidationIssue] = []
def add(message: str, field: str, code: str, level: str = "error") -> None:
out.append(ValidationIssue(level=level, message=message, node_id=node.id, field=field, # type: ignore[arg-type]
code=code))
cfg = node.config
items = cfg.get("items")
if not isinstance(items, list) or not items:
add("「分析卡」还没有配置分析项:至少写一项,说清做什么分析、分析哪个上游查询节点的结果", "items",
"analysis.items_missing")
items = []
upstream = _ancestors(spec, node.id)
seen: dict[str, int] = {}
for i, item in enumerate(items):
at = f"items[{i}]"
if not isinstance(item, dict):
add(f"第 {i + 1} 个分析项的格式有误,需要是一个对象", at, "analysis.item_invalid")
continue
item_id = item.get("id")
who = f"分析项「{item_id}」" if isinstance(item_id, str) and item_id else f"第 {i + 1} 个分析项"
if not isinstance(item_id, str) or not ANALYSIS_ITEM_ID.match(item_id):
add(f"{who}的标识只能用字母、数字和下划线,且不以数字开头:下游按 nodes.{node.id}.results.<标识> 取它的判定",
f"{at}.id", "analysis.item_invalid")
elif item_id in seen:
add(f"有两个分析项的标识都是「{item_id}」,标识要唯一", f"{at}.id", "analysis.item_invalid")
else:
seen[item_id] = i
op, mode = item.get("op"), item.get("mode")
if op not in contract.OPS:
add((f"{who}的分析方法「{op}」不认识" if op not in (None, "") else f"{who}还没有选择分析方法") + ",可选:"
+ "、".join(ANALYSIS_OP_LABEL.values()), f"{at}.op", "analysis.op_invalid")
continue
modes = contract.MODES.get(op)
if modes and mode not in modes:
add(f"{who}是{ANALYSIS_OP_LABEL[op]},要选一种模式:" + "、".join(ANALYSIS_MODE_LABEL[op].values())
+ (f"(当前为「{mode}」)" if mode not in (None, "") else ""), f"{at}.mode", "analysis.mode_invalid")
elif not modes and mode not in (None, ""):
add(f"{who}是{ANALYSIS_OP_LABEL[op]},没有模式可选,请去掉模式", f"{at}.mode", "analysis.mode_invalid")
params = item.get("params") if item.get("params") is not None else {}
if not isinstance(params, dict):
add(f"{who}的参数需要是一个对象", f"{at}.params", "analysis.params_invalid")
continue
allowed = [k for k in param_names(op) if k != "query"]
for key in params:
if key in contract.DEFAULTS:
add(f"{who}的参数里写了阈值「{contract.LABELS[key]}」:阈值不写在分析项里,统一写在这张卡的"
f"「{field_label('overrides', NodeType.ANALYSIS)}」里", f"{at}.params.{key}", "analysis.params_invalid")
elif key == "query":
add(f"{who}要分析的查询节点写在输入里,参数里不写", f"{at}.params.{key}", "analysis.params_invalid")
elif key not in allowed:
add(f"{who}的参数里没有「{key}」,{ANALYSIS_OP_LABEL[op]}可以写:" + "、".join(allowed),
f"{at}.params.{key}", "analysis.params_invalid")
if item.get("hypothesis") is not None and not isinstance(item.get("hypothesis"), str):
add(f"{who}的「在验证什么」需要是一段文字", f"{at}.hypothesis", "analysis.item_invalid")
both = all(isinstance(params.get(k), str) and params[k].strip() for k in ANALYSIS_NODE_PARAMS)
if not (isinstance(item.get("from"), str) and item["from"].strip()) and not both:
two = ";基期、本期是两份查询时,在参数里选上这两个查询节点" if op in ("decompose", "comparable") else ""
add(f"{who}还没有选择输入:选一个上游的查询节点(调用数据库查询工具的「调用工具」节点,或「合并查询」){two}",
f"{at}.from", "analysis.input_invalid")
headline = params.get("headline")
if headline is not None and not (isinstance(headline, dict)
and all(isinstance(headline.get(k), str) for k in ("base", "target"))):
add(f"{who}的总数要写成基期、本期两格,每格写成「节点 id.r行号.列名」,行号从 0 数",
f"{at}.params.headline", "analysis.params_invalid")
elif isinstance(headline, dict):
for side, said in (("base", "基期"), ("target", "本期")):
if not ANALYSIS_CELL.match(headline[side].strip()):
add(f"{who}的总数里{said}那一格要写成「节点 id.r行号.列名」(行号从 0 数),当前为「{headline[side]}」",
f"{at}.params.headline.{side}", "analysis.params_invalid")
for field, target_id in analysis_refs(item):
if (found := _analysis_input(spec, target_id, upstream)) is not None:
add(f"{who}{found[1]}", f"{at}.{field}", "analysis.input_invalid", found[0])
parent = params.get("parent") if "parent" in allowed else None # 别的分析写了下钻,上面已按多写的参数报
if parent is not None:
of = parent.get("of") if isinstance(parent, dict) else None
if not (isinstance(parent, dict) and isinstance(of, str) and of and isinstance(parent.get("key"), list)
and parent["key"]):
add(f"{who}的下钻要写清是哪一项里的哪一段:上一层分析项的标识和那一段的维度值",
f"{at}.params.parent", "analysis.parent_invalid")
elif seen.get(of, i) >= i:
add(f"{who}下钻的上一层「{of}」要是这张分析卡里排在它前面的一项", f"{at}.params.parent",
"analysis.parent_invalid")
elif (items[seen[of]] or {}).get("op") != "decompose":
add(f"{who}下钻的上一层「{of}」不是变化拆解", f"{at}.params.parent", "analysis.parent_invalid")
overrides = cfg.get("overrides")
if overrides not in (None, {}):
label = field_label("overrides", NodeType.ANALYSIS)
if not isinstance(overrides, dict):
add(f"「{label}」需要是一个对象", "overrides", "analysis.overrides_invalid")
elif problem := contract.settings_problem(overrides):
add(f"「{label}」有误:{problem}", "overrides", "analysis.overrides_invalid")
return out
def _check_analysis(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None:
for issue in analysis_issues(spec, node):
result.add(issue.message, level=issue.level, node_id=issue.node_id, field=issue.field, code=issue.code)
#: 报告撰写节点的结论句策略(claims)。off:结论句不参与判档(默认,保持以前的行为);
#: require_citation:没挂引用的结论句计入缺口、按出具档位降档。这两档是确定性的(发布前自动修复
#: 给人选的也只有这两档)
@@ -938,6 +1108,10 @@ def validate_graph(spec: GraphSpec) -> ValidationResult:
elif node.type == NodeType.TOOL:
if not cfg.get("tool"):
result.add("「调用工具」节点还没有选择工具", node_id=node.id, field="tool")
elif str(cfg.get("tool")).strip() in _analysis_tools():
# 分析工具读的是同一个 Agent 查过的结果(【证据 Qn】),调用工具节点里没有这份容器,运行到这里一定失败
result.add("「调用工具」节点不能调用分析工具:分析工具分析的是同一个 Agent 查过的结果。模板里要固定做的分析,"
"请改用「分析卡」节点", node_id=node.id, field="tool", code="analysis.tool_node")
# 写入动作的确认派给谁(应用平台 §3.8):写法同人工节点
from app.engine.lint_process import assignee_problem
@@ -978,6 +1152,8 @@ def validate_graph(spec: GraphSpec) -> ValidationResult:
_check_report_claims(node, spec, result)
elif node.type == NodeType.MERGE:
_check_merge(node, spec, result)
elif node.type == NodeType.ANALYSIS:
_check_analysis(node, spec, result)
elif node.type == NodeType.CHALLENGE:
_check_challenge(node, spec, result)
elif node.type == NodeType.OUTPUT:
+2 -2
View File
@@ -51,9 +51,9 @@ LEVELS = ("published", "governed")
_LLM_ONLY = ("prompt", "system", "skills", "use_history", "output_schema")
#: 新的报告撰写节点照搬过去的模型配置(R3 从 agent 带过去的只有 provider、model:温度、上限是查数用的)
_MODEL_KEYS = ("provider", "model")
#: 报告的指令里照搬了这几类节点的文字,就要提醒:报告撰写自己会收集它们的证据
#: 报告的指令里照搬了这几类节点的文字,就要提醒:报告撰写自己会收集它们的证据(分析卡的结论进台账,写作目录编成 A{n})
_EVIDENCE_TYPES = (NodeType.METRICS, NodeType.TOOL, NodeType.AGENT, NodeType.RETRIEVE, NodeType.CODE,
NodeType.SUBGRAPH, NodeType.MERGE)
NodeType.SUBGRAPH, NodeType.MERGE, NodeType.ANALYSIS)
#: 写整段文字的节点:出具契约的 narrative 拼了其中几个的文字,就说不准该换哪一个
_WRITER_TYPES = (NodeType.LLM, NodeType.AGENT)
_QUESTION = re.compile(r"(?<![\w.])input\s*\.\s*question(?![\w-])")
+22
View File
@@ -309,6 +309,17 @@ def analyze(spec: GraphSpec) -> VariableReport:
f"nodes.{node.id}", kind="node", label=f"「{label}」这一步的输出",
produced_by=node.id, produced_by_label=label, order=order,
)
# 分析卡每一项的结论(engine/nodes/analysis.py 的 card_view):条件分支按判定走、成果取判定语都从这里取
if node.type == "analysis" and isinstance(cfg.get("items"), list):
for item in cfg["items"]:
item_id = item.get("id") if isinstance(item, dict) else None
if isinstance(item_id, str) and item_id:
ensure(
f"nodes.{node.id}.results.{item_id}", kind="node",
label=f"「{label}」分析项「{item_id}」的结论",
produced_by=node.id, produced_by_label=label, order=order,
description="status 状态、verdict 判定、direction 方向、values 关键数、phrase 判定语",
)
# 每个根命名空间本身也是合法引用:{{ input }} 取整个输入 dict,
# {{ vars }} 取整个变量池——template_context 里它们就是顶层的键。
@@ -345,6 +356,17 @@ def analyze(spec: GraphSpec) -> VariableReport:
node_id=node.id, node_label=label, field=field, expr=m.group(0),
))
# 分析卡的输入同样按节点 id 选(主输入、两份查询、总数两格落在的节点):记成对上游节点的引用
if node.type == "analysis" and isinstance(cfg.get("items"), list):
from app.engine.schema import analysis_refs
for i, item in enumerate(cfg["items"]):
for field, source in analysis_refs(item) if isinstance(item, dict) else []:
target = index.get(f"nodes.{source}")
if target is not None:
target.refs.append(VarRef(node_id=node.id, node_label=label, field=f"items[{i}].{field}",
expr=f"nodes.{source}"))
# 合并查询的输入按节点 id 选,不写模板:不认这一条的话,变量表里看不出上游那条查询被谁用了。
# 选错的节点(不存在、不在上游、不是查询)由 validate_graph 报,这里只记引用
if node.type == "merge" and isinstance(cfg.get("inputs"), dict):
+7
View File
@@ -93,6 +93,13 @@ PLAIN: dict[str, Plain] = {
"给口径卡供数的 Agent 没核对字段出处", "口径卡读到的仍是模型给的数。", _A, "打开按出处核对字段。"),
"governed.caliber_model_input": Plain(
"口径卡的数来自「模型调用」节点", "这种节点不调工具,写出的数没有出处。", _A, "改由「调用工具」节点或配好结构化输出的 Agent 取数。"),
# 分析卡(engine/nodes/analysis.py,docs/design/analysis-round/)
"governed.caliber_analysis_input": Plain(
"口径卡的数来自分析卡", "分析结论是叙述的依据,不是口径的原料,口径卡读到的数追不到原始数据。", _A,
"口径卡里按格从查询结果取数,分析结论在报告里直接引用。"),
"governed.analysis_input": Plain(
"分析卡分析的不是数据库查询的结果", "受管级别要求发布时就能确定分析的是平台执行的查询,否则结论说不清出处。", _A,
"把分析卡这一项的输入改选调用数据库查询工具的「调用工具」节点或「合并查询」。"),
# ---------------------------------------------------------------- 写外部系统(engine/lint_capabilities.py)
"action.write_in_agent": Plain(
"Agent 的工具里有会写外部系统的能力", "写入只能放在「调用工具」节点里,先确认再发送、留生效记录。", _Y,
+367
View File
@@ -0,0 +1,367 @@
"""分析的入口共用的运行期部分(docs/design/analysis-round/00-总方案.md §3.2、§3.4、§3.10)。
两个入口——agent 分析工具(tools/builtin/analysis.py)、分析卡(engine/nodes/analysis.py)——和口径卡的可比性检查
(engine/nodes/metrics.py)都经这里取阈值、拼算子要的运行期依赖、认遮罩列、把结论落成工件,算子只经 app.analysis.run
调用,不各自另起一套:
- **本节点的查询容器**(AnalysisScope):agent 建工具早于查询(engine/nodes/llm.py),工具经 ToolContext.extra[SCOPE_KEY]
拿到这个可变容器。节点每记一次查询往里放一份输入快照(编号和交回模型的「【证据 Qn】」同一个),每记一份分析结论往里
放一份(编号 An,下钻的 parent 按它找上一层)。只在记台账的地方往里放:工具调用在 @task 里,重放时不执行,记台账的
那一步按 checkpoint 里的结果照样放一遍,编号还是同一套。
- **阈值**:发起时的快照(run.options["analysis"],engine/launch.py)← 分析卡上写的覆盖;没有快照的现读设置。agent 改不了:
分析工具的参数里没有阈值(契约 D4)。
- **运行期依赖**(contract.Context):运行当天(服务端时区)、现查表的日期范围(analysis.make_prober,按这次运行固定的数据
版本解析数据源)、数据目录(按运行的目录视图读,和 db_schema 给模型看的同一份)、剖析事实(analysis.probe.profile_facts,
和查询工具的覆盖检查同一个取法)。
- **遮罩列**:数据源设的遮罩列、查询当时记下的、按 SQL 认出改了名字的(和证据面板、运行对比同一个函数,api/evidence 的
_mask_sets 的 hidden,宁可多遮),加上遮罩运行里查询时就整列遮掉的。算子不拿它们当维度、度量。
- **结论**:整份落成内容寻址的工件(contract.ARTIFACT_KIND),台账记 contract.ledger_entry;交回 agent 的是一段短的
「【分析 An】」加判定语、关键数、附带说明、算不了的原因。
"""
from __future__ import annotations
import json
import logging
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from datetime import date
from typing import Any
from app.analysis import contract
from app.analysis.contract import AnalysisResult, CallError, InputSnapshot
logger = logging.getLogger(__name__)
#: ToolContext.extra 里放本节点分析容器(AnalysisScope)的键
SCOPE_KEY = "analysis_scope"
#: 五个分析工具的名字(contract.TOOL_NAMES)
ANALYSIS_TOOLS = frozenset(contract.TOOL_NAMES.values())
#: 交回 agent 的那一段开头的编号写法,和查询的「【证据 Qn】」一个样子
REPLY_HEAD = "【分析 {alias}】"
#: 调用写错时交回 agent 的那句的开头:说清没有记成结论,下一步照着改
CALL_ERROR_HEAD = "分析调用有误,没有记成结论:"
#: agent 写的「这次在验证什么」最多留多少字(只在证据面板显示,不进正文)
HYPOTHESIS_MAX = 300
#: 一份分析算不了(status refused)时那条提醒的 code(运行日志):两个入口同一个。复核信号按它认(engine/review.py 的
#: WARN_CODES,第 2 波登记)
REFUSED_CODE = "analysis_refused"
def is_analysis_tool(name: Any) -> bool:
return isinstance(name, str) and name.strip() in ANALYSIS_TOOLS
def call_error_text(error: CallError) -> str:
"""调用写错(contract.CallError)交回模型的那句:不记结论、不进台账,模型照着 message 改了再调。"""
return f"{CALL_ERROR_HEAD}{error.message}"
# --------------------------------------------------------------------------
# 本节点的查询容器
# --------------------------------------------------------------------------
@dataclass
class AnalysisScope:
"""一次节点执行里分析工具读的容器(agent 节点、协作成员的一次执行各一个)。
queries:编号 Qn → 输入快照(contract.InputSnapshot;记台账时先放交回模型的那份 payload,分析前 prepare 换成工件库里
那份完整快照,补上遮罩列)。results:编号 An → 整份结论;artifacts:An → 结论的工件 id。snapshot:发起时的分析设置快照
(没有为 None,现读设置)。mask_cache:快照工件 → 遮罩列;source_cache:数据源和表 → 目录、查询用的源;probe_cache:
数据源名 → coverage.Cache(一次节点执行里各读、各查一次,只在工具执行时用,不影响编号)。"""
node_id: str
snapshot: dict[str, Any] | None = None
queries: dict[str, InputSnapshot] = field(default_factory=dict)
results: dict[str, AnalysisResult] = field(default_factory=dict)
artifacts: dict[str, str] = field(default_factory=dict)
mask_cache: dict[str, list[str]] = field(default_factory=dict)
source_cache: dict[str, Any] = field(default_factory=dict)
probe_cache: dict[str, Any] = field(default_factory=dict)
#: 已经换成工件库里那份完整快照的编号(prepare 每份只取回一次)
prepared: set[str] = field(default_factory=set)
def add_query(self, alias: str, snapshot: InputSnapshot) -> None:
self.queries[alias] = snapshot
def add_result(self, result: AnalysisResult, artifact: str) -> str:
"""记一份结论,返回它在本节点的编号(A1、A2……,按记下的先后)。"""
alias = f"{contract.CATALOG_PREFIX}{len(self.results) + 1}"
self.results[alias] = result
self.artifacts[alias] = artifact
return alias
def resolve(self, name: str) -> InputSnapshot | None:
return self.queries.get(str(name).strip())
def parent(self, name: str) -> AnalysisResult | None:
return self.results.get(str(name).strip())
def scope_of(extra: Mapping[str, Any] | None) -> AnalysisScope | None:
scope = (extra or {}).get(SCOPE_KEY)
return scope if isinstance(scope, AnalysisScope) else None
# --------------------------------------------------------------------------
# 阈值
# --------------------------------------------------------------------------
def run_snapshot(run_ctx: Any) -> dict[str, Any] | None:
"""这次运行发起时快照的分析设置(run.options["analysis"],engine/launch.py 写,runner 放进 RunContext.extra 的
run_options)。没有快照(图里本来没有分析、不经 launch_run 发起的)为 None。"""
from app.engine.runner import RUN_OPTIONS_KEY
options = (getattr(run_ctx, "extra", None) or {}).get(RUN_OPTIONS_KEY)
snapshot = options.get(contract.SETTINGS_KEY) if isinstance(options, dict) else None
return snapshot if isinstance(snapshot, dict) else None
async def settings_of(snapshot: Mapping[str, Any] | None,
overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""这次分析实际用的阈值:缺省 ← 发起时的快照(没有就现读设置)← 分析卡上的覆盖(contract.effective_settings)。"""
if snapshot is None:
from app.api.settings import analysis_settings # 在这里导入:api.settings 导入了 engine 里的模块
from app.db.base import SessionLocal
async with SessionLocal() as session:
snapshot = await analysis_settings(session)
return contract.effective_settings(snapshot, overrides)
# --------------------------------------------------------------------------
# 输入快照与遮罩列
# --------------------------------------------------------------------------
def input_snapshot(content: Mapping[str, Any], *, ref: str, artifact: str, node_id: str,
masked: Iterable[str] = ()) -> InputSnapshot:
"""一份查询快照 → 算子的一份输入:查询工具的 payload(工件库里那份)原样——列、行、类型、SQL、数据源,连同 SQL 检查结果
(checks)、「查询区间里这张表没有数据」(no_data)、查询当时记下的遮罩(mask_columns、masked_at_query),可比性检查按
它们判数据问题——再加上出处(ref、artifact、node_id)和不能当维度、度量的列(masked,见 masked_of)。合并查询在 SQLite
里执行,方言写明;数据源的方言算子按探查器认(prober.dialect)。"""
from app.engine.merge_query import MERGE_SOURCE
out: dict[str, Any] = {**content, "ref": ref, "artifact": artifact, "node_id": node_id, "masked": list(masked)}
if content.get("source") == MERGE_SOURCE and not content.get("dialect"):
out["dialect"] = "sqlite"
return out # type: ignore[return-value]
async def masked_of(content: Mapping[str, Any] | None, artifact: str | None) -> list[str]:
"""一份查询快照里不能当维度、度量的列(小写):结果里取自遮罩列的列按快照的列序在前,其余遮罩列名在后。
认法和证据面板查询步骤、运行对比同一个函数(api/evidence 的 _mask_sets 的 hidden:查询当时记下的加数据源现在设的,
SQL 里给遮罩列起了别名、套了函数的结果列也算,SQL 认不准时整份的列都算,合并查询按它的来历认)。遮罩运行
(mask_results)里查询时就遮掉的列(masked_at_query、整份遮的 masked_whole)格子里已经是「已遮罩」,一并算上。"""
from app.api.evidence import _Masks # 在这里导入:api.evidence 导入了 engine 里的模块
if not isinstance(content, Mapping):
return []
hidden = set((await _Masks().sets(artifact, content.get("source"), dict(content))).hidden)
columns = [str(c) for c in content.get("columns") or []]
hidden |= {str(c).lower() for c in content.get("masked_at_query") or [] if isinstance(c, str)}
if content.get("masked_whole"):
hidden |= {c.lower() for c in columns}
shown = [c.lower() for c in columns if c.lower() in hidden]
return list(dict.fromkeys([*shown, *sorted(hidden - set(shown))]))
async def prepare(scope: AnalysisScope) -> None:
"""分析前把容器里的每份输入换成工件库里那份完整的查询快照(取回时复验哈希),补上遮罩列(每份工件只认一次)。快照
取不回来、和哈希对不上的照实报错:查询明明交回过,分析读不到它的快照,不是 agent 写错了参数。"""
import asyncio
from app.core import artifact_store
for alias, snapshot in list(scope.queries.items()):
if alias in scope.prepared:
continue
artifact = str(snapshot.get("artifact") or "")
try:
content = await asyncio.to_thread(artifact_store.load, artifact)
except ValueError as e:
raise RuntimeError(f"{alias} 的查询快照与哈希不一致,疑似被修改,不能分析") from e
if not isinstance(content, Mapping):
raise RuntimeError(f"{alias} 的查询快照已不存在,不能分析")
if artifact not in scope.mask_cache:
scope.mask_cache[artifact] = await masked_of(content, artifact)
scope.queries[alias] = input_snapshot(content, ref=alias, artifact=artifact,
node_id=str(snapshot.get("node_id") or scope.node_id),
masked=scope.mask_cache[artifact])
scope.prepared.add(alias)
# --------------------------------------------------------------------------
# 运行期依赖:运行当天、目录、剖析事实、现查表范围
# --------------------------------------------------------------------------
def _tables_of(snapshots: Iterable[Mapping[str, Any]]) -> dict[str, set[str]]:
"""数据源名 → 这几份快照的 SQL 里用到的表(evidence.sql_tables,去掉 schema 前缀前的写法)。合并查询不是数据源。"""
from app.engine.evidence import sql_tables
from app.engine.merge_query import MERGE_SOURCE
out: dict[str, set[str]] = {}
for snap in snapshots:
name = str(snap.get("source") or "")
if not name or name == MERGE_SOURCE:
continue
try:
tables = sql_tables(str(snap.get("sql") or ""))
except Exception: # noqa: BLE001 - 认不出表就只是少给一点目录
tables = []
out.setdefault(name, set()).update(str(t) for t in tables)
return out
async def context_of(snapshots: Iterable[Mapping[str, Any]], *, tool_ctx: Any,
cache: dict[str, Any] | None = None,
probe_cache: dict[str, Any] | None = None) -> contract.Context:
"""算子要的运行期依赖(contract.Context)。每次分析调用一份(探查器有总时限,一次调用建一个)。
- today:运行当天,服务端时区(算子据此判本期完不完整)
- catalogs:数据源名 → {表名: 这张表的目录(app/data/catalog 的 notes)},按运行的目录视图读(tool_ctx.catalog_view,
和 db_schema 给模型看的、冻结进表结构快照的同一份),只给快照的 SQL 里用到的表
- facts:数据源名 → 取剖析事实的函数(analysis.probe.profile_facts:上传表格的剖析不是这次查的那一版的不给)
- probe:analysis.make_prober(数据源名 → 查询用的源):按这次运行固定的数据版本解析数据源(上传表格查的是固定的那一版),
遮罩列按数据源设的不探,探不成由算子退回剖析事实;一个源都解析不出来时为 None
tool_ctx 是查询用的那个 ToolContext(固定的数据版本、目录视图都从它取)。cache 是调用方的缓存(同一个节点里分析好几次,
目录、数据源读一次);probe_cache 是数据源名 → coverage.Cache(同一张表的范围一个节点里只查一次)。读不到的源当没有:
目录、剖析、探查都是附加的,不能挡住分析。"""
from app.analysis.probe import profile_facts
cache = cache if cache is not None else {}
catalogs: dict[str, Any] = {}
facts: dict[str, Any] = {}
sources: dict[str, Any] = {}
for name, tables in _tables_of(snapshots).items():
key = f"{name}\n" + "\n".join(sorted(tables))
if key not in cache:
cache[key] = await _source_context(name, tables, tool_ctx)
entry = cache[key]
if entry is None:
continue
sources[name] = entry["source"]
catalogs[name] = entry["catalog"]
facts[name] = profile_facts(entry["source"])
return {"today": date.today(), "probe": _prober(sources, probe_cache), "catalogs": catalogs, "facts": facts}
async def _source_context(name: str, tables: set[str], tool_ctx: Any) -> dict[str, Any] | None:
"""一个数据源这几张表的目录,和查询用的那个源(探查、剖析事实都按它)。读不到为 None。"""
from app.data import catalog as data_catalog
from app.data import catalog_view
from app.tools.datasource import resolved_source
try:
resolved = await resolved_source(name, tool_ctx)
if resolved is None:
return None
row, source = resolved
entries = await catalog_view.read(catalog_view.CatalogView.parse(getattr(tool_ctx, "catalog_view", None)), row)
except Exception: # noqa: BLE001 - 目录读不到当没有(目录视图指定的快照不在了另由查询工具照实报)
logger.warning("分析读取数据源 %s 的目录失败,这次不附", name, exc_info=True)
return None
names = {data_catalog.resolve_table_name(getattr(source, "schema_cache", None), t) or t for t in tables}
return {"source": source, "catalog": {t: dict(entries[t].notes or {}) for t in sorted(names) if t in entries}}
def _prober(sources: Mapping[str, Any], probe_cache: dict[str, Any] | None) -> contract.Prober | None:
"""这次分析调用的探查器(analysis.make_prober)。遮罩列、剖析事实按缺省(数据源设的遮罩列;profile_facts 现读);
coverage.Cache 按数据源名放在调用方给的 probe_cache 里,同一个节点里的几次分析共用。"""
if not sources:
return None
from app import analysis
from app.data import coverage
shared = probe_cache if probe_cache is not None else {}
return analysis.make_prober(dict(sources), cache={name: shared.setdefault(name, coverage.Cache())
for name in sources})
def tool_context(run_ctx: Any, node_id: str, emit: Any = None) -> Any:
"""分析卡、口径卡没有自己的 ToolContext:拼一个只用来解析数据源的(固定的数据版本、目录视图、遮罩、只读同运行)。
记忆范围、知识库用不着,不读节点配置。"""
from app.data import catalog_view
from app.tools.datasource import run_masks_results, run_reads_only, run_scope_fixed, run_versions
from app.tools.registry import ToolContext
return ToolContext(run_id=run_ctx.run_id, node_id=node_id, sandbox_session=run_ctx.thread_id,
data_versions=run_versions(run_ctx), mask_results=run_masks_results(run_ctx),
force_readonly=run_reads_only(run_ctx), fixed_scope=run_scope_fixed(run_ctx),
catalog_view=catalog_view.of_run(run_ctx), emit=emit)
# --------------------------------------------------------------------------
# 结论:落成工件、交回模型
# --------------------------------------------------------------------------
async def store(result: AnalysisResult, *, run_id: str, node_id: str) -> str:
"""整份结论落成内容寻址的工件(contract.ARTIFACT_KIND)。复算、证据面板、导出都按这个 id 取回。"""
from app.core.artifact_store import put_json
return await put_json(result, kind=contract.ARTIFACT_KIND, run_id=run_id, node_id=node_id,
meta={"op": result.get("op"), "status": result.get("status")})
def payload_of(content: Any) -> dict[str, Any] | None:
"""分析工具交回的 JSON(文本或对象)→ {artifact, result};不是一份分析结论的返回 None。"""
data = content
if isinstance(data, str):
if not data.lstrip().startswith("{"):
return None
try:
data = json.loads(data)
except ValueError:
return None
if not isinstance(data, dict):
return None
artifact, result = data.get("analysis_artifact"), data.get("result")
if not (isinstance(artifact, str) and artifact and isinstance(result, dict) and result.get("op") in contract.OPS):
return None
return {"artifact": artifact, "result": result}
def reply_text(alias: str, result: AnalysisResult) -> str:
"""交回模型的那一段:「【分析 An】」加一个紧凑的 JSON——判定、判定语、判定标准、关键数、附带说明、算不了的原因、
结果表的前几段(列几段照结论里记的 table_top_k)。整份结论在工件里,模型要的是能接着往下做的那几样。"""
verdict = result.get("verdict") or {}
body: dict[str, Any] = {"status": result.get("status"), "op": result.get("op")}
if result.get("mode"):
body["mode"] = result.get("mode")
if result.get("status") == "ok":
body.update(verdict=verdict.get("code"), subjects=verdict.get("subjects") or [],
direction=verdict.get("direction"))
else:
body.update(reason_code=result.get("reason_code"), reason=result.get("reason"))
body["phrase"] = result.get("phrase")
if result.get("criterion"):
body["criterion"] = result.get("criterion")
if result.get("values"):
body["values"] = result.get("values")
if caveats := [c.get("text") for c in result.get("caveats") or [] if isinstance(c, dict) and c.get("text")]:
body["caveats"] = caveats
table = result.get("table")
if isinstance(table, dict) and table.get("rows"):
top = int((result.get("settings") or {}).get("table_top_k") or contract.DEFAULTS["table_top_k"])
rows = list(table.get("rows") or [])
body["table"] = {"columns": table.get("columns"), "rows": rows[:top],
**({"more_rows": len(rows) - top} if len(rows) > top else {})}
return REPLY_HEAD.format(alias=alias) + "\n" + json.dumps(body, ensure_ascii=False, default=str)
def hypothesis_of(value: Any) -> str | None:
"""agent(或模板作者)写的「这次在验证什么」:去掉首尾空白、截到 HYPOTHESIS_MAX 字;空的为 None。"""
text = str(value or "").strip()
return text[:HYPOTHESIS_MAX] if text else None
__all__ = [
"ANALYSIS_TOOLS", "CALL_ERROR_HEAD", "REFUSED_CODE", "REPLY_HEAD", "SCOPE_KEY", "AnalysisScope", "call_error_text", "context_of",
"hypothesis_of", "input_snapshot", "is_analysis_tool", "masked_of", "payload_of", "prepare", "reply_text",
"run_snapshot", "scope_of", "settings_of", "store", "tool_context",
]
+2 -2
View File
@@ -1,5 +1,5 @@
"""导入即注册。新增内置工具时在这里加一行。"""
from app.tools.builtin import code, data, files, knowledge, platform, web # noqa: F401
from app.tools.builtin import analysis, code, data, files, knowledge, platform, web # noqa: F401
__all__ = ["code", "data", "files", "knowledge", "platform", "web"]
__all__ = ["analysis", "code", "data", "files", "knowledge", "platform", "web"]
+263
View File
@@ -0,0 +1,263 @@
"""agent 分析工具(docs/design/analysis-round/00-总方案.md §3.2):变化拆解、波动基线、趋势、差异检验、可比性检查,一个算子
一个工具(名字取 contract.TOOL_NAMES),参数各自聚焦,模型少写错。
- **参数只有「算什么」**:引用本节点查过的结果(query: "Q3",和交回模型的「【证据 Q3】」同一个编号)、列的角色、期次、
总数、上一层拆解、度量的叫法、这次在验证什么。没有任何阈值参数:阈值只来自设置快照(D4),模型不能把阈值调到想要的
结论。参数名写错、多写了参数,照别的工具一样交回「它接受什么」(registry.prepare_args)。
- **调用写错**(contract.CallError:列不存在、列被遮罩、比率当可加、这个节点的分析次数用完……)不记结论,原话交回模型改;
**算不了**(历史不够、本期不完整、截断……)是一条结论,照样落成工件、进台账。
- **方法说明**:五个工具共用一份「分析方法」(METHOD_GUIDE),绑了任一个就注入 system,同一份只注入一次。
- 工具本身不查库:读的是本节点的查询容器(app/tools/analysis.AnalysisScope),能力登记里不认它们是查询
(capabilities.registry.is_platform_query 只认数据源查询和声明了表格的端点读取)。
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
from app.analysis import contract
from app.analysis.contract import CallError
from app.tools.registry import ToolContext, ToolGuide, register
CATEGORY = "分析"
#: 「分析方法」:五个分析工具共用,绑了任一个就注入 system(第一版,第 2 波定稿)。改了方法就改 id 里的版本号:评测的
#: prompt_fingerprint 认得出提示词变了
ANALYSIS_METHOD_PROMPT = """\
# 分析方法(平台内置,用分析工具时照这个做)
1. 先定现象:取本期和问题对应的基期,用 analysis_comparable 确认两期能直接比;历史够长(缺省至少 8 期)就用 analysis_baseline 放回历史看——是常规波动就照实说,不硬找原因。
2. 指标是乘积或比率时,先用 analysis_decompose 的 factor、ratio 模式拆因子、拆结构。
3. 按数据目录里的候选维度由粗到细拆(additive 模式),每次用 headline 给出总数核对拆开后是否闭合;找到主要来源再用 parent 下钻一层:下钻的 SQL 由你写(只查那一段),系统按上一层那一段的值核对闭合。
4. 每次调用在 hypothesis 里写明这次在验证什么;拆了没发现集中也是结论,照实写。
5. 不作因果断言:数据只说明变化来自哪里;原因要有知识库原话或输入作依据,否则写成待核实的推测。
6. 阈值和方法由平台定,工具参数里没有阈值;算不了时照实说算不了的原因,不要换个说法硬下判断。
分析结论的编号「【分析 An】」只在本节点有效:下钻时 parent 写它(如 {"of": "A1", "key": ["华东"]})。判定语由系统写好了,结论里照它的意思说,不要改写判断的强弱。"""
METHOD_GUIDE = ToolGuide(id="analysis.method@1", text=ANALYSIS_METHOD_PROMPT)
#: 交回 agent 的说法:这个节点里没有可分析的查询容器(调用工具节点、工具库试用、没记台账的老运行)
NO_SCOPE = ("分析工具只能由 Agent 在本节点查过数之后调用:它分析的是本节点交回过的查询结果(【证据 Qn】的编号)。"
"模板里要固定做的分析,请改用「分析卡」节点")
#: 交回 agent 的说法:本节点还没有成功的查询
NO_QUERIES = "本节点还没有成功的查询可以分析:先用数据库查询工具查出要分析的数,再按交回的编号(Q1、Q2……)调用分析工具"
_QUERY = "要分析的查询结果:本节点交回过的编号,如 Q3(和「【证据 Q3】」同一个)"
_LABEL = "度量的叫法,系统写判定语时用(如「销售额」);不要写数字、百分号和单位,留空就用列名"
_HYPOTHESIS = "这次在验证什么,一句话(如「下降是不是集中在某个地区」):只在证据面板显示,不进报告正文"
_PERIOD = "期次列的列名(日期、周、月……)"
_GRAIN = "期次的粒度:day、week、month、quarter;留空按期次的写法推断"
class Headline(BaseModel):
"""总数的两格:拆开后各段之和要分别等于它们(拆解闭合)。"""
base: str = Field(description="基期总数那一格,写成「编号.r行号.列名」,如 Q1.r1.gmv(行号从 0 数)")
target: str = Field(description="本期总数那一格,如 Q1.r0.gmv")
class Parent(BaseModel):
"""下钻:这一层是上一层拆解里某一段的拆解。"""
of: str = Field(description="上一层拆解的编号,如 A1(「【分析 A1】」里的那个)")
key: list[str] = Field(description="上一层里要往下拆的那一段的维度值,一个维度一个值(如 华东)")
class DecomposeArgs(BaseModel):
mode: Literal["additive", "ratio", "factor"] = Field(
description="additive:可加的量(销售额、订单数)按维度算各段对总变化的贡献;ratio:比率(退款率、客单价)"
"拆成结构变化和各段自身的变化;factor:乘积(销售额 = 订单数 × 客单价)拆成各因子的贡献")
query: str | None = Field(default=None, description=_QUERY + ";这份结果里有期次列,用 base、target 指定比哪两期")
base_query: str | None = Field(default=None,
description="基期、本期是两份查询时:基期那份的编号(这时不写 query、period)")
target_query: str | None = Field(default=None, description="基期、本期是两份查询时:本期那份的编号")
by: list[str] | None = Field(default=None, description="维度列的列名,1 到 2 列(additive、ratio 用)")
period: str | None = Field(default=None, description=_PERIOD)
base: str | None = Field(default=None, description="基期:期次列里的一个值,如 2026-W41")
target: str | None = Field(default=None, description="本期:期次列里的一个值,如 2026-W42")
measure: str | None = Field(default=None, description="度量列(additive 用)")
numerator: str | None = Field(default=None, description="分子列(ratio 用,如退款单数)")
denominator: str | None = Field(default=None, description="分母列(ratio 用,如订单数)")
product: str | None = Field(default=None, description="乘积列(factor 用,如销售额)")
factors: list[str] | None = Field(default=None, description="因子列,2 到 4 列(factor 用,如订单数、客单价)")
headline: Headline | None = Field(default=None, description="可选。总数的两格,用来核对拆开后各段之和对不对得上")
parent: Parent | None = Field(default=None, description="可选。下钻:这一层是哪次拆解里哪一段的拆解")
label: str | None = Field(default=None, description=_LABEL)
hypothesis: str | None = Field(default=None, description=_HYPOTHESIS)
class BaselineArgs(BaseModel):
query: str = Field(description=_QUERY + ";要一条按期次排的序列(要看某一段就在 SQL 里筛好)")
period: str = Field(description=_PERIOD)
measure: str = Field(description="度量列")
target: str | None = Field(default=None, description="本期:期次列里的一个值;留空取最后一期")
basis: Literal["change", "level"] | None = Field(
default=None, description="change:和比较期比的变化率(缺省);level:值本身(存量、比率用)")
grain: Literal["day", "week", "month", "quarter"] | None = Field(default=None, description=_GRAIN)
lag: int | None = Field(default=None, description="和往前第几期比:日缺省 7(和上周同一天比),周、月缺省 1;"
"要同比写 52(周)或 12(月),历史要相应够长")
label: str | None = Field(default=None, description=_LABEL)
hypothesis: str | None = Field(default=None, description=_HYPOTHESIS)
class TrendArgs(BaseModel):
query: str = Field(description=_QUERY + ";要一条按期次排的序列")
period: str = Field(description=_PERIOD)
measure: str = Field(description="度量列")
grain: Literal["day", "week", "month", "quarter"] | None = Field(default=None, description=_GRAIN)
label: str | None = Field(default=None, description=_LABEL)
hypothesis: str | None = Field(default=None, description=_HYPOTHESIS)
class DifferArgs(BaseModel):
mode: Literal["rate", "mean", "rows"] = Field(
description="rate:比率(转化率、退款率),给分子、分母两列;mean:均值,给每组的均值、标准差、样本数三列,"
"或合计、平方和、样本数三列;rows:逐行的值,算子自己算均值和方差(结果不能截断)")
query: str = Field(description=_QUERY)
by: list[str] | None = Field(default=None, description="按组比时:分组列的列名(一列)")
pairs: list[list[str]] | None = Field(default=None, description="只比这几对:每对写两个组在分组列里的值")
reference: str | None = Field(default=None, description="对照组:分组列里的一个值,其余各组各和它比")
period: str | None = Field(default=None, description="按期次比同一组的两期时:期次列(SQL 里先筛好那一组)")
base: str | None = Field(default=None, description="按期次比时:基期")
target: str | None = Field(default=None, description="按期次比时:本期")
numerator: str | None = Field(default=None, description="分子列:成功数(rate 用,如退款单数)")
denominator: str | None = Field(default=None, description="分母列:试验数(rate 用,如订单数)")
mean: str | None = Field(default=None, description="每组的均值列(mean 用,和标准差、样本数一起给)")
sd: str | None = Field(default=None, description="每组的标准差列(mean 用)")
sum: str | None = Field(default=None, description="每组的合计列(mean 用,没有标准差时和平方和、样本数一起给)")
sumsq: str | None = Field(default=None, description="每组的平方和列(mean 用)")
n: str | None = Field(default=None, description="每组的样本数列(mean 用)")
value: str | None = Field(default=None, description="逐行的值列(rows 用)")
label: str | None = Field(default=None, description=_LABEL)
hypothesis: str | None = Field(default=None, description=_HYPOTHESIS)
class ComparableArgs(BaseModel):
base_query: str | None = Field(default=None, description="基期那份查询的编号,如 Q1")
target_query: str | None = Field(default=None, description="本期那份查询的编号,如 Q2")
query: str | None = Field(default=None, description="两期在同一份查询里按期次分组时:那份的编号(这时不写上面两项)")
period: str | None = Field(default=None, description="同一份查询时:期次列")
base: str | None = Field(default=None, description="同一份查询时:基期")
target: str | None = Field(default=None, description="同一份查询时:本期")
measure: str | None = Field(default=None, description="度量列:按它找时间条件、认它是不是可累加的量;只有一列数值列时可以不写")
grain: Literal["day", "week", "month", "quarter"] | None = Field(default=None, description=_GRAIN)
hypothesis: str | None = Field(default=None, description=_HYPOTHESIS)
#: 算子 → 参数模型。分析卡的一项按同一份参数模型校验参数(engine/nodes/analysis.py)
ARGS: dict[str, type[BaseModel]] = {
"decompose": DecomposeArgs, "baseline": BaselineArgs, "trend": TrendArgs, "differ": DifferArgs,
"comparable": ComparableArgs,
}
#: 不算「算什么」的参数:模式另放,这次在验证什么另放(contract.AnalysisRequest)
NOT_PARAMS = frozenset({"mode", "hypothesis"})
def param_names(op: str) -> list[str]:
"""一个算子的参数名(不含模式和「在验证什么」),按参数模型里的先后。分析卡校验一项的参数时按它。"""
return [k for k in ARGS[op].model_fields if k not in NOT_PARAMS]
def request_of(op: str, args: BaseModel) -> contract.AnalysisRequest:
"""校验过的参数 → 一次分析的请求(contract.AnalysisRequest):没填的参数不带。"""
from app.tools.analysis import hypothesis_of
data = args.model_dump(exclude_none=True)
request: contract.AnalysisRequest = {
"op": op, "mode": data.get("mode") if op in contract.MODES else None,
"params": {k: v for k, v in data.items() if k not in NOT_PARAMS},
}
if hypothesis := hypothesis_of(data.get("hypothesis")):
request["hypothesis"] = hypothesis
return request
async def run_tool(op: str, ctx: ToolContext, args: BaseModel) -> dict[str, Any]:
"""一次 agent 分析:读本节点的查询容器、按设置快照取阈值、调算子、结论落成工件。交回 {analysis_artifact, result}:
记台账、编号 An、给模型看的那一段由节点在记台账时做(engine/nodes/llm.py 的 _record_call),重放时照样做一遍。
调用写错(含这个节点的分析次数用完,analysis.exhausted)抛 contract.CallError,节点作为工具错误交回模型、不记台账。
结论原样存成工件、一个字段都不加:下钻时算子按上一层结论的内容哈希认 parent 的工件。"""
import app.analysis as analysis_ops # 按模块取 run:测试换替身时只换模块上的这一个函数
from app.tools.analysis import context_of, prepare, scope_of, settings_of, store
scope = scope_of(ctx.extra)
if scope is None:
raise CallError("no_scope", NO_SCOPE)
if not scope.queries:
raise CallError("no_queries", NO_QUERIES)
settings = await settings_of(scope.snapshot)
if len(scope.results) >= settings["max_calls"]:
raise analysis_ops.exhausted(settings) # 次数用完的说法只有算子那一处
request = request_of(op, args)
await prepare(scope)
context = await context_of(list(scope.queries.values()), tool_ctx=ctx, cache=scope.source_cache,
probe_cache=scope.probe_cache)
result = await analysis_ops.run(request, scope.resolve, settings=settings, context=context, parents=scope.parent)
artifact = await store(result, run_id=ctx.run_id, node_id=scope.node_id)
return {"analysis_artifact": artifact, "result": result}
@register(
name=contract.TOOL_NAMES["decompose"],
category=CATEGORY,
description="变化拆解:说清一个量的变化从哪来。读本节点查过的结果(【证据 Qn】的编号),按维度算各段对总变化的贡献"
"(additive)、把比率的变化拆成结构变化和各段自身的变化(ratio)、把乘积拆成各因子的贡献(factor);给了总数"
"就核对各段之和对不对得上,parent 接着上一次拆解的某一段往下钻。主要来自哪一段、是否分散由系统按平台的阈值判定。",
args_schema=DecomposeArgs,
guide=METHOD_GUIDE,
)
async def analysis_decompose(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
return await run_tool("decompose", ctx, DecomposeArgs(**kwargs))
@register(
name=contract.TOOL_NAMES["baseline"],
category=CATEGORY,
description="波动基线:把本期放回它自己的历史里看,判断这次的变化在不在常规波动范围内。要一份按期次排的序列"
"(期次列 + 度量列);历史不够长、本期还没过完时照实交回算不了的原因。",
args_schema=BaselineArgs,
guide=METHOD_GUIDE,
)
async def analysis_baseline(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
return await run_tool("baseline", ctx, BaselineArgs(**kwargs))
@register(
name=contract.TOOL_NAMES["trend"],
category=CATEGORY,
description="趋势:判断一条按期次排的序列是在持续上升、持续下降,还是没有明显趋势(只是一次波动)。",
args_schema=TrendArgs,
guide=METHOD_GUIDE,
)
async def analysis_trend(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
return await run_tool("trend", ctx, TrendArgs(**kwargs))
@register(
name=contract.TOOL_NAMES["differ"],
category=CATEGORY,
description="差异检验:判断两组(或同一组的两期)的差异站得住,还是可能只是样本波动。比率给分子、分母两列(rate),"
"均值给均值、标准差、样本数或合计、平方和、样本数(mean),也可以给逐行的值(rows);样本小时照实标出。",
args_schema=DifferArgs,
guide=METHOD_GUIDE,
)
async def analysis_differ(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
return await run_tool("differ", ctx, DifferArgs(**kwargs))
@register(
name=contract.TOOL_NAMES["comparable"],
category=CATEGORY,
description="可比性检查:比两期之前先确认能不能直接比——两份查询是不是只差日期、窗口是否一样长、是不是整期、"
"星期构成是否相同。判定为可比、有条件可比或不可比,并列出原因。",
args_schema=ComparableArgs,
guide=METHOD_GUIDE,
)
async def analysis_comparable(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
return await run_tool("comparable", ctx, ComparableArgs(**kwargs))
__all__ = ["ANALYSIS_METHOD_PROMPT", "ARGS", "METHOD_GUIDE", "param_names", "request_of", "run_tool"]
+25 -19
View File
@@ -374,6 +374,26 @@ def _pinned_under_name(pins: dict[str, Any], name: str) -> bool:
return any(isinstance(entry, dict) and entry.get("name") == name for entry in pins.values())
async def resolved_source(name: str, ctx: ToolContext) -> tuple[DataSource, Any] | None:
"""按名字找启用着的数据源,按这次运行固定的版本解析成查询用的那个源(_resolve,同 build_datasource_tools):(数据源的行,
查询用的源)。找不到、固定的版本没了返回 None。
分析(app/tools/analysis.py)读目录、剖析事实、现查表的日期范围时用:查的要和这次运行的查询是同一版数据。用自己的短
会话,不提交(行上已经读出来的属性会话关了照样能读)。"""
from app.db.base import SessionLocal
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(
DataSource.name == name, DataSource.enabled.is_(True)))).scalar_one_or_none()
if row is None:
return None
try:
source, _ = await _resolve(session, row, ctx)
except table_versions.SnapshotMissing:
return None
return row, source
async def _resolve(session: AsyncSession, row: DataSource, ctx: ToolContext) -> tuple[Any, bool]:
"""查询该用的源,以及它的版本是不是这次运行固定的。
@@ -784,29 +804,15 @@ def _unchecked_tables(sql: str, source: Any, outside: frozenset[str]) -> list[st
async def _coverage(source: Any, sql: str, payload: Mapping[str, Any], mask: list[str]) -> list[dict[str, Any]]:
"""查询区间里这张表没有数据的那几列(data/coverage.result_gaps)。只在结果里有整列是 0 或空的数值列时才查库;遮罩列
不探查;现查表的范围不成时退回剖析、取值观察记下的范围(上传表格的快照对不上就不用)。从不抛异常。"""
不探查;现查表的范围不成时退回剖析、取值观察记下的范围(上传表格的快照对不上就不用)。从不抛异常。
剖析事实的取法只有一处(analysis.probe.profile_facts):分析的可比性检查现查不成时退回的也是它。"""
from app.analysis.probe import profile_facts
from app.data import coverage
if not coverage.zero_columns(payload):
return []
async def facts(table: str) -> Mapping[str, Any] | None:
from app.data.catalog_profile_jobs import last_facts
from app.db.base import SessionLocal
source_id = getattr(source, "id", None)
if not source_id:
return None
name = data_catalog.resolve_table_name(getattr(source, "schema_cache", None), table) or table
async with SessionLocal() as session:
found = (await last_facts(session, str(source_id), [name])).get(name)
snapshot = getattr(source, "snapshot_id", None)
if found and snapshot and found.get("snapshot_id") != snapshot:
# 上传表格:剖析时是另一版数据,范围对不上这次读的这一版
return None
return found
return await coverage.result_gaps(source, sql, payload, facts=facts, masked=mask)
return await coverage.result_gaps(source, sql, payload, facts=profile_facts(source), masked=mask)
async def _sql_checks(checker: SqlChecker, sql: str) -> list[dict[str, Any]]:
+32
View File
@@ -55,6 +55,17 @@ class ToolContext:
extra: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class ToolGuide:
"""绑了这个工具时一并交给模型的方法说明(写进 system,engine/nodes/llm.py 的 _build_messages、协作成员同样)。
几个工具共用一份说明时写同一个 id:同一份只注入一次(guides_of 按 id 去重)。说明随代码升版,不是 Skill——Skill 是库里
用户可改、可停用的行,种子只在同名不存在时插入(seed.py),平台的方法要跟着代码走(分析方法,D12)。"""
id: str
text: str
@dataclass
class ToolSpec:
name: str
@@ -72,6 +83,8 @@ class ToolSpec:
# 能力登记里的效果,写了就照它登记(平台内置的写入能力记 write:只能放在调用工具节点里,经动作流程执行,
# capabilities/platform.py)。不写的按 dangerous / writes_state 推
effect: str | None = None
# 绑了它时一并交给模型的方法说明(ToolGuide)。建工具时放进工具的 metadata["guide"],节点按 guides_of 去重注入
guide: ToolGuide | None = None
def json_schema(self) -> dict[str, Any]:
return self.args_schema.model_json_schema()
@@ -90,6 +103,7 @@ def register(
needs_context: bool = True,
writes_state: bool = False,
effect: str | None = None,
guide: ToolGuide | None = None,
) -> Callable[[ToolFunc], ToolFunc]:
def deco(func: ToolFunc) -> ToolFunc:
_REGISTRY[name] = ToolSpec(
@@ -102,6 +116,7 @@ def register(
needs_context=needs_context,
writes_state=writes_state,
effect=effect,
guide=guide,
)
return func
@@ -159,9 +174,26 @@ def build_tool(spec: ToolSpec, ctx: ToolContext) -> BaseTool:
coroutine=_run,
# 只给异步实现;同步路径直接报错,避免在事件循环里被阻塞调用
func=None,
# 方法说明跟着工具走:节点按绑定的工具取(guides_of),没有说明的工具 metadata 和以前一样是空的
**({"metadata": {"guide": spec.guide}} if spec.guide is not None else {}),
)
def guides_of(tools: Any) -> list[ToolGuide]:
"""这批工具带的方法说明,按 id 去重、按第一次出现的先后。没有说明的工具不算。"""
out: dict[str, ToolGuide] = {}
for tool in tools or []:
guide = ((getattr(tool, "metadata", None) or {}) if tool is not None else {}).get("guide")
if isinstance(guide, ToolGuide) and guide.id not in out:
out[guide.id] = guide
return list(out.values())
def guides_text(tools: Any) -> str:
"""注入 system 的那一段:每份说明一段,空行隔开。没有说明为空串。"""
return "\n\n".join(g.text.strip() for g in guides_of(tools) if g.text.strip())
async def build_tools(
names: list[str], ctx: ToolContext, *, session: Any = None, allow_write: bool = False
) -> list[BaseTool]:
+61
View File
@@ -0,0 +1,61 @@
"""测试用的分析算子替身(第 1 波各包并行:W1-a 的 app.analysis.run 落地前,入口的测试不等它)。
FakeOps 换掉模块上的 app.analysis.run(入口都按模块取 run),按请求交回 app.analysis.testing.fake_result 的假结论,
并记下每次调用拿到的请求、按名字解析到的输入快照、阈值、运行期依赖、上一层结论——测试据此断言入口交进去的东西。
"""
from __future__ import annotations
from typing import Any
import app.analysis as analysis_ops
from app.analysis.testing import fake_result
#: 每个算子缺省给的判定(fake_result 有现成判定语的那几个)
VERDICT = {"decompose": "driver", "baseline": "outside_band", "trend": "rising", "differ": "no_evidence",
"comparable": "comparable"}
class FakeOps:
"""errors:按调用的先后,每次要抛的异常(None 表示照常交回);用完了都照常交回。statuses / verdicts 同理。"""
def __init__(self, monkeypatch, *, errors: list[Any] | None = None, statuses: list[str] | None = None,
verdicts: list[str] | None = None, peek: list[str] | None = None, **overrides: Any) -> None:
self.calls: list[dict[str, Any]] = []
#: 每次调用时看一眼这几个名字能不能按 parents 取到上一层结论(visible)
self.peek = list(peek or [])
self.errors = list(errors or [])
self.statuses = list(statuses or [])
self.verdicts = list(verdicts or [])
self.overrides = overrides
monkeypatch.setattr(analysis_ops, "run", self.run)
async def run(self, request: dict[str, Any], resolve: Any, *, settings: Any, context: Any,
parents: Any = None) -> dict[str, Any]:
params = request.get("params") or {}
names = [params[k] for k in ("query", "base_query", "target_query") if params.get(k)]
snapshots = {name: resolve(name) for name in names}
parent = None
if isinstance(params.get("parent"), dict) and parents is not None:
parent = parents(params["parent"]["of"])
self.calls.append({"request": request, "snapshots": snapshots, "settings": dict(settings), "context": context,
"parent": parent, "parents": parents,
"visible": {n: parents is not None and parents(n) is not None for n in self.peek}})
error = self.errors.pop(0) if self.errors else None
if error is not None:
raise error
status = self.statuses.pop(0) if self.statuses else "ok"
verdict = self.verdicts.pop(0) if self.verdicts else VERDICT[request["op"]]
roles = {"query": "data", "base_query": "base", "target_query": "target"}
inputs = [{"ref": name, "artifact": snap["artifact"], "role": roles[key], "columns": snap["columns"],
"rows": "all"}
for key in ("query", "base_query", "target_query")
if (name := params.get(key)) and (snap := snapshots.get(name)) is not None]
if parent is not None:
from app.core.artifact_store import canonical_json, content_hash
inputs.append({"ref": params["parent"]["of"], "artifact": content_hash(canonical_json(parent)),
"role": "parent"})
return fake_result(op=request["op"], mode=request.get("mode"), verdict=verdict, status=status,
reason_code="history_short" if status == "refused" else None, inputs=inputs,
params=dict(params), settings=dict(settings), hypothesis=request.get("hypothesis"),
**self.overrides)
+422
View File
@@ -0,0 +1,422 @@
"""分析卡(节点类型 analysis,docs/design/analysis-round/00-总方案.md §3.2):画图时的校验、执行、产出的变量、受管规则、
登记处(叫法、变量表、助手查看、目录影响面、升级提醒)。运行对比按项对齐见 tests/test_analysis_compare.py。
算子(app.analysis.run)换成按请求交回假结论的替身(tests/fixtures/analysis_ops.FakeOps)。库是虚构的零售库 shop。
"""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from sqlalchemy import select
from app.analysis import contract
from app.analysis.contract import CallError
from app.core import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, NodeType, validate_graph
from tests.fixtures.analysis_ops import FakeOps
TOOL = "db_query__shop"
WEEKLY = "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week"
REGION = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week"
ROWS = [(1, "华东", "2026-W41", 820.0), (2, "华东", "2026-W42", 700.0), (3, "华南", "2026-W41", 700.0),
(4, "华南", "2026-W42", 666.0), (5, "华北", "2026-W41", 600.0), (6, "华北", "2026-W42", 560.0)]
WOW = {"id": "wow", "op": "baseline", "from": "weekly", "params": {"period": "week", "measure": "gmv",
"label": "销售额"}}
WHERE = {"id": "where", "op": "decompose", "mode": "additive", "from": "by_region",
"params": {"by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv",
"headline": {"base": "weekly.r0.gmv", "target": "weekly.r1.gmv"}, "label": "销售额"},
"hypothesis": "下降是不是集中在某个地区"}
DRILL = {"id": "east", "op": "decompose", "mode": "additive", "from": "by_region",
"params": {"by": ["week"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv",
"parent": {"of": "where", "key": ["华东"]}}}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def _shop(tmp_path, options=None):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, week TEXT, amount REAL);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ROWS)
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
if row is None:
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, options or {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
return source_id
@pytest.fixture
async def shop(tmp_path):
source_id = await _shop(tmp_path)
yield
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def edge(a, b, handle=None):
return {"source": a, "target": b, **({"sourceHandle": handle} if handle else {})}
def card_graph(items=(WOW, WHERE), *, extra_nodes=(), extra_edges=(), **card):
nodes = [node("start", "input"),
node("weekly", "tool", tool=TOOL, args={"sql": WEEKLY}),
node("by_region", "tool", tool=TOOL, args={"sql": REGION}),
node("card", "analysis", items=[dict(i) if isinstance(i, dict) else i for i in items], **card),
node("out", "output", fields=[{"name": "判定", "value": "{{ nodes.card.results.wow.phrase }}"}]),
*extra_nodes]
edges = [edge("start", "weekly"), edge("weekly", "by_region"), edge("by_region", "card"), edge("card", "out"),
*extra_edges]
return {"nodes": nodes, "edges": edges}
def card_issues(graph, level="error"):
return [i for i in validate_graph(GraphSpec.model_validate(graph)).issues
if i.node_id == "card" and (level is None or i.level == level)]
async def wait(run_id, statuses=("succeeded", "failed")):
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in statuses and row.manifest_seq is not None:
return row
raise AssertionError(f"run {run_id} 没有结束:{row.status}")
async def events(run_id, etype=None, node_id=None):
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id)
if etype:
q = q.where(RunEvent.type == etype)
if node_id:
q = q.where(RunEvent.node_id == node_id)
return [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()]
async def run_graph(graph, **kw):
run = await run_manager.start(graph=graph, input_payload={}, **kw)
return await wait(run.id)
async def output_of(run_id, node_id):
[finished] = await events(run_id, "node.finished", node_id)
return finished, artifact_store.load(finished["artifact"])
# --------------------------------------------------------------------------
# 画图时的校验
# --------------------------------------------------------------------------
def test_node_type_and_labels():
from app.engine.labels import field_label, type_label
assert NodeType.ANALYSIS == contract.NODE_TYPE == "analysis"
assert type_label("analysis") == "分析卡"
assert field_label("items", "analysis") == "分析项" and field_label("overrides", "analysis") == "阈值覆盖"
def test_a_well_formed_card_validates():
assert card_issues(card_graph(items=(WOW, WHERE, DRILL), overrides={"main_share": 0.6}), level=None) == []
@pytest.mark.parametrize("items, field, code, why", [
([], "items", "analysis.items_missing", "至少写一项"),
(["wow"], "items[0]", "analysis.item_invalid", "需要是一个对象"),
([{**WOW, "id": "1st"}], "items[0].id", "analysis.item_invalid", "字母、数字和下划线"),
([WOW, {**WHERE, "id": "wow"}], "items[1].id", "analysis.item_invalid", "唯一"),
([{**WOW, "op": "forecast"}], "items[0].op", "analysis.op_invalid", "不认识"),
([{**WHERE, "mode": None}], "items[0].mode", "analysis.mode_invalid", "要选一种模式"),
([{**WOW, "mode": "additive"}], "items[0].mode", "analysis.mode_invalid", "没有模式可选"),
([{**WOW, "from": None}], "items[0].from", "analysis.input_invalid", "还没有选择输入"),
([{**WOW, "from": "nope"}], "items[0].from", "analysis.input_invalid", "不存在"),
([{**WOW, "params": {**WOW["params"], "k_warn": 3}}], "items[0].params.k_warn", "analysis.params_invalid",
"阈值覆盖"),
([{**WOW, "params": {**WOW["params"], "query": "weekly"}}], "items[0].params.query", "analysis.params_invalid",
"输入里"),
([{**WOW, "params": {**WOW["params"], "bogus": 1}}], "items[0].params.bogus", "analysis.params_invalid",
"没有「bogus」"),
# 运行当天、目录事实、下钻层数由系统填(算子自己记),分析项里写了不收
([{**WOW, "params": {**WOW["params"], "today": "2026-10-21"}}], "items[0].params.today",
"analysis.params_invalid", "没有「today」"),
([{**WHERE, "params": {**WHERE["params"], "headline": {"base": "weekly.gmv", "target": "weekly.r1.gmv"}}}],
"items[0].params.headline.base", "analysis.params_invalid", "节点 id.r行号.列名"),
([{**WHERE, "params": {**WHERE["params"], "headline": "weekly.r0.gmv"}}], "items[0].params.headline",
"analysis.params_invalid", "两格"),
([DRILL, WHERE], "items[0].params.parent", "analysis.parent_invalid", "排在它前面"),
([WOW, {**DRILL, "params": {**DRILL["params"], "parent": {"of": "wow", "key": ["华东"]}}}],
"items[1].params.parent", "analysis.parent_invalid", "不是变化拆解"),
([WHERE, {**DRILL, "params": {**DRILL["params"], "parent": {"of": "where"}}}], "items[1].params.parent",
"analysis.parent_invalid", "写清是哪一项里的哪一段"),
([WHERE, {**WOW, "params": {**WOW["params"], "parent": {"of": "where", "key": ["华东"]}}}],
"items[1].params.parent", "analysis.params_invalid", "没有「parent」"),
])
def test_item_problems(items, field, code, why):
found = [i for i in card_issues(card_graph(items=items)) if i.field == field]
assert found and found[0].code == code and why in found[0].message, [i.message for i in card_issues(
card_graph(items=items))]
@pytest.mark.parametrize("extra, why", [
(node("think", "llm", prompt="写点什么"), "不产出查询结果"),
(node("asker", "agent", prompt="查一下", tools=[TOOL]), "Agent 可能查询多次"),
])
def test_inputs_must_be_query_nodes(extra, why):
graph = card_graph(items=[{**WOW, "from": extra["id"]}], extra_nodes=[extra],
extra_edges=[edge("by_region", extra["id"]), edge(extra["id"], "card")])
[issue] = card_issues(graph)
assert issue.field == "items[0].from" and why in issue.message and issue.code == "analysis.input_invalid"
def test_inputs_must_be_upstream_and_other_tools_only_warn():
lonely = node("lonely", "tool", tool=TOOL, args={"sql": WEEKLY})
[issue] = card_issues(card_graph(items=[{**WOW, "from": "lonely"}], extra_nodes=[lonely],
extra_edges=[edge("start", "lonely")]))
assert "不在分析卡的上游" in issue.message
# headline 那两格落在的节点同样要是上游的查询节点
headline = {**WHERE["params"], "headline": {"base": "lonely.r0.gmv", "target": "weekly.r1.gmv"}}
[issue] = card_issues(card_graph(items=[{**WHERE, "params": headline}], extra_nodes=[lonely],
extra_edges=[edge("start", "lonely")]))
assert issue.field == "items[0].params.headline.base" and "不在分析卡的上游" in issue.message
# 调用别的工具的调用工具节点:登记里声明了表格的端点读取也交回快照,静态看不出,只提醒
other = node("ext", "tool", tool="crm_report", args={})
graph = card_graph(items=[{**WOW, "from": "ext"}], extra_nodes=[other],
extra_edges=[edge("by_region", "ext"), edge("ext", "card")])
assert card_issues(graph) == []
[warning] = card_issues(graph, level="warning")
assert warning.code == "analysis.input_invalid" and "不是数据库查询工具" in warning.message
@pytest.mark.parametrize("overrides, why", [
({"k_warn": 3.0, "k_alert": 2.5}, "要大于"),
({"main_share": 2}, "之间"),
({"bogus": 1}, "不认识的分析设置"),
("tight", "需要是一个对象"),
])
def test_overrides_are_checked_by_the_contract(overrides, why):
[issue] = card_issues(card_graph(overrides=overrides))
assert issue.field == "overrides" and issue.code == "analysis.overrides_invalid" and why in issue.message
def test_two_snapshot_items_need_both_nodes():
two = {"id": "same", "op": "comparable", "params": {"base_query": "weekly", "target_query": "by_region"}}
assert card_issues(card_graph(items=[two])) == []
half = {"id": "same", "op": "comparable", "params": {"base_query": "weekly"}}
[issue] = card_issues(card_graph(items=[half]))
assert issue.field == "items[0].from" and "两个查询节点" in issue.message
# --------------------------------------------------------------------------
# 执行
# --------------------------------------------------------------------------
async def test_the_card_runs_each_item_and_records_it(monkeypatch, shop):
ops = FakeOps(monkeypatch, verdicts=["outside_band", "driver", "driver"], peek=["wow", "where", "east"])
row = await run_graph(card_graph(items=(WOW, WHERE, DRILL), overrides={"main_share": 0.6}),
options={contract.SETTINGS_KEY: {"k_warn": 2.5}})
assert row.status == "succeeded", row.error
# 算子拿到的:请求、按上游节点 id 解析的快照(只认台账里的那几份)、阈值(缺省 ← 发起时的快照 ← 卡上的覆盖)
wow, where, east = ops.calls
assert wow["request"] == {"op": "baseline", "mode": None,
"params": {"period": "week", "measure": "gmv", "label": "销售额", "query": "weekly"}}
assert where["request"]["hypothesis"] == "下降是不是集中在某个地区" and where["request"]["mode"] == "additive"
assert where["request"]["params"]["headline"] == {"base": "weekly.r0.gmv", "target": "weekly.r1.gmv"}
weekly = wow["snapshots"]["weekly"]
[tool_end] = await events(row.id, "tool.end", "weekly")
assert weekly["ref"] == "weekly" and weekly["node_id"] == "weekly" and weekly["artifact"] == tool_end["query_artifact"]
assert weekly["columns"] == ["week", "gmv"] and weekly["rows"] == [["2026-W41", 2120.0], ["2026-W42", 1926.0]]
assert weekly["sql"] == WEEKLY and weekly["source"] == "shop" and weekly["masked"] == []
assert wow["settings"] == {**contract.DEFAULTS, "k_warn": 2.5, "main_share": 0.6}
# 下钻取到同卡里前一项的结论;只看得到排在前面、已经做完的项
assert east["parent"]["op"] == "decompose" and east["parent"]["params"]["query"] == "by_region"
assert east["visible"] == {"wow": True, "where": True, "east": False}
assert wow["visible"] == {"wow": False, "where": False, "east": False}
finished, output = await output_of(row.id, "card")
results = output["results"]
assert set(results) == {"wow", "where", "east"} # 产出工件按规范 JSON 存,键的先后看配置里项的先后
assert results["wow"]["verdict"] == "outside_band" and results["wow"]["status"] == "ok"
assert results["wow"]["phrase"].startswith("销售额本期变化") and results["wow"]["direction"] == "down"
assert results["where"]["values"]["top_share"] == "0.6186"
# 台账:每项一条 kind analysis(带 item),工件就是整份结论
entries = finished["evidence"]
assert [(e["kind"], e["item"]) for e in entries] == [("analysis", "wow"), ("analysis", "where"),
("analysis", "east")]
for entry, item in zip(entries, ("wow", "where", "east")):
stored = artifact_store.load(entry["artifact"])
assert entry == contract.ledger_entry(stored, node_id="card", exec_no=1, artifact=entry["artifact"],
item=item)
assert results[item]["artifact"] == entry["artifact"]
# 每项一条 analysis.end
ends = await events(row.id, "analysis.end", "card")
assert [(e["item"], e["op"], e["verdict"], e["analysis_artifact"]) for e in ends] == [
(e["item"], {"wow": "baseline"}.get(e["item"], "decompose"), results[e["item"]]["verdict"],
results[e["item"]]["artifact"]) for e in ends]
# 成果字段取判定语
assert row.output["判定"] == results["wow"]["phrase"]
async def test_a_refused_item_does_not_fail_the_card(monkeypatch, shop):
FakeOps(monkeypatch, statuses=["refused", "ok"])
row = await run_graph(card_graph())
assert row.status == "succeeded", row.error
_, output = await output_of(row.id, "card")
assert output["results"]["wow"]["status"] == "refused" and output["results"]["wow"]["verdict"] is None
assert output["results"]["wow"]["phrase"].endswith("历史只有 5 期,至少要 8 期")
assert output["results"]["where"]["status"] == "ok"
[warn] = [e for e in await events(row.id, "log", "card") if e.get("code") == "analysis_refused"]
assert warn["item"] == "wow" and warn["reason_code"] == "history_short" and "wow" in warn["message"]
async def test_a_call_error_fails_the_card_and_names_the_item(monkeypatch, shop):
FakeOps(monkeypatch, errors=[None, CallError("ratio_as_additive", "比率不能按段相加,改用 ratio 模式,给分子、分母两列")])
row = await run_graph(card_graph())
assert row.status == "failed"
assert "分析项「where」(变化拆解)" in row.error and "比率不能按段相加" in row.error and "改正这一项" in row.error
async def test_a_branch_can_follow_the_verdict(monkeypatch, shop):
FakeOps(monkeypatch, verdicts=["outside_band", "driver"])
graph = card_graph(extra_nodes=[
node("route", "branch", cases=[{"key": "alert", "condition": "nodes.card.results.wow.verdict == 'outside_band'"}]),
node("alert", "output", fields=[{"name": "转人工", "value": "{{ nodes.card.results.wow.phrase }}"}])])
graph["edges"] = [e for e in graph["edges"] if e["target"] != "out"] + [
edge("card", "route"), edge("route", "alert", "alert"), edge("route", "out", "default")]
row = await run_graph(graph)
assert row.status == "succeeded", row.error
assert "转人工" in row.output
async def test_inputs_that_are_not_query_snapshots_fail_clearly(monkeypatch, shop):
FakeOps(monkeypatch)
other = node("calc", "tool", tool="json_query", args={"data": '{"a": 1}', "path": "a"})
graph = card_graph(items=[{**WOW, "from": "calc"}], extra_nodes=[other],
extra_edges=[edge("by_region", "calc"), edge("calc", "card")])
row = await run_graph(graph)
assert row.status == "failed" and "不是存成快照的查询结果" in row.error
async def test_masked_columns_reach_the_operator(monkeypatch, tmp_path):
source_id = await _shop(tmp_path, {"mask_columns": ["region"]})
try:
ops = FakeOps(monkeypatch)
row = await run_graph(card_graph(items=[WHERE]))
assert row.status == "succeeded", row.error
assert "region" in ops.calls[0]["snapshots"]["by_region"]["masked"]
finally:
await engines.invalidate(source_id)
# --------------------------------------------------------------------------
# 受管规则、登记处
# --------------------------------------------------------------------------
def _governed(graph):
from app.engine.governance import lint_for_publish
return lint_for_publish(GraphSpec.model_validate(graph), level="governed").issues
def test_governed_cards_must_analyze_platform_queries():
other = node("ext", "tool", tool="crm_report", args={})
graph = card_graph(items=[{**WOW, "from": "ext"}], extra_nodes=[other],
extra_edges=[edge("by_region", "ext"), edge("ext", "card")])
[issue] = [i for i in _governed(graph) if i.code == "governed.analysis_input"]
assert issue.level == "error" and issue.node_id == "card" and issue.field == "items[0].from"
assert [i for i in _governed(card_graph()) if i.code == "governed.analysis_input"] == []
# 发布时:受管级按错误挡、画布那条同义的提醒不再列;已发布级只有画布那条提醒
from app.engine.governance import publish_issues
spec = GraphSpec.model_validate(graph)
governed = [(i.code, i.level) for i in publish_issues(spec, level="governed") if i.node_id == "card"]
assert ("governed.analysis_input", "error") in governed and ("analysis.input_invalid", "warning") not in governed
published = [(i.code, i.level) for i in publish_issues(spec, level="published") if i.node_id == "card"]
assert published == [("analysis.input_invalid", "warning")]
def test_a_caliber_card_reading_analysis_results_gets_a_hint():
from app.engine.governance import _traceable
graph = card_graph(extra_nodes=[node("kpi", "metrics", metrics=[
{"id": "share", "expression": "nodes.card.results.where.values.top_share"}])],
extra_edges=[edge("card", "kpi")])
[issue] = [i for i in _governed(graph) if i.code == "governed.caliber_analysis_input"]
assert issue.level == "warning" and issue.node_id == "card" and "追不到原始数据" in issue.message
assert _traceable(GraphSpec.model_validate(graph).node_map()["card"])
def test_plain_wording_for_the_new_codes():
from app.plain import plain_of
for code in ("governed.analysis_input", "governed.caliber_analysis_input"):
assert plain_of(code) is not None, code
async def test_a_caliber_card_reading_an_analysis_card_is_untraced(monkeypatch, shop):
from app.engine.lineage import untraced
FakeOps(monkeypatch)
graph = card_graph(extra_nodes=[node("kpi", "metrics", metrics=[
{"id": "share", "expression": "float(nodes.card.results.where.values.top_share)"}])],
extra_edges=[edge("card", "kpi")])
row = await run_graph(graph)
assert row.status == "succeeded", row.error
_, card = await output_of(row.id, "kpi")
[item] = card["metrics"][0]["inputs"]
assert item["via"] == "analysis" and item["node_id"] == "card" and untraced([item]) == [item]
def test_variables_list_each_item_and_count_the_inputs_as_references():
from app.engine.variables import analyze
report = analyze(GraphSpec.model_validate(card_graph(items=(WOW, WHERE))))
found = {v.path: v for v in report.variables}
assert {"nodes.card.results.wow", "nodes.card.results.where"} <= set(found)
assert found["nodes.card.results.wow"].produced_by == "card"
assert any(r.node_id == "card" and r.field == "items[0].from" for r in found["nodes.weekly"].refs)
assert any(r.node_id == "card" and r.field == "items[1].params.headline.base" for r in found["nodes.weekly"].refs)
assert not [i for i in report.issues if i.level != "info"]
def test_describe_impact_and_upgrade_know_the_card():
from types import SimpleNamespace
from app.apps.describe import describe_version
from app.data.catalog_impact import template_refs
from app.engine.upgrade import _EVIDENCE_TYPES
graph = card_graph(items=(WOW, WHERE))
text = describe_version(SimpleNamespace(graph=graph, version=2, graph_hash="ab" * 32, level="published"),
workflow_name="区域周报")
assert "分析卡「card」(card),分析项 2 个" in text and "card.results.where 变化拆解" in text
refs = template_refs(GraphSpec.model_validate(graph), "shop", "orders", None)
[card] = [r for r in refs if r["node_id"] == "card"]
assert card["type"] == "analysis" and card["impact"] == "direct"
assert {(v["node_id"], v["alias"]) for v in card["via"]} == {("weekly", "wow"), ("by_region", "where"),
("weekly", "where")}
assert NodeType.ANALYSIS in _EVIDENCE_TYPES
+42
View File
@@ -0,0 +1,42 @@
"""运行对比里的分析卡(engine/compare.py 的 analyses):两次运行的分析卡按节点和项的标识对齐,列出两次的判定和判定语
(最小可用:不拆变化来源)。运行照 tests/fixtures/compare_runs.py 的虚构周报手写,另加分析卡每项一条 analysis.end。
"""
from __future__ import annotations
from app.engine import compare
from tests.fixtures.compare_runs import week
def _with_card(run, items, *, runs=1):
for _ in range(runs):
run.add("node.started", "insight", {"node_type": "analysis"})
for item, verdict, phrase in items:
run.add("analysis.end", "insight", {"item": item, "op": "baseline", "mode": None, "status": "ok",
"verdict": verdict, "direction": "down", "phrase": phrase,
"analysis_artifact": f"{item}-{verdict}-{phrase}"})
run.add("node.finished", "insight", {"duration_ms": 1, "attempt": 1})
return run
def test_compare_aligns_analysis_items():
base = _with_card(week("r1"), [("wow", "within_band", "在常规波动范围内"), ("trend", "rising", "呈上升趋势"),
("gone", "no_trend", "没有明显趋势")])
target = _with_card(week("r2"), [("wow", "outside_band", "超出常规波动范围"), ("trend", "rising", "呈上升趋势"),
("new", "rising", "呈上升趋势")], runs=2)
result = compare.compare(base.side(), target.side())
found = {a["item"]: a for a in result["analyses"]}
assert [a["item"] for a in result["analyses"]] == ["wow", "trend", "new", "gone"]
assert found["wow"]["change"] == "verdict" and found["wow"]["base"]["verdict"] == "within_band"
assert found["wow"]["target"] == {"status": "ok", "verdict": "outside_band", "direction": "down",
"phrase": "超出常规波动范围", "artifact": "wow-outside_band-超出常规波动范围"}
assert found["trend"]["change"] == "same" and found["new"]["change"] == "added" and found["new"]["base"] is None
assert found["gone"]["change"] == "removed" and found["gone"]["target"] is None
assert found["wow"]["title"] == "insight" and found["wow"]["op"] == "baseline"
assert "分析卡「insight」在这次运行中执行了 2 次,按最后一次对比" in result["notes"]
# 判定一样、判定语里的数变了
target = _with_card(week("r3"), [("wow", "within_band", "在常规波动范围内(-3%)")])
[wow] = compare.compare(_with_card(week("r4"), [("wow", "within_band", "在常规波动范围内")]).side(),
target.side())["analyses"]
assert wow["change"] == "phrase"
# 没有分析卡的两次运行:analyses 是空的
assert compare.compare(week("r5").side(), week("r6").side())["analyses"] == []
+314
View File
@@ -0,0 +1,314 @@
"""分析入口接真算子跑一遍(不用替身):agent 分析工具(含下钻)、分析卡(波动基线 + 变化拆解)、口径卡的可比性挂钩、
审批恢复时的重放。断言工件、台账、交回模型的那一段、节点产出,并用 analysis.recompute 按封存的工件复算、逐字段相同。
虚构零售库 shop:orders(day, region, channel, amount),2026-03-02 起 12 个整周,每周每个地区、渠道两笔(周三、周日),
最后一周华东掉三成。模型换成脚本(MockChatModel._decide),不调用任何模型。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
from datetime import date, timedelta
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from sqlalchemy import select
import app.analysis as analysis_ops
from app.analysis import contract
from app.core import artifact_store
from app.core.artifact_store import canonical_json, content_hash
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.runner import run_manager
TOOL = "db_query__shop"
DECOMPOSE = contract.TOOL_NAMES["decompose"]
WEEK = "date(day, '-6 days', 'weekday 1')"
BY_REGION = (f"SELECT region, {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE day >= '2026-05-11' "
"GROUP BY region, week ORDER BY region, week")
TOTALS = f"SELECT {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE day >= '2026-05-11' GROUP BY week ORDER BY week"
EAST = (f"SELECT channel, {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE region = '华东' AND day >= '2026-05-11' "
"GROUP BY channel, week ORDER BY channel, week")
WEEKLY = f"SELECT {WEEK} AS week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week"
TWO_WEEKS = {"period": "week", "base": "2026-05-11", "target": "2026-05-18", "measure": "gmv"}
A1_ARGS = {"mode": "additive", "query": "Q1", "by": ["region"], **TWO_WEEKS, "label": "销售额",
"headline": {"base": "Q2.r0.gmv", "target": "Q2.r1.gmv"}, "hypothesis": "下降是不是集中在某个地区"}
A2_ARGS = {"mode": "additive", "query": "Q3", "by": ["channel"], **TWO_WEEKS, "parent": {"of": "A1", "key": ["华东"]},
"hypothesis": "华东是哪个渠道掉的"}
def _orders():
out, i = [], 0
base = {"华东": 400, "华南": 300, "华北": 250, "西南": 50}
for w in range(12):
monday = date(2026, 3, 2) + timedelta(weeks=w)
for region, amount in base.items():
for channel, share in (("线上", 3), ("线下", 2)):
value = amount * share // 5 + (w % 3) * 2
if w == 11 and region == "华东":
value = value * 7 // 10
for offset in (2, 6):
i += 1
out.append((i, (monday + timedelta(days=offset)).isoformat(), region, channel, value))
return out
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture(autouse=True)
async def shop(tmp_path):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, day TEXT, region TEXT, channel TEXT, amount INTEGER);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", _orders())
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
if row is None:
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
class Script:
"""Agent 依次发 calls 里的工具调用,再给结论;seen 记下每次带工具的调用看到的消息。"""
def __init__(self, monkeypatch, calls):
from app.providers import mock_model
self.seen: list[list] = []
def decide(model, messages):
if not model.tools:
return AIMessage(content="收尾")
self.seen.append(list(messages))
done = sum(isinstance(m, ToolMessage) for m in messages)
if done < len(calls):
name, args = calls[done]
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"call_{done}"}])
return AIMessage(content="销售额下降主要来自华东。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
def replies(self):
return [m.content for m in self.seen[-1] if isinstance(m, ToolMessage)]
class Counting:
"""真算子外面包一层只数次数(重放时不该再算)。"""
def __init__(self, monkeypatch):
self.calls = 0
real = analysis_ops.run
async def run(*args, **kwargs):
self.calls += 1
return await real(*args, **kwargs)
monkeypatch.setattr(analysis_ops, "run", run)
async def wait(run_id, statuses=("succeeded", "failed")):
for _ in range(600):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
return row
raise AssertionError(f"run {run_id} 没有结束:{row.status}")
async def events(run_id, etype=None, node_id=None):
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id)
if etype:
q = q.where(RunEvent.type == etype)
if node_id:
q = q.where(RunEvent.node_id == node_id)
return [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()]
def agent_graph(approval="never"):
return chain(node("start", "input"),
node("bot", "agent", prompt="销售额为什么下降", tools=[TOOL, DECOMPOSE], approval=approval),
node("out", "output", fields=[{"name": "answer", "value": "{{ nodes.bot.text }}"}]))
def recomputes(stored):
"""按封存的工件复算(查询快照、上一层结论都按 sha256 取回),和封存的逐字段相同。"""
again = analysis_ops.recompute(stored, artifact_store.load, load_result=artifact_store.load)
assert contract.same(stored, again), {k: (stored.get(k), again.get(k)) for k in stored if stored.get(k) != again.get(k)}
AGENT_CALLS = [(TOOL, {"sql": BY_REGION}), (TOOL, {"sql": TOTALS}), (TOOL, {"sql": EAST}),
(DECOMPOSE, A1_ARGS), (DECOMPOSE, A2_ARGS)]
# --------------------------------------------------------------------------
# agent 分析工具:拆解、下钻
# --------------------------------------------------------------------------
async def test_an_agent_decomposes_and_drills_down_with_the_real_operator(monkeypatch):
script = Script(monkeypatch, AGENT_CALLS)
run = await run_manager.start(graph=agent_graph(), input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
ends = await events(row.id, "tool.end", "bot")
q1, q2, q3 = (ends[i]["query_artifact"] for i in range(3))
a1_artifact, a2_artifact = ends[3]["analysis_artifact"], ends[4]["analysis_artifact"]
a1, a2 = artifact_store.load(a1_artifact), artifact_store.load(a2_artifact)
# 结论:华东是主要来源,下钻到渠道是线上;总数闭合(headline 是 Q2 的两格)
assert a1["status"] == "ok" and a1["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"}
assert "主要来自华东" in a1["phrase"] and a1["hypothesis"] == "下降是不是集中在某个地区"
assert {i["ref"] for i in a1["inputs"]} >= {"Q1", "Q2.r0.gmv", "Q2.r1.gmv"}
assert a2["verdict"]["code"] == "driver" and a2["verdict"]["subjects"] == [["线上"]]
# 下钻:算子按上一层结论的内容哈希认 parent,就是存下的那份工件
[parent] = [i for i in a2["inputs"] if i.get("role") == "parent"]
assert parent == {"ref": "A1", "artifact": a1_artifact, "role": "parent"}
assert content_hash(canonical_json(a1)) == a1_artifact
# 台账:两条 kind analysis,带 call_id,输入是那几份查询快照(下钻的另有上一层结论)
[finished] = await events(row.id, "node.finished", "bot")
analyses = [e for e in finished["evidence"] if e["kind"] == "analysis"]
assert [(e["call_id"], e["artifact"]) for e in analyses] == [("call_3", a1_artifact), ("call_4", a2_artifact)]
assert set(analyses[0]["inputs"]) == {q1, q2} and set(analyses[1]["inputs"]) == {q3, a1_artifact}
assert analyses[0] == contract.ledger_entry(a1, node_id="bot", exec_no=1, artifact=a1_artifact, call_id="call_3")
# 交回模型的:编号、判定语、关键数
replies = script.replies()
assert [r.split("\n", 1)[0] for r in replies] == ["【证据 Q1】", "【证据 Q2】", "【证据 Q3】", "【分析 A1】", "【分析 A2】"]
body = json.loads(replies[3].split("\n", 1)[1])
assert body["phrase"] == a1["phrase"] and body["verdict"] == "driver" and body["values"] == a1["values"]
assert body["table"]["rows"][0][0] == "华东"
# 按封存的工件复算,逐字段相同
recomputes(a1)
recomputes(a2)
async def test_replaying_after_approvals_does_not_recompute(monkeypatch):
counted = Counting(monkeypatch)
script = Script(monkeypatch, AGENT_CALLS)
run = await run_manager.start(graph=agent_graph(approval="always"), input_payload={})
for _ in AGENT_CALLS:
row = await wait(run.id, ("interrupted", "failed", "succeeded"))
assert row.status == "interrupted", row.error
await run_manager.resume(run.id, {"approved": True})
row = await wait(run.id, ("succeeded", "failed", "interrupted"))
assert row.status == "succeeded", row.error
assert counted.calls == 2, "重放时分析又算了一次"
ends = await events(row.id, "tool.end", "bot")
assert len(ends) == len(AGENT_CALLS)
a1_artifact, a2_artifact = ends[3]["analysis_artifact"], ends[4]["analysis_artifact"]
assert [r.split("\n", 1)[0] for r in script.replies()][-2:] == ["【分析 A1】", "【分析 A2】"]
[parent] = [i for i in artifact_store.load(a2_artifact)["inputs"] if i.get("role") == "parent"]
assert parent["artifact"] == a1_artifact
[finished] = await events(row.id, "node.finished", "bot")
assert [e["artifact"] for e in finished["evidence"] if e["kind"] == "analysis"] == [a1_artifact, a2_artifact]
# --------------------------------------------------------------------------
# 分析卡:波动基线 + 变化拆解
# --------------------------------------------------------------------------
async def test_an_analysis_card_runs_the_real_operators():
items = [
{"id": "wow", "op": "baseline", "from": "weekly", "params": {"period": "week", "measure": "gmv", "label": "销售额"}},
{"id": "where", "op": "decompose", "mode": "additive", "from": "by_region",
"params": {"by": ["region"], **TWO_WEEKS, "label": "销售额",
"headline": {"base": "weekly.r10.gmv", "target": "weekly.r11.gmv"}}},
]
graph = chain(node("start", "input"), node("weekly", "tool", tool=TOOL, args={"sql": WEEKLY}),
node("by_region", "tool", tool=TOOL, args={"sql": BY_REGION}),
node("card", "analysis", items=items),
node("out", "output", fields=[{"name": "判定", "value": "{{ nodes.card.results.wow.phrase }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
[finished] = await events(row.id, "node.finished", "card")
results = artifact_store.load(finished["artifact"])["results"]
wow, where = results["wow"], results["where"]
assert wow["status"] == "ok" and wow["verdict"] in ("outside_band", "far_outside_band") and wow["direction"] == "down"
assert where["status"] == "ok" and where["verdict"] == "driver" and "主要来自华东" in where["phrase"]
assert row.output["判定"] == wow["phrase"]
baseline = artifact_store.load(wow["artifact"])
# 本期完不完整按现查的表范围判(analysis.make_prober 按这次运行的数据源探 orders.day):探到了,不再说核对不了
[probe] = baseline["probes"]
assert (probe["source"], probe["table"], probe["column"], probe["basis"]) == ("shop", "orders", "day", "live")
assert probe["max"] == "2026-05-24"
assert not [c for c in baseline["caveats"] if c["code"] == "comparability"]
decomposed = artifact_store.load(where["artifact"])
assert decomposed["verdict"]["subjects"] == [["华东"]]
entries = finished["evidence"]
assert [(e["kind"], e["item"], e["artifact"]) for e in entries] == [
("analysis", "wow", wow["artifact"]), ("analysis", "where", where["artifact"])]
recomputes(baseline)
recomputes(decomposed)
# --------------------------------------------------------------------------
# 口径卡的可比性挂钩:真 comparable
# --------------------------------------------------------------------------
def _week_sql(start: str, end: str) -> str:
return f"SELECT SUM(amount) AS gmv FROM orders WHERE day BETWEEN '{start}' AND '{end}'"
@pytest.mark.parametrize("this_week, verdict, has_reasons", [
(("2026-05-18", "2026-05-24"), "comparable", False),
(("2026-05-18", "2026-05-23"), "conditional", True), # 本期 6 天对上期 7 天:窗口长度、周末天数都不一样
])
async def test_a_caliber_card_checks_two_periods_with_the_real_operator(this_week, verdict, has_reasons):
metrics = [{"id": "wow", "name": "销售额环比", "decimals": 4,
"expression": "(cell(nodes.this_week, 0, 'gmv') - cell(nodes.last_week, 0, 'gmv'))"
" / cell(nodes.last_week, 0, 'gmv')"}]
graph = chain(node("start", "input"), node("this_week", "tool", tool=TOOL, args={"sql": _week_sql(*this_week)}),
node("last_week", "tool", tool=TOOL, args={"sql": _week_sql("2026-05-11", "2026-05-17")}),
node("card", "metrics", caliber="周报口径", metrics=metrics),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
[finished] = await events(row.id, "node.finished", "card")
[metric] = artifact_store.load(finished["artifact"])["metrics"]
found = metric["comparability"]
stored = artifact_store.load(found["artifact"])
assert stored["op"] == "comparable" and stored["params"]["base_query"] == "last_week"
assert stored["params"]["target_query"] == "this_week"
assert found == {"artifact": found["artifact"], "verdict": verdict, "phrase": stored["phrase"],
"reasons": analysis_ops.comparable_reasons(stored)}
assert bool(found["reasons"]) is has_reasons
# 整期按现查的表范围判
assert any(p.get("table") == "orders" and p.get("basis") == "live" for p in stored["probes"])
recomputes(stored)
assert not [e for e in await events(row.id, "log", "card") if e.get("code") == "metric_comparability_skipped"]
+179
View File
@@ -0,0 +1,179 @@
"""分析设置(设置 → 偏好设置 → 分析,docs/design/analysis-round/01-算子与判定.md §10)和发起时的快照。
- 设置接口的 analysis 一组:键、缺省、范围、叫法全取 app/analysis/contract.py;GET 按生效的样子给(存坏了的项按缺省),
PUT 先查后写(不认识的键、类型、范围、两两之间的大小)
- 发起运行时快照进 run.options["analysis"]:只在图(含子工作流)里有分析卡、或 Agent / 协作成员绑了分析工具时写;
别的图的 run.started 一字不变
"""
from __future__ import annotations
import asyncio
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.analysis import contract
from app.api.settings import ANALYSIS_GROUP, analysis_problem, analysis_values
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent, Setting, Workflow
from app.engine.launch import launch_run
from app.engine.runner import run_manager
from app.main import app
#: 加 options 之前 run.started 就有的键(同 tests/test_run_options.py)
LEGACY_STARTED_KEYS = {"nodes", "resumed", "memory_scope", "collection", "approval_default", "tool_trust",
"agent_limits", "data_versions", "replay_protocol"}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture(autouse=True)
async def clean_group():
yield
async with SessionLocal() as session:
row = await session.get(Setting, ANALYSIS_GROUP)
if row is not None:
await session.delete(row)
await session.commit()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
# --------------------------------------------------------------------------
# 设置接口
# --------------------------------------------------------------------------
def test_the_group_is_the_contract():
assert ANALYSIS_GROUP == contract.SETTINGS_KEY
assert analysis_values(None) == contract.DEFAULTS
# 存坏了的项按缺省;两两之间的大小对不上的那一对都按缺省
broken = {"main_share": "一半", "max_calls": 3.5, "k_warn": 4, "k_alert": 3.5, "min_history": 20, "max_history": 10,
"table_top_k": 5, "trend_alpha": True}
assert analysis_values(broken) == {**contract.DEFAULTS, "table_top_k": 5}
# 浮点的项存成浮点:发起时的快照、结论里记的阈值一个写法
assert analysis_values({"k_warn": 3, "k_alert": 4})["k_warn"] == 3.0
assert isinstance(analysis_values({"k_warn": 3, "k_alert": 4})["k_alert"], float)
@pytest.mark.parametrize("value, why", [
("宽松", "需要是一个对象"),
({"bogus": 1}, "不支持「bogus」"),
({"main_share": 0.95}, f"{contract.LABELS['main_share']}要在 0.3 到 0.9 之间"),
({"max_calls": 2.5}, f"{contract.LABELS['max_calls']}要填整数"),
({"k_warn": 3.5}, f"{contract.LABELS['k_alert']}要大于{contract.LABELS['k_warn']}"),
({"min_history": 60}, f"{contract.LABELS['max_history']}不能小于{contract.LABELS['min_history']}"),
])
def test_problems_are_said_with_labels(value, why):
assert why in (analysis_problem(value) or "")
async def test_get_put_round_trip(client):
got = (await client.get("/api/settings")).json()
assert got[ANALYSIS_GROUP] == contract.DEFAULTS
put = await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"main_share": 0.6, "max_calls": 5}}})
assert put.status_code == 200, put.text
got = (await client.get("/api/settings")).json()[ANALYSIS_GROUP]
assert got == {**contract.DEFAULTS, "main_share": 0.6, "max_calls": 5}
# 整组替换:这次没写的项回到缺省
await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"k_warn": 2.5}}})
assert (await client.get("/api/settings")).json()[ANALYSIS_GROUP] == {**contract.DEFAULTS, "k_warn": 2.5}
async def test_put_refuses_bad_values_and_stores_nothing(client):
r = await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"main_share": 0.6, "k_alert": 1.5}}})
assert r.status_code == 422 and contract.LABELS["k_alert"] in r.json()["detail"]
assert (await client.get("/api/settings")).json()[ANALYSIS_GROUP] == contract.DEFAULTS
# --------------------------------------------------------------------------
# 发起时的快照
# --------------------------------------------------------------------------
async def _started(run_id: str) -> dict:
for _ in range(200):
async with SessionLocal() as session:
found = (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "run.started"))).scalars().first()
if found is not None:
await run_manager.wait_idle(run_id)
return found.data
await asyncio.sleep(0.05)
raise AssertionError("没有 run.started")
async def _launch(graph, **kw) -> tuple[Run, dict]:
async with SessionLocal() as session:
run = await launch_run(session, graph=graph, actor="测试", **kw)
return run, await _started(run.id)
CARD = node("card", "analysis", items=[{"id": "wow", "op": "baseline", "from": "q",
"params": {"period": "week", "measure": "gmv"}}])
QUERY = node("q", "tool", tool="calculator", args={"expression": "1 + 1"})
OUT = node("out", "output", fields=[{"name": "r", "value": "完成"}])
async def test_graphs_without_analysis_start_exactly_as_before():
run, started = await _launch(chain(node("start", "input"), node("t", "transform", mode="template", template="x"),
OUT))
assert set(started) == LEGACY_STARTED_KEYS, "没有分析的图,run.started 一个键都不多"
async with SessionLocal() as session:
assert (await session.get(Run, run.id)).options in (None, {})
# 绑了别的工具的 Agent:有能力治理的快照(以前就有),没有分析这一组
_, started = await _launch(chain(node("start", "input"),
node("bot", "agent", prompt="说一句", tools=["calculator"]), OUT))
assert ANALYSIS_GROUP not in (started.get("options") or {})
async def test_an_analysis_card_snapshots_the_settings():
async with SessionLocal() as session:
session.add(Setting(key=ANALYSIS_GROUP, value={"main_share": 0.6}))
await session.commit()
run, started = await _launch(chain(node("start", "input"), QUERY, CARD, OUT))
assert started["options"][ANALYSIS_GROUP] == {**contract.DEFAULTS, "main_share": 0.6}
async with SessionLocal() as session:
assert (await session.get(Run, run.id)).options[ANALYSIS_GROUP]["main_share"] == 0.6
@pytest.mark.parametrize("graph", [
chain(node("start", "input"), node("bot", "agent", prompt="为什么", tools=[contract.TOOL_NAMES["trend"]]), OUT),
chain(node("start", "input"), node("team", "supervisor", goal="为什么", agents=[
{"name": "分析员", "tools": [contract.TOOL_NAMES["differ"]]}]), OUT),
])
async def test_agents_and_members_with_analysis_tools_snapshot_the_settings(graph):
_, started = await _launch(graph)
assert started["options"][ANALYSIS_GROUP] == contract.DEFAULTS
async def test_a_card_inside_a_subgraph_counts_and_given_options_win():
async with SessionLocal() as session:
inner = Workflow(name="异动分析(子流程)", graph=chain(node("start", "input"), QUERY, CARD, OUT))
session.add(inner)
await session.commit()
inner_id = inner.id
outer = chain(node("start", "input"), node("sub", "subgraph", workflow_id=inner_id), OUT)
_, started = await _launch(outer)
assert started["options"][ANALYSIS_GROUP] == contract.DEFAULTS
# 调用方已经给了这一组的,照它的
_, started = await _launch(outer, options={ANALYSIS_GROUP: {"max_calls": 2}})
assert started["options"][ANALYSIS_GROUP] == {"max_calls": 2}
+464
View File
@@ -0,0 +1,464 @@
"""agent 分析工具(docs/design/analysis-round/00-总方案.md §3.2):参数、方法说明、查询容器、台账、编号、下钻、次数上限、
遮罩列、续跑。
算子(app.analysis.run)还没落地:换成按请求交回假结论的替身(tests/fixtures/analysis_ops.FakeOps),断言入口交给算子的
东西、算子交回后入口做的事。模型换成脚本(MockChatModel._decide),不调用任何模型。库是虚构的零售库 shop。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import pytest
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
from sqlalchemy import select
from app.analysis import contract
from app.analysis.contract import CallError
from app.core import artifact_store
from app.core.artifact_store import canonical_json, content_hash
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import Artifact, DataSource, Run, RunEvent
from app.engine.runner import run_manager
from app.tools.analysis import CALL_ERROR_HEAD, REFUSED_CODE
from app.tools.builtin.analysis import ANALYSIS_METHOD_PROMPT, ARGS, METHOD_GUIDE, NO_SCOPE
from tests.fixtures.analysis_ops import FakeOps
TOOL = "db_query__shop"
DECOMPOSE = contract.TOOL_NAMES["decompose"]
BASELINE = contract.TOOL_NAMES["baseline"]
SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week"
MASKED_SQL = "SELECT region, phone AS tel, SUM(amount) AS gmv FROM orders GROUP BY region, phone ORDER BY region"
ROWS = [(1, "华东", "2026-W41", 820.0, "139-0000-0001"), (2, "华东", "2026-W42", 700.0, "139-0000-0001"),
(3, "华南", "2026-W41", 700.0, "139-0000-0002"), (4, "华南", "2026-W42", 666.0, "139-0000-0002"),
(5, "华北", "2026-W41", 600.0, "139-0000-0003"), (6, "华北", "2026-W42", 560.0, "139-0000-0003")]
DECOMPOSE_ARGS = {"mode": "additive", "query": "Q1", "by": ["region"], "period": "week", "base": "2026-W41",
"target": "2026-W42", "measure": "gmv", "label": "销售额", "hypothesis": "下降是不是集中在某个地区"}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def _shop(tmp_path, options=None):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, week TEXT, amount REAL, phone TEXT);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", ROWS)
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
if row is None:
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, options or {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
return source_id
@pytest.fixture
async def shop(tmp_path):
source_id = await _shop(tmp_path)
yield
await engines.invalidate(source_id)
class Script:
"""Agent(和协作成员)依次发 calls 里的工具调用,再给结论;seen 记下每次带工具的调用看到的消息。"""
def __init__(self, monkeypatch, calls, *, final="销售额下降,主要来自华东。", route=None):
from app.providers import mock_model
self.seen: list[list] = []
self.plain: list[list] = []
routed = {"n": 0}
def decide(model, messages):
if model.response_format:
routed["n"] += 1
if route is not None and routed["n"] == 1:
return AIMessage(content=json.dumps(route, ensure_ascii=False))
return AIMessage(content=json.dumps({"assignments": [], "done": True, "reason": "完成"}))
if not model.tools:
self.plain.append(list(messages))
return AIMessage(content="收尾")
self.seen.append(list(messages))
done = sum(isinstance(m, ToolMessage) for m in messages)
if done < len(calls):
name, args = calls[done]
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"call_{done}"}])
return AIMessage(content=final)
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def agent_graph(tools=(TOOL, DECOMPOSE), approval="never", **cfg):
nodes = [node("start", "input"),
node("bot", "agent", prompt="销售额为什么下降", tools=list(tools), approval=approval, **cfg),
node("out", "output", fields=[{"name": "answer", "value": "{{ nodes.bot.text }}"}])]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def wait(run_id, statuses=("succeeded", "failed")):
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
return row
raise AssertionError(f"run {run_id} 没有结束:{row.status}")
async def events(run_id, etype=None, node_id=None):
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id)
if etype:
q = q.where(RunEvent.type == etype)
if node_id:
q = q.where(RunEvent.node_id == node_id)
return [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()]
async def run_graph(graph, **kw):
run = await run_manager.start(graph=graph, input_payload={}, **kw)
row = await wait(run.id)
assert row.status == "succeeded", row.error
return row
def text(message):
return message.content if isinstance(message.content, str) else json.dumps(message.content, ensure_ascii=False)
def tool_messages(script):
return [text(m) for m in script.seen[-1] if isinstance(m, ToolMessage)]
async def ledger(run_id, node_id="bot"):
[finished] = await events(run_id, "node.finished", node_id)
return finished["evidence"]
# --------------------------------------------------------------------------
# 工具定义:参数只有「算什么」,五个工具共用一份方法说明
# --------------------------------------------------------------------------
def test_the_five_tools_take_no_thresholds_and_share_one_method():
from app.capabilities.registry import is_platform_query
from app.tools.registry import all_specs
specs = all_specs()
for op, name in contract.TOOL_NAMES.items():
spec = specs[name]
props = spec.args_schema.model_json_schema()["properties"]
assert not set(props) & set(contract.DEFAULTS), f"{name} 的参数里有阈值:{set(props) & set(contract.DEFAULTS)}"
assert spec.args_schema is ARGS[op] and spec.category == "分析" and spec.description
assert spec.guide is METHOD_GUIDE and not spec.dangerous and not spec.writes_state
# 不查库:能力登记、出处链都不把它的结果当查询快照
assert not is_platform_query(name)
assert "query" in ARGS["decompose"].model_json_schema()["properties"]
assert {"mode", "by", "period", "base", "target", "measure", "numerator", "denominator", "product", "factors",
"headline", "parent", "label", "hypothesis"} <= set(ARGS["decompose"].model_fields)
assert {"basis", "grain", "lag"} <= set(ARGS["baseline"].model_fields)
# 参数名以算子为准(app/analysis 各算子文件头):差异检验的均值是 mean / sd / n 或 sum / sumsq / n,逐行是 value
assert {"reference", "pairs", "mode", "mean", "sd", "sum", "sumsq", "n", "value"} <= set(ARGS["differ"].model_fields)
assert {"base_query", "target_query", "measure", "grain"} <= set(ARGS["comparable"].model_fields)
# 运行当天、目录事实、下钻层数由系统填,哪个工具的参数里都没有
assert not any({"today", "catalog", "depth"} & set(model.model_fields) for model in ARGS.values())
def test_unknown_parameters_and_thresholds_are_rejected_like_any_tool():
from app.tools.registry import ToolArgsError, prepare_args
with pytest.raises(ToolArgsError) as found:
prepare_args(ARGS["decompose"], {**DECOMPOSE_ARGS, "main_share": 0.3})
assert "main_share 不是该工具的参数" in str(found.value)
with pytest.raises(ToolArgsError):
prepare_args(ARGS["decompose"], {**DECOMPOSE_ARGS, "mode": "average"})
def test_chat_users_may_bind_the_analysis_tools():
from app.auth.chat_policy import CHAT_BUILTIN_TOOLS, CHAT_NODE_TYPES, chat_graph_problem
assert set(contract.TOOL_NAMES.values()) <= CHAT_BUILTIN_TOOLS and contract.NODE_TYPE in CHAT_NODE_TYPES
assert chat_graph_problem(agent_graph()) is None
def test_a_tool_node_cannot_call_an_analysis_tool():
from app.engine.schema import GraphSpec, validate_graph
graph = {"nodes": [node("start", "input"), node("t", "tool", tool=DECOMPOSE, args=DECOMPOSE_ARGS)],
"edges": [{"source": "start", "target": "t"}]}
[issue] = [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.code == "analysis.tool_node"]
assert issue.level == "error" and issue.field == "tool" and "分析卡" in issue.message
async def test_calling_it_outside_an_agent_says_where_it_belongs():
from app.tools.registry import ToolContext, Versions, call_tool
with pytest.raises(CallError) as found:
await call_tool(DECOMPOSE, DECOMPOSE_ARGS, ToolContext(data_versions=Versions.CURRENT))
assert found.value.message == NO_SCOPE
def test_input_snapshots_carry_the_payload_as_is():
"""交给算子的一份输入:查询工具的 payload 原样(SQL 检查结果、「查询区间里这张表没有数据」、查询当时记下的遮罩,可比性
检查按它们判数据问题),加上出处和遮罩列;合并查询写明方言(在 SQLite 里执行)。"""
from app.engine.merge_query import MERGE_SOURCE
from app.tools.analysis import input_snapshot
content = {"columns": ["region", "gmv"], "rows": [["华东", 1]], "sql": "SELECT …", "source": "shop",
"column_types": {"region": "text", "gmv": "number"}, "truncated": False,
"checks": [{"level": "error", "code": "fanout_sum"}], "no_data": [{"columns": ["gmv"]}],
"mask_columns": ["phone"], "masked_at_query": ["tel"]}
snap = input_snapshot(content, ref="Q1", artifact="a" * 64, node_id="bot", masked=["tel", "phone"])
assert snap == {**content, "ref": "Q1", "artifact": "a" * 64, "node_id": "bot", "masked": ["tel", "phone"]}
merged = input_snapshot({**content, "source": MERGE_SOURCE}, ref="m", artifact="b" * 64, node_id="m")
assert merged["dialect"] == "sqlite" and merged["masked"] == []
# --------------------------------------------------------------------------
# 方法说明:绑了分析工具的 Agent 注入一次,模型调用节点、没绑的 Agent 不注入
# --------------------------------------------------------------------------
async def test_the_method_is_injected_once_into_the_agent_system(monkeypatch, shop):
FakeOps(monkeypatch)
script = Script(monkeypatch, [(TOOL, {"sql": SQL})])
await run_graph(agent_graph(tools=(TOOL, DECOMPOSE, BASELINE), system="你是零售分析师。"))
[system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)]
assert system.content.startswith("你是零售分析师。") and system.content.count(ANALYSIS_METHOD_PROMPT) == 1
script = Script(monkeypatch, [(TOOL, {"sql": SQL})])
await run_graph(agent_graph(tools=(TOOL,), system="你是零售分析师。"))
[system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)]
assert system.content == "你是零售分析师。", "没绑分析工具的 Agent,system 和以前一字不差"
async def test_model_call_nodes_get_no_method(monkeypatch):
from app.providers import mock_model
seen: list = []
def decide(model, messages):
seen.append(list(messages))
return AIMessage(content="好")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
nodes = [node("start", "input"), node("llm", "llm", system="你是零售分析师。", prompt="说一句"),
node("out", "output", fields=[{"name": "a", "value": "{{ nodes.llm.text }}"}])]
await run_graph({"nodes": nodes, "edges": [{"source": "start", "target": "llm"}, {"source": "llm", "target": "out"}]})
assert ANALYSIS_METHOD_PROMPT not in json.dumps([text(m) for m in seen[0]], ensure_ascii=False)
# --------------------------------------------------------------------------
# 一次分析:查询容器、台账、工件、编号、交回模型的那一段
# --------------------------------------------------------------------------
async def test_an_analysis_reads_this_nodes_query_and_is_recorded(monkeypatch, shop):
ops = FakeOps(monkeypatch)
script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)])
row = await run_graph(agent_graph())
# 算子拿到的:请求只有「算什么」,Q1 解析成本节点那次查询的快照
[call] = ops.calls
assert call["request"] == {"op": "decompose", "mode": "additive", "hypothesis": "下降是不是集中在某个地区",
"params": {k: v for k, v in DECOMPOSE_ARGS.items() if k not in ("mode", "hypothesis")}}
ends = await events(row.id, "tool.end", "bot")
query_artifact = ends[0]["query_artifact"]
snap = call["snapshots"]["Q1"]
assert snap["ref"] == "Q1" and snap["artifact"] == query_artifact and snap["node_id"] == "bot"
assert snap["source"] == "shop" and snap["sql"] == SQL and snap["truncated"] is False
assert snap["columns"] == ["region", "week", "gmv"] and snap["rows"][0] == ["华东", "2026-W41", 820.0]
# 查询工具的 payload 原样(工件库里那份),加上出处和遮罩列
assert snap["column_types"] == {"region": "text", "week": "text", "gmv": "number"} and snap["masked"] == []
assert snap["row_count"] == 6 and snap == {**artifact_store.load(query_artifact), "ref": "Q1",
"artifact": query_artifact, "node_id": "bot", "masked": []}
# 阈值:没有发起时的快照,现读设置(没存过就是缺省)
assert call["settings"] == contract.DEFAULTS
assert set(call["context"]) == {"today", "probe", "catalogs", "facts"}
# 探查器按这次运行固定的数据版本解析数据源(analysis.make_prober),剖析事实是按表取的函数(probe.profile_facts)
assert call["context"]["probe"].dialect("shop") == "sqlite" and callable(call["context"]["facts"]["shop"])
# 结论落成工件,tool.end 带着它;台账记一条 kind analysis(带 call_id),在那次查询之后
analysis_artifact = ends[1]["analysis_artifact"]
stored = artifact_store.load(analysis_artifact)
assert stored["op"] == "decompose" and stored["verdict"]["code"] == "driver"
assert stored["hypothesis"] == "下降是不是集中在某个地区"
entries = await ledger(row.id)
assert [e["kind"] for e in entries] == ["tool", "query", "tool", "analysis"]
analysis = entries[-1]
assert analysis == contract.ledger_entry(stored, node_id="bot", exec_no=1, artifact=analysis_artifact,
call_id="call_1")
assert analysis["inputs"] == [query_artifact]
async with SessionLocal() as session:
kinds = {r.kind for r in (await session.execute(
select(Artifact).where(Artifact.id == analysis_artifact))).scalars()}
assert kinds == {contract.ARTIFACT_KIND}
# 交回模型的:查询是「【证据 Q1】」,分析是「【分析 A1】」加判定语、关键数、结果表的前几段
first, second = tool_messages(script)
assert first.startswith("【证据 Q1】\n")
head, body = second.split("\n", 1)
assert head == "【分析 A1】"
reply = json.loads(body)
assert reply["verdict"] == "driver" and reply["subjects"] == [["华东"]] and reply["status"] == "ok"
assert reply["phrase"] == stored["phrase"] and reply["values"]["top_share"] == "0.6186"
assert reply["table"]["rows"][0][0] == "华东" and "criterion" in reply
# 调用记录里记着编号和结论工件
output = artifact_store.load((await events(row.id, "node.finished", "bot"))[0]["artifact"])
assert [c.get("alias") for c in output["tool_calls"]] == ["Q1", "A1"]
assert output["tool_calls"][1]["analysis_artifact"] == analysis_artifact
async def test_a_refused_analysis_is_still_a_recorded_conclusion(monkeypatch, shop):
FakeOps(monkeypatch, statuses=["refused"])
script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (BASELINE, {"query": "Q1", "period": "week",
"measure": "gmv"})])
row = await run_graph(agent_graph(tools=(TOOL, BASELINE)))
entries = await ledger(row.id)
assert entries[-1]["kind"] == "analysis" and entries[-1]["status"] == "refused" and entries[-1]["verdict"] is None
reply = json.loads(tool_messages(script)[1].split("\n", 1)[1])
assert reply["status"] == "refused" and reply["reason_code"] == "history_short" and "8 期" in reply["reason"]
[warn] = [e for e in await events(row.id, "log", "bot") if e.get("code") == REFUSED_CODE]
assert warn["level"] == "warn" and warn["reason_code"] == "history_short"
async def test_call_errors_go_back_to_the_model_and_are_not_recorded(monkeypatch, shop):
ops = FakeOps(monkeypatch, errors=[CallError("masked_column", "列「tel」设置了遮罩,不能当维度"), None])
script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS),
(DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["week"]})])
row = await run_graph(agent_graph())
assert len(ops.calls) == 2
_, refused, done = tool_messages(script)
assert refused == f"{CALL_ERROR_HEAD}列「tel」设置了遮罩,不能当维度"
assert done.startswith("【分析 A1】"), "写错的那次不占编号"
[error] = [e for e in await events(row.id, "tool.error", "bot")]
assert error["call_error"] == "masked_column" and error["error"] == refused
entries = await ledger(row.id)
assert [e["call_id"] for e in entries if e["kind"] == "analysis"] == ["call_2"]
async def test_drill_down_reads_this_nodes_earlier_conclusion(monkeypatch, shop):
ops = FakeOps(monkeypatch)
drill = {**DECOMPOSE_ARGS, "by": ["week"], "parent": {"of": "A1", "key": ["华东"]}, "hypothesis": "华东内部"}
script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), (DECOMPOSE, drill)])
row = await run_graph(agent_graph())
first, second = ops.calls
# parent 按本节点的编号取回上一层那份结论:就是存下的那一份(按内容寻址,哈希对得上)
stored = artifact_store.load((await events(row.id, "tool.end", "bot"))[1]["analysis_artifact"])
assert second["parent"] == stored
assert content_hash(canonical_json(second["parent"])) == (await events(row.id, "tool.end", "bot"))[1][
"analysis_artifact"]
assert second["parents"]("A9") is None
assert tool_messages(script)[2].startswith("【分析 A2】")
async def test_the_number_of_analyses_per_node_is_capped(monkeypatch, shop):
ops = FakeOps(monkeypatch)
# 第二次换一个维度(同样的参数会被护栏当成重复调用、直接交回上次的结果,不走到分析)
script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS),
(DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["week"]})])
await run_graph(agent_graph(), options={contract.SETTINGS_KEY: {"max_calls": 1}})
assert len(ops.calls) == 1
capped = tool_messages(script)[2]
import app.analysis as analysis_ops
assert capped == CALL_ERROR_HEAD + analysis_ops.exhausted({"max_calls": 1}).message
# 阈值来自发起时的快照
assert ops.calls[0]["settings"]["max_calls"] == 1
async def test_masked_columns_reach_the_operator(monkeypatch, tmp_path):
source_id = await _shop(tmp_path, {"mask_columns": ["phone"]})
try:
ops = FakeOps(monkeypatch)
Script(monkeypatch, [(TOOL, {"sql": MASKED_SQL}), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["tel"]})])
await run_graph(agent_graph())
masked = ops.calls[0]["snapshots"]["Q1"]["masked"]
# 按 SQL 认出改了名字的遮罩列(phone AS tel)排在前面,数据源设的列名也在
assert masked[0] == "tel" and "phone" in masked
finally:
await engines.invalidate(source_id)
async def test_mask_results_runs_hand_over_the_masked_snapshot(monkeypatch, tmp_path):
source_id = await _shop(tmp_path, {"mask_columns": ["phone"]})
try:
ops = FakeOps(monkeypatch)
Script(monkeypatch, [(TOOL, {"sql": MASKED_SQL}), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["region"]})])
await run_graph(agent_graph(), options={"mask_results": True, "read_only_data": True})
snap = ops.calls[0]["snapshots"]["Q1"]
assert "tel" in snap["masked"]
tel = snap["columns"].index("tel")
assert {r[tel] for r in snap["rows"]} == {"已遮罩"}, "遮罩运行里算子拿到的是遮过的快照"
finally:
await engines.invalidate(source_id)
# --------------------------------------------------------------------------
# 续跑:审批恢复时节点整个重放,分析不再执行、编号不错位
# --------------------------------------------------------------------------
async def test_replay_after_approvals_does_not_repeat_or_renumber(monkeypatch, shop):
ops = FakeOps(monkeypatch)
script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS),
(DECOMPOSE, {**DECOMPOSE_ARGS, "parent": {"of": "A1", "key": ["华东"]}})])
run = await run_manager.start(graph=agent_graph(approval="always"), input_payload={})
for _ in range(3):
row = await wait(run.id, ("interrupted", "failed", "succeeded"))
assert row.status == "interrupted", row.error
await run_manager.resume(run.id, {"approved": True})
row = await wait(run.id, ("succeeded", "failed", "interrupted"))
assert row.status == "succeeded", row.error
assert len(ops.calls) == 2, "分析在重放时又执行了一次"
ends = await events(row.id, "tool.end", "bot")
assert len(ends) == 3
# 重放之后模型看到的编号还是同一套,下钻取到的是同一份结论
assert [m.split("\n", 1)[0] for m in tool_messages(script)] == ["【证据 Q1】", "【分析 A1】", "【分析 A2】"]
assert ops.calls[1]["parent"] == artifact_store.load(ends[1]["analysis_artifact"])
entries = [e for e in await ledger(row.id) if e["kind"] == "analysis"]
assert [(e["call_id"], e["artifact"]) for e in entries] == [("call_1", ends[1]["analysis_artifact"]),
("call_2", ends[2]["analysis_artifact"])]
# --------------------------------------------------------------------------
# 协作成员:同样注入方法说明、同样编号(协作节点不记证据台账)
# --------------------------------------------------------------------------
async def test_a_team_member_gets_the_method_and_numbers(monkeypatch, shop):
ops = FakeOps(monkeypatch)
script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)],
route={"assignments": [{"agent": "分析员", "instruction": "拆一下销售额的变化"}], "done": False})
nodes = [node("start", "input"),
node("team", "supervisor", goal="销售额为什么下降", approval="never", max_rounds=2, agents=[
{"name": "分析员", "system": "你负责拆解", "tools": [TOOL, DECOMPOSE], "max_steps": 4}]),
node("out", "output", fields=[{"name": "a", "value": "{{ nodes.team.text }}"}])]
run = await run_manager.start(graph={"nodes": nodes, "edges": [{"source": "start", "target": "team"},
{"source": "team", "target": "out"}]},
input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
[system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)]
assert system.content.startswith("你负责拆解") and system.content.count(ANALYSIS_METHOD_PROMPT) == 1
assert [m.split("\n", 1)[0] for m in tool_messages(script)] == ["【证据 Q1】", "【分析 A1】"]
assert ops.calls[0]["snapshots"]["Q1"]["node_id"] == "team"
[end] = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")]
assert artifact_store.load(end["analysis_artifact"])["op"] == "decompose"
+1 -1
View File
@@ -258,7 +258,7 @@ def test_of_run_reads_the_run_options():
#: 给模型、给自查读目录的模块(四处消费 + 它们的帮手)。要读目录一律经 data/catalog_view.py
CONSUMERS = ("tools/datasource.py", "look/tables.py", "data/sqlcheck.py", "engine/nodes/dual_check.py",
"engine/verify_agent.py", "api/copilot_context.py")
"engine/verify_agent.py", "api/copilot_context.py", "tools/analysis.py")
FORBIDDEN = re.compile(r"\bread_catalogs?\s*\(|\bread_entry\s*\(|\bCatalogNote\b")
+174
View File
@@ -0,0 +1,174 @@
"""口径卡的可比性挂钩(docs/design/analysis-round/00-总方案.md §3.4 第 3 条):一个指标的输入落在两份不同的查询快照上、
两份 SQL 同形只差字面量(本周对上周)时,口径卡执行时自动做一次可比性检查,结论落成工件、记在指标的 comparability 上。
- 同形只差日期 → 有 comparability,早的那期是基期(和表达式里谁先写无关);同一对快照只查一次;原因按算子的
analysis.comparable_reasons 拆
- SQL 结构不同(退款额 / 销售额)、两次是同一条 SQL → 不查、不记日志
- 算子出错、算不了、算子没有实现(NotImplementedError)→ 不挡口径卡,记一条 metric_comparability_skipped
这里的算子是替身(tests/fixtures/analysis_ops.FakeOps);真算子跑一遍的见 tests/test_analysis_e2e.py。
"""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from sqlalchemy import select
from app.analysis.contract import CallError
from app.core import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.nodes.metrics import COMPARABILITY_SKIPPED
from app.engine.runner import run_manager
from tests.fixtures.analysis_ops import FakeOps
TOOL = "db_query__shop"
THIS = "SELECT SUM(amount) AS gmv FROM orders WHERE day BETWEEN '2026-10-12' AND '2026-10-18'"
LAST = "SELECT SUM(amount) AS gmv FROM orders WHERE day BETWEEN '2026-10-05' AND '2026-10-11'"
REFUNDS = "SELECT SUM(refund) AS gmv FROM orders WHERE refund > 0 AND day BETWEEN '2026-10-12' AND '2026-10-18'"
WOW = "(cell(nodes.this_week, 0, 'gmv') - cell(nodes.last_week, 0, 'gmv')) / cell(nodes.last_week, 0, 'gmv')"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture(autouse=True)
async def shop(tmp_path):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, day TEXT, region TEXT, amount REAL, refund REAL);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", [
(1, "2026-10-06", "华东", 500.0, 0.0), (2, "2026-10-08", "华南", 400.0, 20.0),
(3, "2026-10-13", "华东", 450.0, 0.0), (4, "2026-10-15", "华北", 300.0, 15.0)])
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
if row is None:
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(metrics, *, this=THIS, last=LAST):
nodes = [node("start", "input"),
node("this_week", "tool", tool=TOOL, args={"sql": this}),
node("last_week", "tool", tool=TOOL, args={"sql": last}),
node("card", "metrics", caliber="周报口径", metrics=metrics),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}])]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def run_card(g) -> tuple[Run, dict, list[dict]]:
run = await run_manager.start(graph=g, input_payload={})
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
break
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
evts = [(e.type, e.node_id, e.data) for e in (await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq))).scalars()]
[finished] = [d for t, n, d in evts if t == "node.finished" and n == "card"]
card = artifact_store.load(finished["artifact"])
logs = [d for t, n, d in evts if t == "log" and n == "card"]
return row, card, logs
def by_id(card):
return {m["id"]: m for m in card["metrics"]}
async def test_two_periods_of_the_same_query_get_a_comparability_check(monkeypatch):
ops = FakeOps(monkeypatch)
_, card, logs = await run_card(graph([
{"id": "wow", "name": "销售额环比", "expression": WOW, "decimals": 4},
{"id": "diff", "name": "销售额变化", "expression": "cell(nodes.last_week, 0, 'gmv') - cell(nodes.this_week, 0, 'gmv')"},
{"id": "this", "name": "本周销售额", "expression": "cell(nodes.this_week, 0, 'gmv')"}]))
metrics = by_id(card)
[call] = ops.calls # 同一对快照只查一次
# 早的那期是基期:表达式里先写的是本周还是上周都一样
assert call["request"] == {"op": "comparable", "mode": None,
"params": {"base_query": "last_week", "target_query": "this_week"}}
base, target = call["snapshots"]["last_week"], call["snapshots"]["this_week"]
assert base["sql"] == LAST and target["sql"] == THIS and base["node_id"] == "last_week"
assert base["artifact"] == metrics["wow"]["inputs"][1]["artifact"] and base["masked"] == []
found = metrics["wow"]["comparability"]
assert found == metrics["diff"]["comparability"]
stored = artifact_store.load(found["artifact"])
assert stored["op"] == "comparable" and found == {"artifact": found["artifact"], "verdict": "comparable",
"phrase": "两期可比", "reasons": []}
# 只用到一份快照的指标不查
assert "comparability" not in metrics["this"]
assert not [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED]
#: 可比性结论的检查表(照算子的 comparable.table_of:检查项、结果、基期、本期、说明)
CHECKS = {"columns": ["check", "status", "base", "target", "note"], "labels": {},
"rows": [["同口径", "通过", None, None, None], ["窗口长度", "通过", "7 天", "7 天", None],
["整期", "不通过", None, None, "本期数据只到 2026-10-15,还差 3 天"],
["星期构成", "提醒", None, None, "两段的周末天数不同:2 天对 1 天"]]}
async def test_an_incomparable_pair_lists_its_reasons(monkeypatch):
import app.analysis as analysis_ops
FakeOps(monkeypatch, verdicts=["incomparable"], table=CHECKS)
_, card, _ = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW}]))
found = by_id(card)["wow"]["comparability"]
stored = artifact_store.load(found["artifact"])
assert found == {"artifact": found["artifact"], "verdict": "incomparable", "phrase": stored["phrase"],
"reasons": analysis_ops.comparable_reasons(stored)}
assert found["reasons"] == ["本期数据只到 2026-10-15,还差 3 天", "两段的周末天数不同:2 天对 1 天"]
async def test_different_structures_and_the_same_query_are_not_checked(monkeypatch):
ops = FakeOps(monkeypatch)
ratio = "cell(nodes.this_week, 0, 'gmv') / cell(nodes.last_week, 0, 'gmv')"
_, card, logs = await run_card(graph([{"id": "refund_rate", "name": "退款率", "expression": ratio}], last=REFUNDS))
assert "comparability" not in by_id(card)["refund_rate"] and ops.calls == []
_, card, logs2 = await run_card(graph([{"id": "same", "name": "同一条", "expression": ratio}], last=THIS))
assert "comparability" not in by_id(card)["same"] and ops.calls == []
assert not [log for log in [*logs, *logs2] if log.get("code") == COMPARABILITY_SKIPPED]
async def test_an_operator_that_is_not_there_does_not_block_the_card(monkeypatch):
"""算子没有实现(合并前的骨架抛 NotImplementedError):口径卡照常算,照实记一条没做成。"""
FakeOps(monkeypatch, errors=[NotImplementedError("analysis.run 还没有实现")])
_, card, logs = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW, "decimals": 4}]))
wow = by_id(card)["wow"]
assert "comparability" not in wow and wow["value"] == pytest.approx((450 + 300 - 900) / 900, abs=1e-4)
[log] = [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED]
assert log["level"] == "warn" and log["metric"] == "wow" and "销售额环比" in log["message"]
assert "没做成可比性检查" in log["message"]
@pytest.mark.parametrize("ops_kw", [
{"errors": [CallError("no_window", "认不出时间条件")]},
{"errors": [RuntimeError("探查超时")]},
{"statuses": ["refused"]},
])
async def test_failures_do_not_block_the_card(monkeypatch, ops_kw):
FakeOps(monkeypatch, **ops_kw)
_, card, logs = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW}]))
assert "comparability" not in by_id(card)["wow"]
[log] = [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED]
assert log["reason"]