mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
合入 W1-c 入口:五个分析工具与方法注入、分析卡、口径卡可比性、运行对比按项对齐、设置与快照、使用者白名单
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": "期望指标",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 子图:把另一张工作流当成一个节点
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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-])")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user