分析能力入口(三):分析卡(节点类型 analysis,和口径卡平级,engine/nodes/analysis.py)。配置 items: [{id, op, mode, from, params, hypothesis}] 和阈值覆盖 overrides;画图时校验(schema.analysis_issues,执行前同一套再过一遍):至少一项、标识合法且不重复、分析方法和模式是契约里的、输入(from,或两份查询的 base_query / target_query,总数两格落在的节点)指向上游的调用工具 / 合并查询节点(Agent、不产出查询的节点、不在上游的报错,调用别的工具的只提醒)、参数和 agent 工具同一份定义且不能写阈值、下钻指向同卡排在前面的变化拆解、阈值覆盖按 contract.settings_problem。执行照合并查询的做法从 state.nodes 取上游的查询快照 id、复验哈希,只认台账里 kind query 的快照,遮罩列同证据面板;参数里的 {{ }} 先渲染;阈值是缺省 ← 发起时的快照 ← 卡上的覆盖;每项的结论落成工件、台账记一条带 item 的 kind analysis、发一条 analysis.end;产出 nodes.<卡>.results.<项> = {status, verdict, direction, values, phrase, artifact},条件分支能按判定走、成果能取判定语。一项算不了照实记(analysis_refused),不让卡失败;调用写错让卡失败并说清是哪一项、怎么改。登记:事件 analysis.end、叫法「分析卡」「分析项」「阈值覆盖」和分析方法、模式的叫法,编译器,治理(分析卡算可追溯的节点;受管级别分析卡的输入要是数据库查询或合并查询 governed.analysis_input,画布那条同义的提醒不再重复列;口径卡读分析卡的结果给提示 governed.caliber_analysis_input,输入按 via analysis 记、追不到原始数据),升级提醒的证据类节点,变量表(每项的结论、对上游节点的引用),数据目录影响面(同合并查询经由输入),助手查看的工作流说明,两条新编号的白话

This commit is contained in:
ThearchyHelios
2026-10-21 13:03:00 +08:00
parent 601b84918d
commit 3009a43cee
13 changed files with 864 additions and 13 deletions
+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"):
+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
+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": "期望指标",
+166
View File
@@ -0,0 +1,166 @@
"""分析卡(节点类型 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}的查询快照已不存在,无法分析。请重新运行它")
snapshot = input_snapshot(content, ref=node_id, artifact=artifact, node_id=node_id)
snapshot["masked"] = await masked_of(content, artifact)
return snapshot
__all__ = ["REFUSED_CODE", "card_view", "run_analysis"]
+1 -1
View File
@@ -694,7 +694,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,
+165
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" # 双盲复算:另起一路独立算一遍,和原结果比对
@@ -632,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:没挂引用的结论句计入缺口、按出具档位降档。这两档是确定性的(发布前自动修复
#: 给人选的也只有这两档)
@@ -989,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,
+419
View File
@@ -0,0 +1,419 @@
"""分析卡(节点类型 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」"),
([{**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