合入 W3-b 口径账本闭环:caliber_from.nodes 改名映射;问数据的助手 use_caliber 选用已发布口径(服务端确定性内联实例化);按口径正式出具(自动只给问数据的答案)与口径对账;认证队列、固化成口径草稿、发布后补比;账本统计补上对账与认证转化;使用者的原始运行记录只给问数据的运行

This commit is contained in:
ThearchyHelios
2026-10-16 18:45:45 +08:00
34 changed files with 3985 additions and 100 deletions
+27 -6
View File
@@ -117,7 +117,8 @@ async def _look_service(session: AsyncSession, payload: GenerateIn, sources: lis
spec = await copilot_model_spec(session, payload, max_tokens=copilot_context.PICK_MAX_TOKENS)
return await copilot_context.look_service(sources, spec=spec, graph=graph, kinds=kinds,
answer=payload.intent == "answer", catalog_view=catalog_view,
trail={"conversation_id": getattr(payload, "conversation_id", None)})
trail={"conversation_id": getattr(payload, "conversation_id", None)},
session=session)
NODE_REFERENCE = """\
@@ -200,7 +201,8 @@ NODE_REFERENCE = """\
{{ nodes.报告节点id.text }}),审批卡会附上这份报告的核对概况
- validate:JSON Schema 校验,可自动让模型修复。config: {schema, source, max_retries}
- transform:数据整形。config: {mode: expression|template|json, expression/template, assign_to}
- subgraph:嵌套另一个工作流。config: {workflow_id, input}
- subgraph:嵌套另一个工作流。config: {workflow_id, workflow_version, input}:workflow_version 写固定的版本号(1、2…),
不写就跟随那个工作流最新的发布版本。问数据里不要拿子工作流去拼已发布的口径(子工作流里的数报告引用不到)
- metrics:口径卡。报告里要出现的数字、以及所有派生计算(比率、增幅、占比、差值)都登记在这里。
config: {caliber, caliber_version, metrics:[{id, name, unit, decimals, format, expression}], on_missing, assign_to}
expression 是受限表达式(不是模板):vars.kpi.gmv、cell(nodes.fetch, 0, 'gmv')、
@@ -817,7 +819,9 @@ def _graph_system(sources: list[Any], scope: set[str] | None, datasources: str,
"5. 能用 3 个节点解决就别堆 8 个"
f"{_CHAT_USER_PROMPT if chat_user else ''}\n\n"
f"{_STREAM_PROTOCOL}"
+ (f"\n\n{looks.guide()}\n{copilot_context.LOOK_WHEN_PROMPT}" if looks.kinds else "")
# 问数据里能选用已发布口径时(next-round 02 做法 4):协议多一段 use_caliber,「什么时候该看」换成问数据那一份
+ (f"\n{copilot_context.USE_CALIBER_PROMPT}" if copilot_context.composing(looks) else "")
+ (f"\n\n{looks.guide()}\n{copilot_context.look_when(looks)}" if looks.kinds else "")
)
@@ -1700,12 +1704,16 @@ async def answer_ops(payload: GenerateIn, *, catalog_view: Any = None):
# 比对工具绑定的基准:画布上是改之前的图;问数据页是上一轮的图(沿用它的节点 id)
baseline = copy.deepcopy(
(payload.base_graph or {}).get("nodes") or (prev_graph or {}).get("nodes") or [])
# 查看服务:读目录、读能力登记在开流之前做完。现有工作流(画布上的原图、问数据追问时上一轮的图)查过的表列在概览里
# 查看服务:读目录、读能力登记在开流之前做完。现有工作流(画布上的原图、问数据追问时上一轮的图)查过的表列在概览里。
# 问数据另看已发布口径、可以 use_caliber 选用(next-round 02 做法 4):只给搭建者及以上——使用者的图里看得到拷进来的
# 取数 SQL(口径的 SQL 可能写着遮罩值),这一波不给
kinds = ("table", "capability", "caliber") if payload.intent == "answer" and not chat_user \
else ("table", "capability")
looks = await _look_service(session, payload, sources, graph=payload.base_graph if (
payload.base_graph or {}).get("nodes") else prev_graph, catalog_view=view)
payload.base_graph or {}).get("nodes") else prev_graph, catalog_view=view, kinds=kinds)
# 这一份 system 一直用到最后:接着写、自查修正都沿用,不换
system = _graph_system(sources, scope, await copilot_context.section(looks), looks, chat_user=chat_user)
applier = GraphApplier(nodes, edges)
applier = GraphApplier(nodes, edges, composer=copilot_context.composer(looks))
started = time.monotonic()
gen = Generation(model, system=system, applier=applier, looks=looks, started=started,
checker=GraphChecker(applier, scope=scope, sources=sources, baseline=baseline, checkers=checkers,
@@ -2454,6 +2462,10 @@ class FromRunIn(BaseModel):
#: 问数据回答上点「做成应用」时带上:这次运行出自哪段对话的哪一轮,记进新工作流的来源(workflows.origin)
conversation_id: str | None = None
turn_id: str | None = None
#: 固化成口径草稿(next-round 02 做法 8,app/ledger/certify.freeze):报告引用的查询变成固定 SQL 的取数节点,跨轮变化
#: 的字面量成为参数(不保留 Agent)。peers 是同一组(同一个 SQL 形状)的别的运行,认哪些字面量位置变了
freeze: bool = False
peers: list[str] = Field(default_factory=list, max_length=50)
@router.post("/from-run")
@@ -2475,6 +2487,15 @@ async def extract_template(
# 对话只是记进来源:不能用的不让记(D-d),不存在的照旧记下 id
await _ensure_conversation_access(session, payload.conversation_id)
if payload.freeze:
# 固化成口径草稿:取数是固定的 SQL(以后比对认得出等价),不是保留 Agent 的执行路径
from app.ledger.certify import CertifyRefused, freeze
try:
return await freeze(session, payload.run_id, peers=payload.peers, name=payload.name,
conversation_id=payload.conversation_id, turn_id=payload.turn_id)
except CertifyRefused as e:
raise HTTPException(e.status_code, e.message) from e
try:
path = await extract_run_path(session, payload.run_id)
except PathRefused as e:
+50 -4
View File
@@ -10,7 +10,10 @@
模型,数据类请求一次查看到位。
**各入口看什么**(总方案 §5.3):画布、问数据看表和能力(问数据只列只读能力);发布前修复、升级证据看表,由系统按要修
的 SQL 代为查看(look 文档 D5);应用润色看工作流版本和能力(在 copilot.py 里组装)。
的 SQL 代为查看(look 文档 D5);应用润色看工作流版本和能力(在 copilot.py 里组装)。问数据另看已发布口径(next-round 02
做法 4:搭建者及以上;使用者发起的图里看得到拷进来的取数 SQL,这一波不给):助手可以 use_caliber 选用一份口径做骨架,
「什么时候该看」换成 LOOK_WHEN_ANSWER_PROMPT,输出协议多 USE_CALIBER_PROMPT 一段。运行时写 SQL 的 Agent 不看口径(拍板
L2:保持独立解读,「等价已证明」才有意义)。
这里提供:look_service(按入口组装查看服务,开流之前调:读目录、读能力登记要用会话)、section(system 里数据源那一节)、
system_look(系统代为查看)、写 SQL 的硬性要求和「什么时候该看」的判断标准。
@@ -59,6 +62,23 @@ LOOK_WHEN_PROMPT = """\
· 用户说了一条数据事实:先看那张表的现有目录,再提目录修改
· 要看就先看,看完再规划,不要先搭节点再回头看;这次对话里已经看过的表直接点名看,不要只给需求"""
#: 问数据里(能看已发布口径时)的「什么时候该看」:问数据那一条按口径账本改写(next-round 02 做法 4 ④),别的照旧
LOOK_WHEN_ANSWER_PROMPT = LOOK_WHEN_PROMPT.replace(
""" · 问数据:查库交给 agent 节点,它运行时会自己查表结构。只有从概览看不出该用哪个库、用户点了表名、用户说了口径时
才看一次,把看到的表名写进 agent 的提示词,运行时少走弯路""",
""" · 问数据:先看已发布口径(kind 写 caliber)。问法和口径的定义完全对得上时用 use_caliber 选用它;口径只覆盖问题的
一部分时 use_caliber 选用那一部分,其余交给 agent 补;问法多了口径没有的条件(比如「含退款」「只看新客」)时不要套
口径,交给 agent 去查,系统会自动拿它的 SQL 和口径对账。查库交给 agent 时它运行时会自己查表结构:只有从概览看不出
该用哪个库、用户点了表名时才看表,把看到的表名写进 agent 的提示词""")
#: 问数据里输出协议多的一段:选用口径(ledger/compose.py)。只在这一轮能看已发布口径时接在协议后面
USE_CALIBER_PROMPT = """- 选用已发布口径(只在问数据里、look 过 caliber 之后):
{"op":"use_caliber","id":"c1","workflow_id":"…","version":3,"card":"口径卡节点 id","metrics":["指标id"],"inputs":{"字段":"值"}}
照抄查看结果里给的写法,只改 id、metrics、inputs。系统按那一版生成取数节点(c1_<原 id>)和口径卡(节点 id 就是 c1),
并把入口连到取数、取数连到口径卡;你**不要**自己写这几个节点,也不要改它们。接下来只需把口径卡连到报告
(add_edge c1 → report),报告里用 [[m:指标id]] 引用。inputs 按口径的入口字段填(日期、门店这类问题里给出的条件),
填不对会被拒收、原因会交回给你改;口径对不上问法时不要选用"""
#: 系统代为查看时写进记录的需求(界面上显示)
SYSTEM_LOOK_FIX_NEED = "要修的工作流里,查库节点的 SQL 用到的表"
SYSTEM_LOOK_UPGRADE_NEED = "要升级的工作流里,查库节点的 SQL 用到的表"
@@ -66,22 +86,48 @@ SYSTEM_LOOK_UPGRADE_NEED = "要升级的工作流里,查库节点的 SQL 用
async def look_service(sources: list[Any], *, spec: ModelSpec | None, graph: Mapping[str, Any] | None = None,
kinds: tuple[str, ...] = ("table", "capability"), answer: bool = False,
catalog_view: Any = None, trail: Mapping[str, Any] | None = None) -> LookService:
catalog_view: Any = None, trail: Mapping[str, Any] | None = None,
session: Any = None) -> LookService:
"""按入口组装这一轮的查看服务。读目录、读能力登记都用各自的短会话,开流前后都能调。
spec 是助手的模型规格:挑选模型第一次要用时才按它拿(关思考、额度按 look/pick.PICK_MAX_TOKENS)。graph 是现有工作流,概览里列出它查过的表。
answer:问数据(意图为回答),能力只列只读的。catalog_view:表详情里给哪一份数据目录(data/catalog_view.py),不给是当前目录。
trail:按需加入探查范围时的留痕(哪一轮对话:{conversation_id};谁取这次请求的身份)。
kinds 里有 caliber 时用 session(调用方开流之前的会话)读已发布口径,问数据里另判能不能组合(look/calibers 的 compose)。
"""
from app.look.calibers import CaliberKind
picker = Picker(spec)
made: list[Any] = []
if "table" in kinds:
made.append(await TableKind.load(sources, graph=graph, picker=picker, catalog_view=catalog_view, trail=trail))
if "capability" in kinds:
made.append(await CapabilityKind.load(picker=picker, read_only=answer))
if "caliber" in kinds and session is not None:
made.append(await CaliberKind.load(session, picker=picker, compose=answer))
return LookService(made)
def composing(looks: LookService) -> bool:
"""这一轮能不能选用已发布口径(问数据里读好了能组合的口径卡)。"""
kind = looks.kinds.get("caliber")
return kind is not None and getattr(kind, "options", None) is not None
def look_when(looks: LookService) -> str:
"""「什么时候该看」:能选用已发布口径的问数据用改写过的那一份。"""
return LOOK_WHEN_ANSWER_PROMPT if composing(looks) else LOOK_WHEN_PROMPT
def composer(looks: LookService) -> Any | None:
"""GraphApplier 用的组合器(ledger/compose.Composer);这一轮不能选用口径时为 None。"""
if not composing(looks):
return None
from app.ledger.compose import Composer
return Composer(list(looks.kinds["caliber"].options))
async def section(looks: LookService) -> str:
"""system 里数据源那一节:各类对象的概览,有数据源时再加写 SQL 的硬性要求。什么都没有时是空串,不占提示词。"""
overview = await looks.overview()
@@ -105,6 +151,6 @@ async def system_look(looks: LookService, graph: Mapping[str, Any] | None, sourc
__all__ = [
"LOOK_WHEN_PROMPT", "SQL_RULES_PROMPT", "SYSTEM_LOOK_FIX_NEED", "SYSTEM_LOOK_UPGRADE_NEED", "graph_tables",
"look_service", "section", "system_look",
"LOOK_WHEN_ANSWER_PROMPT", "LOOK_WHEN_PROMPT", "SQL_RULES_PROMPT", "SYSTEM_LOOK_FIX_NEED", "SYSTEM_LOOK_UPGRADE_NEED",
"USE_CALIBER_PROMPT", "composer", "composing", "graph_tables", "look_service", "look_when", "section", "system_look",
]
+93 -2
View File
@@ -255,14 +255,30 @@ class GraphApplier:
- skipped_types:模型编出来的节点类型,收尾时说出来
patches:目录修改提案转不转(只有用户亲口说了数据事实的画布、问数据才转)。
composer:问数据里能选用已发布口径时(ledger/compose.Composer,next-round 02 做法 4)。use_caliber 由它按那一版确定性
实例化成取数节点和口径卡,这里落地:节点、连线照 add_node / add_edge 记进 seen(兜底重放照样重放得出来),入口连到
第一个取数节点(入口还没加的话,等入口加了再接);转给界面的只是那一条 use_caliber,带上 result(选用了哪一版、生成了
哪几个节点)。不合规的拒收:result 写原因、照样转给界面(过程里照实写一行),原因记进 refused,自查时交回模型改
(GraphChecker.issues)。没有 composer 的入口(画布、发布前修复、升级)use_caliber 不认,照旧当认不出的操作丢掉。
"""
def __init__(self, nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], *, patches: bool = True,
questions: bool = False) -> None:
questions: bool = False, composer: Any = None) -> None:
self.nodes = nodes
self.edges = edges
self.patches = patches
self.questions_mode = questions
self.composer = composer
#: 选用口径实例化出来的段:[(第一个取数节点, 全部节点 id)],接入口用
self.composed: list[tuple[str, list[str]]] = []
#: 已经落地的 use_caliber(ledger/compose.signature),接着写时重发的同一个不再落地
self._composed_ops: set[str] = set()
#: 系统接上的连线(入口 → 取数):模型再发一条一模一样的不重复加
self._wired: list[dict[str, Any]] = []
#: 拒收的 use_caliber:[{op, message}],自查时交回模型
self.refused: list[dict[str, Any]] = []
self._reported = 0
self.seen: list[dict[str, Any]] = []
self.tried: list[dict[str, Any]] = []
self.effective: list[dict[str, Any]] = []
@@ -305,11 +321,18 @@ class GraphApplier:
self.autorun = op.get("run") is not False
self.seen.append(op)
return Applied(False, True)
if kind == "use_caliber" and self.composer is not None:
return self._use_caliber(op)
if kind == "add_edge" and self._was_wired(op.get("edge") or {}):
# 入口 → 取数这条系统已经接上了
return Applied(False, False)
if self._continuing and self._repeats(op):
return Applied(False, False)
self.seen.append(op)
self.tried.append(op)
changed = _apply_op(self.nodes, self.edges, op)
if changed and kind == "add_node":
self._wire()
if changed:
self.effective.append(op)
nid = (op.get("node") or {}).get("id") if kind == "add_node" else op.get("id")
@@ -321,6 +344,56 @@ class GraphApplier:
self.skipped_types.append(str(bad))
return Applied(changed, changed)
def _use_caliber(self, op: dict[str, Any]) -> Applied:
"""选用一份已发布口径(见类说明)。op 原地写上 result,原样转给界面。"""
from app.ledger.compose import ComposeRefused, signature
sig = signature(op)
if sig in self._composed_ops:
return Applied(False, False)
try:
composed = self.composer.compose(op, taken=set(self.nodes))
except ComposeRefused as e:
op["result"] = {"status": "refused", "reason": e.message}
self.refused.append({"op": {k: v for k, v in op.items() if k != "result"}, "message": e.message})
return Applied(False, True, e.message)
self._composed_ops.add(sig)
primitive = [{"op": "add_node", "node": {"id": n["id"], "type": n["type"], "label": n["data"].get("label") or "",
"config": n["data"].get("config") or {}}} for n in composed.nodes]
primitive += [{"op": "add_edge", "edge": e} for e in composed.edges]
for item in primitive:
self.seen.append(item)
self.tried.append(item)
if _apply_op(self.nodes, self.edges, item):
self.effective.append(item)
ids = [str(n["id"]) for n in composed.nodes]
self.composed.append((ids[0], ids))
if composed.op_id not in self._landed:
self._landed.append(composed.op_id)
self._wire()
op["result"] = composed.result()
return Applied(True, True)
def _wire(self) -> None:
"""选用口径生成的取数节点还没有上游、图里已经有入口的:系统把入口连到第一个取数节点。"""
entry = next((nid for nid, n in self.nodes.items() if n.get("type") == "input"), None)
if entry is None:
return
for first, _ in self.composed:
if first in self.nodes and not any(e["target"] == first for e in self.edges):
edge = {"source": entry, "target": first, "sourceHandle": None}
self.edges.append(edge)
self._wired.append(edge)
def _was_wired(self, edge: dict[str, Any]) -> bool:
return any(w["source"] == edge.get("source") and w["target"] == edge.get("target") for w in self._wired)
def unreported(self) -> list[dict[str, Any]]:
"""还没交回过模型的拒收的选用,取走即算交回过(同 AppApplier.unreported)。"""
out = self.refused[self._reported:]
self._reported = len(self.refused)
return out
def _repeats(self, op: dict[str, Any]) -> bool:
"""接着写时和已落地完全相同的操作(look §2.5.5):已有的同一条连线、和已落地节点一模一样的节点、重发的同一个操作。"""
kind = op.get("op")
@@ -355,6 +428,7 @@ class GraphApplier:
for e in graph.get("edges") or []]
for op in self.seen:
_apply_op(self.nodes, self.edges, op)
self._wire()
class GraphChecker:
@@ -387,8 +461,9 @@ class GraphChecker:
async def issues(self) -> list[dict[str, Any]]:
errors = self.blocking()
refused = _refused_issues(self.applier)
if self.known is None:
return errors or []
return [*refused, *(errors or [])]
from app.engine.autofix import _sig
if not self.applier.effective:
@@ -405,6 +480,22 @@ class GraphChecker:
# 应用的落地
# ==========================================================================
#: 选用口径被拒时交回模型的说法
_REFUSED_FOR_MODEL = ("上一次的 use_caliber 被拒收:{message}。按原因改正后重新输出 use_caliber(id 可以沿用);这份口径对不上"
"问法的话就不要选用,交给 agent 查")
def _refused_issues(applier: Any) -> list[dict[str, Any]]:
"""拒收的 use_caliber 还没交回过模型的那几条,形状同自查的问题(error 级:不改就少了这一块)。"""
if not isinstance(applier, GraphApplier):
return []
from app.ledger.compose import REFUSED
return [{"level": "error", "node_id": None, "edge_id": None, "field": None, "code": REFUSED,
"message": f"没有选用口径:{r['message']}", "for_model": _REFUSED_FOR_MODEL.format(message=r["message"])}
for r in applier.unreported()]
APP_REPAIR_PROMPT = (
"这是你刚改好的应用草稿:\n{spec}\n\n"
"应用检查(和发布同一套规则)发现下面这些问题:\n{issues}\n\n"
+9 -6
View File
@@ -9,7 +9,7 @@
- can_read_run / can_read_artifact:运行、工件的读权限(user 角色只能读有授权的应用视图运行、自己参与的实例);
- ensure_role / ensure_can_publish:接口内部按请求内容判定的权限(MCP 的 stdio、受管级发布、版本退役,Q8);
- restricted():这次请求是不是开登录后的 user 角色(按请求内容收窄的接口用:问数据只能发起探索运行、工件按白名单给);
- run_events_readable(principal, run):运行的原始记录、事件、实时流能不能读(user 只读查询结果在工具边界遮过的);
- run_events_readable(principal, run):运行的原始记录、事件、实时流能不能读(user 只读查询结果在工具边界遮过的问数据运行);
- ensure_conversation_access(session, conversation_id):接受 conversation_id 的接口核对对话归属(对话按人隔离);
- run_scope / evidence_scope / chat_scope:main.py 挂在运行、证据、问数据几组路由上的依赖,按 run_id 判断能不能读。
run_scope 同样管运行的 WebSocket,握手时按运行判;
@@ -213,15 +213,18 @@ async def run_events_readable(principal: Principal, run: Any) -> bool:
"""能不能读这次运行的原始记录、事件和实时流(GET /api/runs/{id}、/events、/graph,WebSocket /stream)。
关登录:能。builder 及以上:这次运行它能读就能(can_read_run:别人的个人应用刷新、个人蓝图试运行读不了)。
user:这次运行它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。两个条件缺一不可:
没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的应用实例、
有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。实际上满足两条的就是它自己开登录后发起的
问数据运行;关登录时自报同名留下的老运行没在边界遮过,开了登录也读不到。"""
user:问数据的运行(origin_kind=chat),这次它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。
三个条件缺一不可:没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的
应用实例、有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。服务端替他派生的运行也带着边界遮罩,但图
不是他的助手搭的:口径对账(ledger/reconcile)的图里原样是口径取数节点的 SQL 模板(口径的 SQL 可能写着遮罩值,所以
问数据里使用者不看口径),同样经证据接口读(展示的 SQL 里写着的遮罩值换成「已遮罩」,同应用视图运行)。实际上满足
三条的就是它自己开登录后发起的问数据运行;关登录时自报同名留下的老运行没在边界遮过,开了登录也读不到。"""
if principal.mode == "off":
return True
if principal.at_least("builder"):
return await can_read_run(principal, run)
if run is None or not principal.username or not masked_at_boundary(run):
if run is None or not principal.username or getattr(run, "origin_kind", None) != "chat" \
or not masked_at_boundary(run):
return False
return await can_read_run(principal, run)
+8 -2
View File
@@ -1466,7 +1466,9 @@ def _located(locator: dict[str, Any]) -> list[tuple[Any, Any]]:
async def _caliber_source(source: Any) -> dict[str, Any] | None:
"""钉住别处口径卡(caliber_from)时,定义取自哪个工作流的哪一版;带上工作流现在的名字方便认。"""
"""钉住别处口径卡(caliber_from)时,定义取自哪个工作流的哪一版;带上工作流现在的名字方便认。取数节点拷进这张图时
改了名的(caliber_from.nodes,next-round 02 做法 4),来源里照带 nodes 映射:指标的输入写的是改名后的节点,面板据此
说清它们对应那一版里的哪个取数节点。"""
if not isinstance(source, dict) or not source.get("workflow_id"):
return None
async with SessionLocal() as session:
@@ -1478,7 +1480,11 @@ def _caliber_upgrade(event: dict[str, Any] | None) -> dict[str, Any] | None:
"""这张口径卡在正式运行发起时的升版处置:钉在哪一版、上游最新是哪一版、怎么处置的。"""
if not isinstance(event, dict):
return None
return {k: event.get(k) for k in ("workflow_id", "caliber_node", "pinned", "latest", "policy", "policy_label")}
out = {k: event.get(k) for k in ("workflow_id", "caliber_node", "pinned", "latest", "policy", "policy_label")}
if isinstance(event.get("caliber_nodes"), dict):
# 取数节点拷进这张图时改了名(caliber_from.nodes):照给映射;没改名的卡没有这个键,老运行一字不差
out["caliber_nodes"] = dict(event["caliber_nodes"])
return out
#: 双盲复算节点产出里一个指标的结果(engine/nodes/dual_check.py 的 SAME / DIFFERENT / UNCHECKED),原样交给界面
+91 -3
View File
@@ -3,10 +3,15 @@
不另建存储:ActionRecord 本身带着工作流、版本、graph_hash、能力版本、payload_hash、回执;「口径 → 应用」就是固定这一版的
应用版本。用在发布弹窗、退役确认、版本列表三处。
口径账本(docs/design/next-round/02-口径账本.md)在这里加两个只读接口:
口径账本(docs/design/next-round/02-口径账本.md)在这里加的接口:
- GET /api/runs/{run_id}/ledger(runs_router,和证据同一道门):一次运行里每个数的口径标签、整题判断、每条查询的比对、能做的
动作(app/ledger/view.py);
- GET /api/ledger/stats(router,搭建者):账本自身给出的统计 E1–E4,从比对工件、运行、审计现算(app/ledger/stats.py)。
动作、出具过的正式运行、对过的账(app/ledger/view.py);
- GET /api/ledger/stats(router,搭建者):账本自身给出的统计 E1–E4,从比对工件、运行、审计现算(app/ledger/stats.py);
- POST /api/ledger/answers/{run_id}/issue(answers_router,登录即可,第 3 波):按口径正式出具(app/ledger/issue.py,拍板
L1、L5:参数只从比对工件取,使用者只能按他有权使用的应用固定着的版本出具);
- POST /api/ledger/answers/{run_id}/reconcile(同上):口径对账,点开才跑一次确定性派生的只读图(app/ledger/reconcile.py);
- GET /api/ledger/certify-queue、POST /api/ledger/certify-queue/dismiss(router,搭建者,第 3 波):可沉淀的问题、「不是口径」
(app/ledger/certify.py)。这几个新接口都不放给访问密钥(auth/keys.KEY_ROUTES 不登记)。
"""
from __future__ import annotations
@@ -15,6 +20,7 @@ from typing import Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -100,3 +106,85 @@ async def ledger_stats(since: datetime | None = Query(default=None, description=
origins = [o.strip() for o in (origin or "").split(",") if o.strip()] or None
return await compute_stats(session, since=since, until=until, origins=origins, principal=current_principal())
class IssueIn(BaseModel):
"""按哪份口径出具:只能选口径,参数由服务端从比对工件里取(不收客户端传的参数)。"""
workflow_id: str = Field(min_length=1, max_length=32)
version: int = Field(ge=1)
@answers_router.post("/{run_id}/issue")
async def issue_answer(run_id: str, payload: IssueIn) -> dict[str, Any]:
"""按口径正式出具这次问数据的答案(app/ledger/issue.py):经 launch_run 发起正式运行,来源 ledger_issue。同一运行、同一
口径、同一组参数只发起一次,再点交回已经发起的那一次(created 为假)。不能出具时回 409(整题不等价、版本退役、有人工
节点或写入、缺入口字段……)、403(权限不够:使用者只能按他有权使用的应用固定着的版本出具)、429(超过每日上限),
detail 写明原因。"""
from app.api.coded import CodedHTTPException
from app.api.deps import current_principal
from app.ledger import issue as ledger_issue
try:
run, created = await ledger_issue.issue(run_id, payload.workflow_id, payload.version,
principal=current_principal())
except ledger_issue.IssueRefused as e:
raise CodedHTTPException(e.status_code, e.message, e.code) from e
return {"run_id": run.id, "created": created, "issued": ledger_issue.issued_view(run)}
class ReconcileIn(BaseModel):
"""对哪条查询、和哪份相关口径对账。inputs + confirm:参数要核对时(动作里 needs_check)人核对、改过的参数;pairs:认不出
哪一列对哪个指标时人选的 [{ours: 这次的列, metric: 口径卡上的指标 id}](动作里写明要不要选)。"""
query: str = Field(min_length=1, max_length=64)
workflow_id: str = Field(min_length=1, max_length=32)
version: int = Field(ge=1)
inputs: dict[str, Any] | None = None
confirm: bool = False
pairs: list[dict[str, Any]] | None = None
@answers_router.post("/{run_id}/reconcile", status_code=201)
async def reconcile_answer(run_id: str, payload: ReconcileIn) -> dict[str, Any]:
"""口径对账(app/ledger/reconcile.py):按那一版口径确定性派生只读图,发起一次探索运行(来源 reconcile),差值在对账卡
里算。参数是推断出来的、不全的要先核对(409,code ledger_reconcile_check_inputs);认不出列怎么配的要人选(409,
ledger_reconcile_needs_pairs);多行时维度是遮罩列、键不唯一照实拒绝(409)。"""
from app.api.coded import CodedHTTPException
from app.api.deps import current_principal
from app.ledger import reconcile as ledger_reconcile
try:
run = await ledger_reconcile.reconcile(run_id, ledger_reconcile.Request(
query=payload.query, workflow_id=payload.workflow_id, version=payload.version, inputs=payload.inputs,
confirm=payload.confirm, pairs=payload.pairs), principal=current_principal())
except ledger_reconcile.ReconcileRefused as e:
raise CodedHTTPException(e.status_code, e.message, e.code) from e
return {"run_id": run.id, "reconciled": ledger_reconcile.reconciled_view(run)}
@router.get("/certify-queue")
async def certify_queue(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""可沉淀的问题(认证队列,app/ledger/certify.py):确定性重复的 SQL 形状、问题聚类、被反复用的答案。每项给代表问题、
轮次、(形状的)参数候选和相关口径;点过「不是口径」的不再列。固化走 POST /api/copilot/from-run(freeze: true)。"""
from app.api.deps import current_principal
from app.ledger import certify
return await certify.queue(session, principal=current_principal())
class DismissIn(BaseModel):
item_id: str = Field(min_length=1, max_length=64)
kind: str | None = Field(default=None, max_length=20)
note: str | None = Field(default=None, max_length=500)
@router.post("/certify-queue/dismiss")
async def certify_dismiss(payload: DismissIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""「不是口径」:记一条审计(ledger.certify.dismiss),这一项之后不再进队。"""
from app.ledger import certify
if payload.kind is not None and payload.kind not in certify.KINDS:
raise HTTPException(422, "可沉淀的问题只有三种:确定性重复、问题聚类、被反复用的答案")
await certify.dismiss(session, payload.item_id, kind=payload.kind, note=payload.note)
return {"ok": True, "item_id": payload.item_id}
+62
View File
@@ -718,6 +718,61 @@ async def apps_settings(session: AsyncSession) -> dict[str, Any]:
return apps_values(row.value if row else None)
# --------------------------------------------------------------------------
# 口径账本:按口径自动正式出具的开关、每日上限,口径比对的时限(docs/design/next-round/02-口径账本.md 做法 5、2)
# --------------------------------------------------------------------------
#: 设置里 ledger 这一组。auto_issue:问数据的答案整题等价于受管口径(Agent 独立写的 SQL 被证明等价、版本受管、图里没有人工
#: 节点和写入)时自动按口径发起一次正式运行(拍板 L1;不满足的只给一键)。issue_daily_limit:每天最多按口径正式出具几次
#: (自动的、一键的合计,0 表示不再出具):正式运行含报告撰写的模型费用。match_timeout_s:一次运行的口径比对最多算几秒,
#: 超时的照实记没比完(ledger/match.py)。召回的上限在第 4 波加。只有管理员能改(整个设置接口的改动都要管理员)
LEDGER_GROUP = "ledger"
LEDGER_DEFAULTS: dict[str, Any] = {"auto_issue": True, "issue_daily_limit": 20, "match_timeout_s": 8}
LEDGER_SETTING_LABEL = {"auto_issue": "自动按口径正式出具", "issue_daily_limit": "按口径正式出具每日上限",
"match_timeout_s": "口径比对时限(秒)"}
#: 时限的范围:太短比不完一份口径,太长占着比对的线程
LEDGER_TIMEOUT_RANGE = (1, 60)
def _timeout_ok(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) \
and LEDGER_TIMEOUT_RANGE[0] <= value <= LEDGER_TIMEOUT_RANGE[1]
def ledger_problem(value: Any) -> str | None:
"""设置页写进来的 ledger 这一组有什么问题,没问题返回 None。"""
if not isinstance(value, dict):
return f"「口径账本」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key not in LEDGER_DEFAULTS:
return (f"「口径账本」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{LEDGER_SETTING_LABEL[k]}」" for k in LEDGER_DEFAULTS))
if key == "auto_issue" and not isinstance(item, bool):
return f"「{LEDGER_SETTING_LABEL[key]}」只能是开或关,当前为「{item}」"
if key == "issue_daily_limit" and not _daily_limit_ok(item):
return (f"「{LEDGER_SETTING_LABEL[key]}」需要是 0 到 {APPS_DAILY_LIMIT_MAX:,} 之间的整数(0 表示不再按口径出具),"
f"当前为「{item}」")
if key == "match_timeout_s" and not _timeout_ok(item):
return (f"「{LEDGER_SETTING_LABEL[key]}」需要是 {LEDGER_TIMEOUT_RANGE[0]} 到 {LEDGER_TIMEOUT_RANGE[1]} 之间的数,"
f"当前为「{item}」")
return None
def ledger_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)ledger 一组 → 补齐默认;存坏了的项按默认(自动出具的开关存坏了按关,宁可少出具)。"""
stored = stored if isinstance(stored, dict) else {}
auto, limit, timeout = stored.get("auto_issue"), stored.get("issue_daily_limit"), stored.get("match_timeout_s")
return {"auto_issue": auto if isinstance(auto, bool) else (LEDGER_DEFAULTS["auto_issue"] if auto is None else False),
"issue_daily_limit": limit if _daily_limit_ok(limit) else LEDGER_DEFAULTS["issue_daily_limit"],
"match_timeout_s": timeout if _timeout_ok(timeout) else LEDGER_DEFAULTS["match_timeout_s"]}
async def ledger_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 ledger 一组,没存过的补默认。按口径出具、比对从这里读。"""
row = await session.get(Setting, LEDGER_GROUP)
return ledger_values(row.value if row else None)
# --------------------------------------------------------------------------
# 用户设置
# --------------------------------------------------------------------------
@@ -763,6 +818,8 @@ DEFAULT_SETTINGS: dict[str, Any] = {
CATALOG_DRAFT_GROUP: catalog_draft_values(None),
# 应用:按需视图的每日上限、使用者能不能建个人应用(管理员在设置页改)
APPS_GROUP: apps_values(None),
# 口径账本:自动按口径正式出具的开关、每日上限,口径比对的时限
LEDGER_GROUP: ledger_values(None),
}
@@ -825,6 +882,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
merged[PUBLIC_URL_GROUP] = public_url_values(stored.get(NOTIFY_GROUP))
merged[CATALOG_DRAFT_GROUP] = catalog_draft_values(stored.get(CATALOG_DRAFT_GROUP))
merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP))
merged[LEDGER_GROUP] = ledger_values(stored.get(LEDGER_GROUP))
for key, value in stored.items():
merged.setdefault(key, value)
return merged
@@ -924,6 +982,10 @@ async def put_settings(
if problem := apps_problem(payload.values[APPS_GROUP]):
raise HTTPException(422, problem)
payload.values[APPS_GROUP] = apps_values(payload.values[APPS_GROUP])
if LEDGER_GROUP in payload.values:
if problem := ledger_problem(payload.values[LEDGER_GROUP]):
raise HTTPException(422, problem)
payload.values[LEDGER_GROUP] = ledger_values(payload.values[LEDGER_GROUP])
if found := await _provider_problem(session, payload.values):
raise CodedHTTPException(422, found[1], PROVIDER_INVALID, field=found[0])
for key, value in payload.values.items():
+4
View File
@@ -576,6 +576,10 @@ async def commit_publish(session: AsyncSession, workflow: Workflow, snapshot: Wo
workflow.published_version = snapshot.version
workflow.published_by = actor
await session.commit()
# 口径账本(next-round 02 做法 8):从问数据固化出的口径发布了,来源运行在后台按它补比一次(不挡发布)
from app.ledger.certify import after_publish
after_publish(workflow.id, workflow.origin)
return {
"ok": True,
"level": level,
+12 -5
View File
@@ -107,19 +107,26 @@ async def _upgrade(session: AsyncSession, app: Any, version: AppVersion, view_id
async def _side_effect(session: AsyncSession, state: Any) -> str | None:
"""新版本的图补算时会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。
作为流程固定的一律算;别的按图判(graph_side_effect)。"""
if state.pin.get("use") == "process":
return "是作为流程固定的"
return await graph_side_effect(session, state.graph, level=state.level)
async def graph_side_effect(session: AsyncSession, graph_json: Any, *, level: str | None = None) -> str | None:
"""一张图替人重跑一次会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。按新口径
补算(这里)和按口径正式出具(ledger/issue.py:自动出具、一键出具都不代人做这些事)共用这一个判断。
判断沿用应用检查对流程钉住项的那一套(apps/lint._lint_process):人工节点、写外部系统的能力
(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)、process.* 问题(子工作流先读好,
process.human_in_subgraph 才查得到)。"""
process.human_in_subgraph 才查得到)。level 是那一版发布时的级别(门禁按它查),没有按 published。"""
from app.capabilities.registry import effective_effect, get_capability, writes_externally
from app.engine.governance import publish_issues
from app.engine.lint_process import load_subgraphs, with_subgraphs
from app.engine.schema import GraphSpec, NodeType
if state.pin.get("use") == "process":
return "是作为流程固定的"
try:
graph = GraphSpec.model_validate(state.graph)
graph = GraphSpec.model_validate(graph_json)
except ValueError:
return "的图无法解析"
if humans := [n.title for n in graph.nodes if n.type == NodeType.HUMAN]:
@@ -132,7 +139,7 @@ async def _side_effect(session: AsyncSession, state: Any) -> str | None:
if cap is not None and writes_externally(cap.name, effective_effect(cap), cap.endpoint_id):
return f"的「{node.title}」会写外部系统({name})"
with with_subgraphs(await load_subgraphs(session, graph)):
issues = publish_issues(graph, level=state.level or "published")
issues = publish_issues(graph, level=level or "published")
if process := [i for i in issues if (i.code or "").startswith("process.")]:
return f"按流程使用不安全:{process[0].message.rstrip('。')}"
return None
+9 -5
View File
@@ -65,7 +65,7 @@ from app.engine.evidence import DEFAULT_FORMAT, RenderError, is_number, render_n
from app.engine.expressions import ExpressionError, cell_value, leaf_refs, parse_expression
from app.engine.labels import TYPE_LABEL, field_label
from app.engine.merge_query import MERGE_SOURCE
from app.engine.nodes.metrics import _REF_HEAD, _display_of
from app.engine.nodes.metrics import _REF_HEAD, _display_of, source_expression
from app.engine.toolcalls import QUERY_PREFIX
UNCHANGED = "unchanged"
@@ -568,9 +568,13 @@ def _shown(value: Any) -> str:
return text if len(text) <= _SHOWN_MAX else text[:_SHOWN_MAX] + "…"
def _expression(metric: dict[str, Any]) -> str:
"""表达式的规范写法:空白、括号写法不同的算同一个。解析不了的照原文。"""
raw = str(metric.get("expression") or "").strip()
def _expression(metric: dict[str, Any], source: Any = None) -> str:
"""表达式的规范写法:空白、括号写法不同的算同一个。解析不了的照原文。
source 是这张卡的来源(指标集上的 source):钉住别处口径卡、取数节点拷进图里时改了名的(caliber_from.nodes,
next-round 02 做法 4),先按映射换回那一版里的写法再比——同一份口径换了节点名不算口径变了;取数本身变没变照旧按
输入的出处说。"""
raw = source_expression(str(metric.get("expression") or "").strip(), source)
try:
return ast.unparse(parse_expression(raw)[0])
except (ExpressionError, ValueError):
@@ -1715,7 +1719,7 @@ def _caliber_reasons(bm: dict[str, Any], tm: dict[str, Any], bc: dict[str, Any],
defs: tuple[dict[str, Any] | None, dict[str, Any] | None] = (None, None)) -> list[str]:
"""口径变了的地方,一处一句;没变返回空列表。defs 是两次运行图里这个指标的定义(老卡没记小数位、格式时用)。"""
out = []
if _expression(bm) != _expression(tm):
if _expression(bm, bc.get("source")) != _expression(tm, tc.get("source")):
out.append(f"表达式由「{_shown(bm.get('expression'))}」改为「{_shown(tm.get('expression'))}」")
before, after = str(bc.get("caliber_version") or ""), str(tc.get("caliber_version") or "")
if before != after:
+4
View File
@@ -699,6 +699,10 @@ async def unresolved_caliber_upgrades(
ref = node.config["caliber_from"]
pinned, wf_id = ref.get("workflow_version"), ref.get("workflow_id")
extra = {"caliber_node": ref.get("node_id")}
if isinstance(ref.get("nodes"), dict) and ref["nodes"]:
# 拷进这张图时改了名的取数节点(next-round 02 做法 4):升版事件照记映射,证据面板说得清这张卡读的是哪几个
# 节点;升不升版、怎么处置和没改名的卡同一套规则
extra["caliber_nodes"] = dict(ref["nodes"])
what = f"「{node.title}」(口径卡)引用的口径卡固定在 v{pinned}"
else:
continue
+81 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import ast
import json
import math
import re
@@ -282,8 +283,87 @@ async def _pinned_caliber(ctx: NodeContext) -> tuple[dict[str, Any], dict[str, A
raise NodeError(ctx.node.id, f"「{name}」v{version} 中的「{title}」本身也引用自其他工作流的口径卡(源头是 {where})。"
"请直接固定到源头的口径卡,否则升版时无法正确比对版本")
source = {"workflow_id": wf_id, "workflow_version": version, "node_id": node_id}
metrics = cfg.get("metrics") or []
rename = ref.get("nodes") if isinstance(ref, dict) else None
if rename not in (None, {}):
# 节点改名映射(next-round 02 做法 4):那一版的式子读 nodes.fetch,这张图里拷过来的取数节点叫 c1_fetch。式子按
# 语法树把 nodes.<原 id> 的根换成新 id 再算;来源里照记映射,证据、对比、升版处置据此认得是同一份口径
if problem := rename_problem(rename):
raise NodeError(ctx.node.id, f"「{field_label('caliber_from')}」的节点改名映射有误:{problem}")
metrics = [rename_definition(m, rename) for m in metrics]
source["nodes"] = dict(rename)
return {"caliber": cfg.get("caliber") or title, "caliber_version": str(cfg.get("caliber_version") or "v1"),
"metrics": cfg.get("metrics") or []}, source
"metrics": metrics}, source
#: 改名映射里的节点 id:要能写进式子的取值链(nodes.<id>),和画布上的节点 id 同一种写法
_NODE_ID = re.compile(r"^[A-Za-z_][\w-]*$")
def rename_problem(rename: Any) -> str | None:
"""caliber_from.nodes 的形状对不对:{原 id: 新 id},两边都是非空的节点 id,新 id 不重复。对的是 None。
画布校验(engine/schema._caliber_from_issues)和运行时同一个判断;新 id 在不在这张图里由校验另查。"""
if not isinstance(rename, dict) or not rename:
return "需要写成「原节点 id → 这张图里的节点 id」的对照"
seen: set[str] = set()
for old, new in rename.items():
if not isinstance(old, str) or not _NODE_ID.match(old) or not isinstance(new, str) or not _NODE_ID.match(new):
return f"「{old}」→「{new}」不是节点 id 的写法"
if new in seen:
return f"有两个节点都改名成了「{new}」"
seen.add(new)
return None
class _RenameNodes(ast.NodeTransformer):
"""nodes.<原 id>(含 cell(nodes.<原 id>, …) 的第一个参数、nodes['原 id'])的根换成新 id。别的根(vars、input)不动。"""
def __init__(self, rename: dict[str, str]) -> None:
self.rename = rename
self.hits = 0
def visit_Attribute(self, node: ast.Attribute) -> ast.AST:
if isinstance(node.value, ast.Name) and node.value.id == "nodes" and node.attr in self.rename:
self.hits += 1
return ast.copy_location(ast.Attribute(value=node.value, attr=self.rename[node.attr], ctx=node.ctx), node)
return self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
key = node.slice
if isinstance(node.value, ast.Name) and node.value.id == "nodes" and isinstance(key, ast.Constant) \
and isinstance(key.value, str) and key.value in self.rename:
self.hits += 1
return ast.copy_location(ast.Subscript(value=node.value, slice=ast.Constant(self.rename[key.value]),
ctx=node.ctx), node)
return self.generic_visit(node)
def rename_expression(expression: str, rename: dict[str, str]) -> str:
"""把式子里 nodes.<原 id> 的根按映射换成新 id(语法树上换,字符串字面量里的字不动)。一处都没换到、解析不了的原样交回:
解析不了的式子后面求值时照常报「表达式有误」。"""
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError:
return expression
renamer = _RenameNodes(rename)
tree = ast.fix_missing_locations(renamer.visit(tree))
return ast.unparse(tree) if renamer.hits else expression
def rename_definition(definition: Any, rename: dict[str, str]) -> Any:
"""一个指标定义按映射改写式子(别的项原样)。不是对象的原样交回,后面照常报「指标定义不完整」。"""
if not isinstance(definition, dict) or not isinstance(definition.get("expression"), str):
return definition
return {**definition, "expression": rename_expression(definition["expression"], rename)}
def source_expression(expression: str, source: Any) -> str:
"""卡上记的式子换回那一版里的写法(来源里有改名映射时按映射反着换):对比两次运行的口径(engine/compare)按它比,
同一份口径拷进图里时取数节点改了名,不算口径变了。没有映射的原样交回。"""
rename = source.get("nodes") if isinstance(source, dict) else None
if not isinstance(rename, dict) or not rename or rename_problem(rename) is not None:
return expression
return rename_expression(expression, {new: old for old, new in rename.items()})
#: 新写法的小数位范围。负数是取整到十位、百位(round(x, -2))
+22 -3
View File
@@ -1443,7 +1443,7 @@ def evidence_issues(spec: GraphSpec) -> list[ValidationIssue]:
"evidence_role", level="error")
elif node.type == NodeType.METRICS:
if cfg.get("caliber_from") not in (None, "", {}):
out.extend(_caliber_from_issues(node, UPGRADE_POLICIES, UPGRADE_POLICY_LABELS))
out.extend(_caliber_from_issues(node, UPGRADE_POLICIES, UPGRADE_POLICY_LABELS, spec))
warned: set[str] = set()
for i, producer in _caliber_inputs(node, spec):
role = str(producer.config.get("evidence_role") or "compute")
@@ -1457,8 +1457,12 @@ def evidence_issues(spec: GraphSpec) -> list[ValidationIssue]:
return out
def _caliber_from_issues(node: GraphNode, policies: set[str], labels: dict[str, str]) -> list[ValidationIssue]:
"""钉住别的工作流里一张口径卡:{workflow_id, workflow_version, node_id} 三项都要写对。"""
def _caliber_from_issues(node: GraphNode, policies: set[str], labels: dict[str, str],
spec: GraphSpec | None = None) -> list[ValidationIssue]:
"""钉住别的工作流里一张口径卡:{workflow_id, workflow_version, node_id} 三项都要写对。
可选的 nodes(节点改名映射,next-round 02 做法 4):那一版口径卡读的取数节点拷进这张图时改了名,{原 id: 这张图里的 id}。
形状和运行时同一个判断(metrics.rename_problem);改名后的节点要在这张图里、在口径卡上游——不然运行时口径卡取不到输入。"""
cfg = node.config
ref = cfg.get("caliber_from")
out: list[ValidationIssue] = []
@@ -1475,6 +1479,21 @@ def _caliber_from_issues(node: GraphNode, policies: set[str], labels: dict[str,
or int(version) < 1:
add(f"「{field_label('caliber_from')}」需要选择具体的版本号(1、2…),当前为「{version}」:不固定版本,"
"口径会随上游变化", "caliber_from.workflow_version")
if ref.get("nodes") not in (None, {}):
from app.engine.nodes.metrics import rename_problem
if problem := rename_problem(ref.get("nodes")):
add(f"「{field_label('caliber_from')}」的节点改名映射有误:{problem}", "caliber_from.nodes")
elif spec is not None:
nodes = spec.node_map()
upstream = _ancestors(spec, node.id)
for old, new in ref["nodes"].items():
if new not in nodes:
add(f"「{field_label('caliber_from')}」把取数节点「{old}」改名为「{new}」,但工作流中没有节点「{new}」",
"caliber_from.nodes")
elif new not in upstream:
add(f"「{field_label('caliber_from')}」改名后的取数节点「{nodes[new].title}」不在口径卡的上游:口径卡计算时"
"它还没有执行。请把它连到口径卡之前", "caliber_from.nodes")
if cfg.get("metrics"):
add(f"已设置「{field_label('caliber_from')}」,将按所引用的口径卡计算,此处填写的「{field_label('metrics')}」"
"不会生效", "metrics", level="warning")
+549
View File
@@ -0,0 +1,549 @@
"""沉淀:可沉淀的问题(认证队列)和「固化成口径草稿」(docs/design/next-round/02-口径账本.md 做法 8,拍板 L9)。
同一类问题被反复问、Agent 反复写出同一个形状的 SQL,就该沉淀成一份口径:以后再问,比对器认得出「等价已证明」,还能按口径
正式出具。以前的 from-run 提取保留 Agent,沉淀出的模板没有固定 SQL,以后永远比对不上;聚类又混着 history。
**认证队列**(GET /api/ledger/certify-queue,搭建者及以上)三种信号:
- 确定性重复(shape):问数据的探索运行里执行过、被报告引用(比对工件里标签记着的查询)、又没对上任何口径(none / related)
的查询,按(数据源、normalize_sql 的规范文本)分组;出现在 ≥ REPEAT_RUNS 次运行、来自 ≥ REPEAT_CONVERSATIONS 段对话的进队。
跨运行变化的字面量位置是参数候选,不变的是口径常量。展示的是规范文本(字面量都换成了「?」,不带取值);
- 问题聚类(cluster):app/chat/questions 全站唯一的聚类(只取问题原文、不含 history、排除系统派生的运行),≥ REPEAT_RUNS
个问题的类;
- 被反复用的答案(reused):导出过 ≥ 2 次(report.exported 审计,第 2 波)、做成过应用(蓝图的来源是这次运行)、在画布里
打开过或提取过(工作流的 origin.run_id 是这次运行)的轮次。
每项给代表问题、轮次链接、(shape 的)SQL 形状和参数候选、有没有相关口径(可以对账的那份)、是不是已经固化过。点「不是口径」
写一条审计(ledger.certify.dismiss,subject 是这一项的 id),之后不再进队。门槛同 ledger/stats(E3 的重复度)。
**固化成口径草稿**(freeze,from-run 加 freeze 选项后转调它):代表运行里报告引用了的查询,各变成一个调用工具节点——SQL 用
规范化后的文本,跨轮变化的字面量换成 '{{ input.p1 }}'(同组的别的运行 peers 用来认哪些位置变了),入口字段的缺省值取
代表运行里的值;只有一行的结果每个数值列建一项口径卡指标(cell(nodes.取数, 0, '列'));报告节点保留,出口契约写
report_from + metrics_from(有多行结果的另加 cells: true),过 validate_graph。新工作流记 origin.sources:
[{run_id, conversation_id, turn_id, question}],这就是「口径 → 来源问题」的记录。之后由人在画布上审改、按受管级别发布——
提取器是作者,不是发布者。
**发布之后补比**(after_publish):工作流发布时,origin.sources 里的来源运行在后台按现在的口径补比一次(trigger=certified,
标「按之后发布的口径回看」),E1 也就有了回溯数据。
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from app.db.models import Run
logger = logging.getLogger(__name__)
#: 门槛:同一个形状出现在几次运行里、来自几段对话;被导出几次算反复用(同 ledger/stats 的 E3)
REPEAT_RUNS = 3
REPEAT_CONVERSATIONS = 2
EXPORTS = 2
#: 看最近多少次问数据运行、每项最多列几轮
RUN_LIMIT = 500
TURNS_LIMIT = 10
QUESTION_LIMIT = 500
#: 「不是口径」的审计
DISMISS_ACTION = "ledger.certify.dismiss"
SUBJECT_KIND = "ledger_certify"
KINDS = ("shape", "cluster", "reused")
#: 固化被拒的说法
NOT_FOUND = "运行记录不存在,可能已被删除"
NOT_MATCHED = "这次运行还没有比对口径,比对完才能固化(打开它的口径账本会按需比对)"
NOT_SUCCEEDED = "这次运行没有成功结束,提取出的查询不完整"
NO_QUERIES = "这次运行的报告没有引用任何查询结果,没有可固化的取数"
UNPARSED = "这次的 SQL 无法规范化({reason}),不能固化成固定的取数"
SNAPSHOT = "查询快照读不出来(文件缺失或与哈希不一致),不能固化"
#: 新工作流的说明、报告指令
DRAFT_NAME = "{question}(固化的口径草稿)"
DRAFT_DESCRIPTION = ("从问数据的回答固化:报告引用了的 {n} 条查询变成固定的取数,跨轮变化的 {p} 个字面量成为入口参数。"
"经人工审核、按受管级别发布后,再问同样的问题就能认出等价、按口径出具")
REPORT_INSTRUCTIONS = "回答:{question}"
class CertifyRefused(Exception):
def __init__(self, status_code: int, message: str) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
def item_id(kind: str, key: str) -> str:
return f"{kind}:{hashlib.sha256(key.encode()).hexdigest()[:16]}"
# ==========================================================================
# SQL 的形状和参数
# ==========================================================================
@dataclass(frozen=True)
class Shape:
"""一条查询的形状:数据源、规范文本(字面量换成「?」)、字面量(SQL 写法,字符串带引号)、规范化失败的原因。"""
source: str
text: str
literals: tuple[str, ...]
problem: str | None = None
def shape_of(snap: Any, kinds: dict[str, str | None]) -> Shape | None:
from app.data.sql_semantics import normalize_sql
if not isinstance(snap, dict) or not isinstance(snap.get("sql"), str) or not snap["sql"].strip():
return None
source = str(snap.get("source") or "")
norm = normalize_sql(snap["sql"], kinds.get(source))
return Shape(source, norm.text, norm.literals, norm.parse_error)
def varying(literals: Sequence[Sequence[str]]) -> list[int]:
"""几次运行同一个形状的字面量:哪几个位置跨运行变过(参数候选),不变的是口径常量。长度不一致的那几份不算。"""
rows = [list(row) for row in literals]
if not rows:
return []
width = len(rows[0])
same = [r for r in rows if len(r) == width]
return [i for i in range(width) if len({r[i] for r in same}) > 1]
def _unquote(literal: str) -> str | None:
"""'2026-09-01' → 2026-09-01;不是普通单引号字符串(N'…'、x'…'、$$…$$)的为 None。"""
if len(literal) >= 2 and literal[0] == "'" and literal[-1] == "'":
return literal[1:-1].replace("''", "'")
return None
def _number(literal: str) -> int | float | None:
try:
value = float(literal)
except ValueError:
return None
return int(value) if value.is_integer() and "." not in literal and "e" not in literal.lower() else value
def template_of(shape: Shape, params: Sequence[int], dialect: str | None, *,
start: int = 1) -> tuple[str, list[dict[str, Any]]]:
"""规范文本 + 参数位置 → (口径的取数 SQL 模板, 入口字段)。参数位置的字面量换成 '{{ input.pN }}'(字符串)或
{{ input.pN }}(数,N 从 start 起),缺省值是这一次的值;别的位置照原样写回字面量。规范文本按方言重新切词,按「?」的
先后对上字面量。"""
from app.data.sql_semantics import PLACEHOLDER, _read, _tokens
tokens = [t for t in _tokens(shape.text, _read(dialect)) if shape.text[t.start:t.end + 1] == PLACEHOLDER]
if len(tokens) != len(shape.literals):
raise CertifyRefused(409, UNPARSED.format(reason="字面量对不上"))
out: list[str] = []
fields: list[dict[str, Any]] = []
pos = 0
wanted = set(params)
for i, (token, literal) in enumerate(zip(tokens, shape.literals)):
out.append(shape.text[pos:token.start])
pos = token.end + 1
text, number = _unquote(literal), _number(literal)
if i in wanted and (text is not None or number is not None):
name = f"p{start + len(fields)}"
if text is not None:
out.append(f"'{{{{ input.{name} }}}}'")
fields.append({"name": name, "type": "string", "required": True, "default": text})
else:
out.append(f"{{{{ input.{name} }}}}")
fields.append({"name": name, "type": "number", "required": True, "default": number})
else:
out.append(literal)
out.append(shape.text[pos:])
return "".join(out), fields
# ==========================================================================
# 认证队列
# ==========================================================================
@dataclass
class _Member:
run_id: str
artifact: str
literals: tuple[str, ...]
conversation_id: str | None
turn_id: str | None
question: str
related: list[dict[str, Any]] = field(default_factory=list)
def referenced_queries(content: dict[str, Any]) -> list[str]:
"""比对工件里被报告引用了的查询(标签记着的:单元格的 query、指标的 queries),按先后去重。"""
out: list[str] = []
for report in (content.get("reports") or {}).values():
for label in (report.get("labels") or {}).values():
for artifact in [label.get("query"), *(label.get("queries") or [])]:
if isinstance(artifact, str) and artifact and artifact not in out:
out.append(artifact)
return out
async def _turns(session: Any, runs: list[Run]) -> dict[str, dict[str, Any]]:
"""运行 → {conversation_id, turn_id, question}:运行来源里封存的(origin_ref);老运行退回 conversation_turns.run_id。"""
from app.db.models import ConversationTurn
out: dict[str, dict[str, Any]] = {}
ids = [r.id for r in runs]
if ids:
for turn in (await session.execute(select(ConversationTurn).where(ConversationTurn.run_id.in_(ids)))).scalars():
out.setdefault(turn.run_id, {"conversation_id": turn.conversation_id, "turn_id": turn.id,
"question": turn.question or ""})
for run in runs:
origin = run.origin if isinstance(run.origin, dict) else {}
found = out.setdefault(run.id, {"conversation_id": None, "turn_id": None, "question": ""})
if origin.get("conversation_id"):
found["conversation_id"] = str(origin["conversation_id"])
found["turn_id"] = str(origin.get("turn_id") or found.get("turn_id") or "") or None
if not found["question"] and isinstance(run.input, dict) and isinstance(run.input.get("question"), str):
found["question"] = run.input["question"]
return out
async def _dismissed(session: Any) -> set[str]:
from app.db.models import AuditEvent
return {sid for (sid,) in (await session.execute(select(AuditEvent.subject_id).where(
AuditEvent.action == DISMISS_ACTION, AuditEvent.subject_kind == SUBJECT_KIND))).all() if sid}
async def _frozen(session: Any) -> dict[str, list[dict[str, Any]]]:
"""来源运行 → 从它固化出的工作流 [{workflow_id, name, status, published_version}](origin.sources)。"""
from app.db.models import Workflow
out: dict[str, list[dict[str, Any]]] = {}
for wf in (await session.execute(select(Workflow).where(Workflow.origin.is_not(None)))).scalars():
for src in (wf.origin or {}).get("sources") or [] if isinstance(wf.origin, dict) else []:
if isinstance(src, dict) and src.get("run_id"):
out.setdefault(str(src["run_id"]), []).append(
{"workflow_id": wf.id, "name": wf.name, "status": wf.status,
"published_version": wf.published_version})
return out
def _turn_view(member: Any) -> dict[str, Any]:
return {"run_id": member.run_id, "conversation_id": member.conversation_id, "turn_id": member.turn_id,
"question": member.question}
async def queue(session: Any, *, principal: Any) -> dict[str, Any]:
"""认证队列(见模块说明):{items: [...], threshold, considered}。只读。"""
from app.chat.questions import cluster_questions, embedder, recent_questions
from app.core.artifact_store import load
from app.db.models import Artifact, DataSource
from app.engine.origin import counted_runs
from app.ledger import store
dismissed = await _dismissed(session)
frozen = await _frozen(session)
kinds = {name: kind for name, kind in (await session.execute(select(DataSource.name, DataSource.kind))).all()}
runs = list((await session.execute(select(Run).where(
Run.origin_kind == "chat", Run.status == "succeeded", Run.run_class != "formal", counted_runs())
.order_by(Run.created_at.desc()).limit(RUN_LIMIT))).scalars())
turns = await _turns(session, runs)
by_run = {r.id: r for r in runs}
# 每次运行最新的一份比对(store.latest 按事件认这一次封存的;这里只要内容,按工件引用的时间取最新)
latest: dict[str, tuple[Any, str]] = {}
if runs:
for artifact, run_id, at in (await session.execute(select(Artifact.id, Artifact.run_id, Artifact.created_at)
.where(Artifact.kind == store.KIND,
Artifact.run_id.in_(list(by_run))))).all():
if run_id not in latest or (at and latest[run_id][0] and at > latest[run_id][0]):
latest[run_id] = (at, artifact)
def read() -> dict[tuple[str, str], list[_Member]]:
groups: dict[tuple[str, str], list[_Member]] = {}
for run_id, (_, artifact) in latest.items():
try:
content = load(artifact)
except ValueError:
continue
if not isinstance(content, dict) or content.get("format") != store.FORMAT:
continue
statuses = {q.get("artifact"): q for q in content.get("queries") or []}
for query in referenced_queries(content):
row = statuses.get(query) or {}
if row.get("status") not in ("none", "related"):
continue
try:
shape = shape_of(load(query), kinds)
except ValueError:
shape = None
if shape is None or shape.problem:
continue
info = turns.get(run_id) or {}
related = [{k: c.get(k) for k in ("caliber", "workflow_id", "version", "level")}
for c in row.get("candidates") or [] if c.get("status") == "related"]
groups.setdefault((shape.source, shape.text), []).append(_Member(
run_id, query, shape.literals, info.get("conversation_id"), info.get("turn_id"),
str(info.get("question") or ""), related))
return groups
groups = await asyncio.to_thread(read)
items: list[dict[str, Any]] = []
for (source, text), members in groups.items():
once = list({m.run_id: m for m in members}.values())
conversations = {m.conversation_id or f"run:{m.run_id}" for m in once}
if len(once) < REPEAT_RUNS or len(conversations) < REPEAT_CONVERSATIONS:
continue
iid = item_id("shape", f"{source}\n{text}")
if iid in dismissed:
continue
params = varying([m.literals for m in once])
related = list({r["caliber"]: r for m in once for r in m.related if r.get("caliber")}.values())
rep = once[0]
items.append({
"id": iid, "kind": "shape", "source": source, "shape": text, "runs": len(once),
"conversations": len(conversations), "question": rep.question,
"turns": [_turn_view(m) for m in once[:TURNS_LIMIT]],
"params": [{"index": i, "distinct": len({m.literals[i] for m in once if len(m.literals) > i})}
for i in params],
"constants": len(rep.literals) - len(params), "related": related,
"representative": {"run_id": rep.run_id, "query": rep.artifact},
"peers": [m.run_id for m in once[1:]],
"frozen_into": [w for m in once for w in frozen.get(m.run_id, [])],
})
# 问题聚类
questions = await recent_questions(session, principal, limit=QUESTION_LIMIT)
for cluster in await cluster_questions(questions) if questions else []:
if cluster.count < REPEAT_RUNS:
continue
seed = cluster.members[0]
iid = item_id("cluster", " ".join(seed.text.split()))
if iid in dismissed:
continue
items.append({"id": iid, "kind": "cluster", "question": seed.text, "count": cluster.count,
"turns": [{"run_id": q.run_id, "turn_id": q.turn_id, "question": q.text}
for q in cluster.members[:TURNS_LIMIT]],
"frozen_into": [w for q in cluster.members if q.run_id for w in frozen.get(q.run_id, [])]})
items += await _reused(session, runs, turns, dismissed, frozen)
return {"items": items, "considered": {"runs": len(runs), "matched": len(latest), "questions": len(questions)},
"threshold": {"runs": REPEAT_RUNS, "conversations": REPEAT_CONVERSATIONS, "exports": EXPORTS},
"embedder": embedder()}
async def _reused(session: Any, runs: list[Run], turns: dict[str, dict[str, Any]], dismissed: set[str],
frozen: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
"""被反复用的答案:导出过 ≥ EXPORTS 次、做成过应用、在画布里打开过或提取过(见模块说明)。"""
from sqlalchemy import func
from app.db.models import AuditEvent, Workflow
from app.db.models_blueprints import Blueprint
ids = [r.id for r in runs]
if not ids:
return []
exports = {sid: int(n) for sid, n in (await session.execute(
select(AuditEvent.subject_id, func.count()).where(AuditEvent.action == "report.exported",
AuditEvent.subject_kind == "run",
AuditEvent.subject_id.in_(ids))
.group_by(AuditEvent.subject_id))).all()}
apps: set[str] = set()
for source in (await session.execute(select(Blueprint.source))).scalars():
if isinstance(source, dict) and source.get("kind") == "run" and source.get("id") in ids:
apps.add(str(source["id"]))
canvas: dict[str, list[str]] = {}
for wf_id, origin, tags in (await session.execute(select(Workflow.id, Workflow.origin, Workflow.tags)
.where(Workflow.origin.is_not(None)))).all():
if isinstance(origin, dict) and origin.get("run_id") in ids and not origin.get("sources"):
canvas.setdefault(str(origin["run_id"]), []).append("extracted" if "extracted" in (tags or []) else "canvas")
out: list[dict[str, Any]] = []
for run in runs:
uses = {"exports": exports.get(run.id, 0), "app": run.id in apps,
"extracted": "extracted" in canvas.get(run.id, []), "canvas": "canvas" in canvas.get(run.id, [])}
if not (uses["exports"] >= EXPORTS or uses["app"] or uses["extracted"] or uses["canvas"]):
continue
iid = item_id("reused", run.id)
if iid in dismissed:
continue
info = turns.get(run.id) or {}
out.append({"id": iid, "kind": "reused", "question": info.get("question") or "", "uses": uses,
"turns": [{"run_id": run.id, "conversation_id": info.get("conversation_id"),
"turn_id": info.get("turn_id"), "question": info.get("question") or ""}],
"representative": {"run_id": run.id}, "frozen_into": frozen.get(run.id, [])})
return out
async def dismiss(session: Any, iid: str, *, kind: str | None = None, note: str | None = None) -> None:
"""「不是口径」:写一条审计,之后这一项不再进队。"""
from app.auth.audit import record
await record(session, DISMISS_ACTION, SUBJECT_KIND, iid,
data={k: v for k, v in {"kind": kind, "note": note}.items() if v})
await session.commit()
# ==========================================================================
# 固化成口径草稿
# ==========================================================================
async def freeze(session: Any, run_id: str, *, peers: Sequence[str] = (), name: str = "",
conversation_id: str | None = None, turn_id: str | None = None) -> dict[str, Any]:
"""把一次问数据运行固化成口径草稿(见模块说明),落库成新的草稿工作流,返回 {workflow_id, name, nodes, edges, params,
sources, ...}。peers 是同一组(同一个 SQL 形状)的别的运行:认哪些字面量位置跨轮变了。不能固化抛 CertifyRefused。"""
from app.api.deps import can_read_run, current_principal
from app.api.evidence import _reports, _sealed
from app.core.artifact_store import graph_hash, load
from app.db.models import DataSource, Workflow, WorkflowVersion
from app.engine.governance import REPORT_POLICY
from app.engine.layout import auto_layout
from app.engine.schema import GraphSpec
from app.ledger import store
from app.tools.datasource import QUERY_PREFIX
run = await session.get(Run, run_id)
principal = current_principal()
if run is None or not await can_read_run(principal, run):
raise CertifyRefused(404, NOT_FOUND)
if run.status != "succeeded":
raise CertifyRefused(409, NOT_SUCCEEDED)
recorded = await store.latest(run_id)
if recorded is None or recorded.content is None:
raise CertifyRefused(409, NOT_MATCHED)
queries = referenced_queries(recorded.content)
if not queries:
raise CertifyRefused(409, NO_QUERIES)
kinds = {n: k for n, k in (await session.execute(select(DataSource.name, DataSource.kind))).all()}
# 同组的别的运行:它们封存的查询里同形状的那一条的字面量
peer_literals: dict[tuple[str, str], list[tuple[str, ...]]] = {}
peer_sources: list[dict[str, Any]] = []
peer_runs = [r for r in [await session.get(Run, p) for p in dict.fromkeys(peers) if p != run_id] if r is not None]
readable = [r for r in peer_runs if await can_read_run(principal, r)]
for peer in readable:
sealed = await _sealed(peer.id)
for artifact in sealed.queries:
try:
shape = shape_of(load(artifact), kinds)
except ValueError:
continue
if shape is not None and not shape.problem:
peer_literals.setdefault((shape.source, shape.text), []).append(shape.literals)
sealed = await _sealed(run_id)
shapes: list[Shape] = []
snaps: list[dict[str, Any]] = []
for artifact in queries:
try:
snap = load(artifact)
except ValueError:
raise CertifyRefused(409, SNAPSHOT) from None
shape = shape_of(snap, kinds)
if shape is None:
raise CertifyRefused(409, SNAPSHOT)
if shape.problem:
raise CertifyRefused(409, UNPARSED.format(reason=shape.problem))
shapes.append(shape)
snaps.append(snap)
def nid(i: int) -> str:
return "fetch" if len(shapes) == 1 else f"fetch_{i + 1}"
nodes: list[dict[str, Any]] = []
fields: list[dict[str, Any]] = []
metrics: list[dict[str, Any]] = []
multi = False
for i, (shape, snap) in enumerate(zip(shapes, snaps)):
params = varying([shape.literals, *peer_literals.get((shape.source, shape.text), [])])
template, new_fields = template_of(shape, params, kinds.get(shape.source), start=len(fields) + 1)
fields += new_fields
nodes.append({"id": nid(i), "type": "tool", "position": {"x": 0, "y": 0},
"data": {"label": f"取数 {i + 1}" if len(shapes) > 1 else "取数",
"config": {"tool": f"{QUERY_PREFIX}{shape.source}", "args": {"sql": template}}}})
rows = snap.get("rows") if isinstance(snap.get("rows"), list) else []
columns = [str(c) for c in snap.get("columns") or []]
if len(rows) == 1 and isinstance(rows[0], (list, tuple)):
for col, value in zip(columns, rows[0]):
if isinstance(value, (int, float)) and not isinstance(value, bool):
metrics.append({"id": f"m{len(metrics) + 1}", "name": col,
"expression": f"cell(nodes.{nid(i)}, 0, {col!r})"})
else:
multi = True
question = ((await _turns(session, [run])).get(run.id) or {}).get("question") or ""
reports = _reports(sealed)
report_cfg: dict[str, Any] = {}
if reports:
found = next((n for n in (run.graph or {}).get("nodes") or [] if n.get("id") == reports[0].node_id), None)
cfg = ((found or {}).get("data") or {}).get("config") or {}
report_cfg = {k: cfg[k] for k in ("provider", "model", "entities") if k in cfg}
report_cfg["instructions"] = REPORT_INSTRUCTIONS.format(question=question or "(问题)")
# 报告的核对规则一律按受管级别的要求写(engine/governance.REPORT_POLICY,和发布门禁同一份):草稿要能按受管发布
report_cfg.update({key: value for key, value, _ in REPORT_POLICY})
contract: dict[str, Any] = {"report_from": "write"}
tail: list[dict[str, Any]] = []
if metrics:
tail.append({"id": "card", "type": "metrics", "position": {"x": 0, "y": 0},
"data": {"label": "口径卡", "config": {"caliber": (question or "口径")[:40], "caliber_version": "v1",
"metrics": metrics}}})
report_cfg["metrics_from"] = ["card"]
# 受管出具要写必需指标(不然「不予出具」这一档触发不了):固化出的每个指标都是这个口径要给的数
contract.update({"metrics_from": "card", "required": [m["id"] for m in metrics]})
if multi or not metrics:
contract["cells"] = True
chain = [{"id": "start", "type": "input", "position": {"x": 0, "y": 0},
"data": {"label": "入口", "config": {"fields": fields}}},
*nodes, *tail,
{"id": "write", "type": "report", "position": {"x": 0, "y": 0},
"data": {"label": "报告", "config": report_cfg}},
{"id": "out", "type": "output", "position": {"x": 0, "y": 0},
"data": {"label": "出口", "config": {"fields": [{"name": "报告", "value": "{{ nodes.write.text }}"}],
"contract": contract}}}]
edges = [{"source": a["id"], "target": b["id"]} for a, b in zip(chain, chain[1:])]
graph = auto_layout(GraphSpec.model_validate({"nodes": chain, "edges": edges})).model_dump(mode="json")
info = (await _turns(session, [run])).get(run.id) or {}
sources = [{"run_id": run.id, "conversation_id": conversation_id or info.get("conversation_id"),
"turn_id": turn_id or info.get("turn_id"), "question": question}]
for peer, peer_info in (await _turns(session, readable)).items():
sources.append({"run_id": peer, "conversation_id": peer_info.get("conversation_id"),
"turn_id": peer_info.get("turn_id"), "question": peer_info.get("question") or ""})
workflow = Workflow(
name=(name or DRAFT_NAME.format(question=(question or run.workflow_name or "问数据")[:30]))[:200],
description=DRAFT_DESCRIPTION.format(n=len(shapes), p=len(fields)), graph=graph, tags=["certified"],
status="draft",
origin={"run_id": run.id, **({"conversation_id": sources[0]["conversation_id"]} if sources[0]["conversation_id"]
else {}), **({"turn_id": sources[0]["turn_id"]} if sources[0]["turn_id"] else {}),
"freeze": True, "sources": sources})
session.add(workflow)
await session.flush()
session.add(WorkflowVersion(workflow_id=workflow.id, version=1, graph=graph, graph_hash=graph_hash(graph),
note=f"从运行 {run.id} 固化"))
await session.commit()
return {"workflow_id": workflow.id, "name": workflow.name, "nodes": len(chain), "edges": len(edges),
"dropped_nodes": 0, "source_run": run.id, "frozen": True, "params": [f["name"] for f in fields],
"metrics": [m["id"] for m in metrics], "sources": sources}
# ==========================================================================
# 发布之后补比
# ==========================================================================
_background: set[asyncio.Task[Any]] = set()
def after_publish(workflow_id: str, origin: Any) -> int:
"""工作流发布了:从问数据固化来的(origin.sources),来源运行在后台按现在的口径补比一次(trigger=certified)。返回排上了
几个。不挡发布,失败只记日志(ledger/match 的 schedule 自己兜着)。"""
from app.ledger import match as ledger_match
sources = origin.get("sources") if isinstance(origin, dict) else None
count = 0
for src in sources or []:
if isinstance(src, dict) and src.get("run_id"):
try:
if ledger_match.schedule(str(src["run_id"]), trigger="certified"):
count += 1
except Exception: # noqa: BLE001 - 补比是辅助步骤
logger.exception("工作流 %s 发布后补比来源运行出错", workflow_id)
return count
__all__ = ["DISMISS_ACTION", "KINDS", "REPEAT_CONVERSATIONS", "REPEAT_RUNS", "CertifyRefused", "Shape", "after_publish",
"dismiss", "freeze", "item_id", "queue", "referenced_queries", "shape_of", "template_of", "varying"]
+254
View File
@@ -0,0 +1,254 @@
"""组合式问数:口径做骨架,Agent 填缺口(docs/design/next-round/02-口径账本.md 做法 4,拍板 L3)。
问数据的助手看到已发布口径(look 的 caliber,问数据里另给入口字段和「能不能组合」,不给 SQL)之后,可以输出一行
{"op":"use_caliber","id":"c1","workflow_id":"…","version":3,"card":"card","metrics":["gmv"],"inputs":{"start":"…"}}
选用一份口径。**取数节点不让模型写**:由服务端按那一版确定性地内联实例化(ledger/derive.caliber_block,全站唯一的
派生图生成器)——口径卡读的取数节点照抄,节点 id 加前缀「c1_」,模板里的 {{ input.字段 }} 按 inputs 渲染成字面 SQL
(和运行时同一个渲染器);口径卡 id 就是 c1,caliber_from 固定到那一版的那张卡、带改名映射 {原 id: c1_原 id}
(metrics 运行时按它改写式子)。取数节点首尾相接、最后一个接口径卡;入口连到第一个取数节点(GraphApplier 接上)。
模型只管把口径卡 c1 接到报告、报告里引用 [[m:指标 id]]。
**为什么不用子图节点。** 子图的证据不交回父图(报告引用不到子图里的数),使用者也用不了子工作流(auth/chat_policy)。
内联实例化只用到 tool 和 metrics 两种已放行的节点,证据照常封存,比对也照常复证(ledger/match 认出这些查询是助手
选用的,标 composed「助手选用」,不标「等价已证明」:选哪份口径是助手凭问法判断的)。
**参数校验**(不合规拒收,原因交回模型改,GraphApplier.refusals):
- 只能选这一轮 look 能看到的那一版(每个工作流最新的已发布、未退役版本),而且那张卡能组合(derive.load_pinned:卡是
源头、每个输入都是取数节点第一行的格、取数模板只引用入口字段);
- inputs 只认口径入口声明的字段,没写的补缺省值,再过 apps/forms 同一套表单校验(类型、可选值、必填、格式);
- 参数要渲染进 SQL:写在引号里的不能带引号、反斜杠、换行;裸写的只能是数;写在注释里的不能带换行和注释结束符——
否则渲染出来的就不是那一版口径的 SQL 了;
- metrics 只能是这张卡上的指标 id;id 不能和图里已有的节点撞。
实例化是确定的:同样的版本、同样的操作得到一模一样的节点(测试锁住)。这里只是纯计算:版本快照由 look/calibers 的
CaliberKind 在开流之前用会话读好(GraphApplier.apply 是同步的,流里不碰库)。
"""
from __future__ import annotations
import copy
import json
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from app.ledger.derive import Pinned, caliber_block
#: 操作名
OP = "use_caliber"
#: 拒收时的问题编号(自查交回模型、过程里照实写一行)
REFUSED = "ledger.use_caliber_refused"
#: 操作 id:口径卡的节点 id,取数节点加它做前缀。简短英文小写下划线(同节点 id 的约定)
_OP_ID = re.compile(r"^[a-z][a-z0-9_]{0,23}$")
_NUMBER = re.compile(r"^\s*-?\d+(?:\.\d+)?\s*$")
#: 写给模型的拒收说明(也上界面:过程里那一行)
NOT_LISTED = "只能选用这一轮查看到的已发布口径:「{workflow}」v{version} 不在里面(口径要先 look,版本以查看结果为准)"
WRONG_VERSION = "「{workflow}」这一轮能选用的是 v{latest}(最新的已发布版本),不是 v{version}"
CARD_UNKNOWN = "「{workflow}」v{version} 里没有口径卡「{card}」(有:{cards})"
CARD_AMBIGUOUS = "「{workflow}」v{version} 有几张口径卡({cards}),请在 card 里写选用哪一张"
NOT_COMPOSABLE = "「{workflow}」v{version} 的口径卡「{card}」不能组合:{reason}"
BAD_ID = "use_caliber 的 id 要写成简短的英文小写加下划线(例如 c1),它就是口径卡的节点 id,当前为「{id}」"
ID_TAKEN = "use_caliber 的 id「{id}」和图里已有的节点「{node}」撞了,请换一个"
NO_METRICS = "use_caliber 的 metrics 要写这张口径卡上要用的指标 id(有:{metrics})"
METRIC_UNKNOWN = "口径卡「{card}」上没有指标「{metric}」(有:{metrics})"
FIELD_UNKNOWN = "口径「{workflow}」的入口没有字段「{name}」(有:{fields})"
INPUTS_NOT_OBJECT = "use_caliber 的 inputs 要写成字段和值的对照(JSON 对象)"
QUOTED_VALUE = "参数「{name}」写在 SQL 的引号里,值里不能有引号、反斜杠或换行"
BARE_VALUE = "参数「{name}」直接写在 SQL 里(没有引号),只能是数,当前为「{value}」"
COMMENT_VALUE = "参数「{name}」写在 SQL 的注释里,值里不能有换行或注释结束符"
TEMPLATE_UNREADABLE = "取数节点的 SQL 模板认不出参数写在哪:{reason}"
class ComposeRefused(Exception):
"""不实例化这次选用:message 给模型看、也写进过程里那一行。"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
@dataclass(frozen=True)
class Option:
"""这一轮能选用的一张口径卡:读好的那一版(pinned),或者不能组合的原因(reason)。"""
workflow_id: str
workflow_name: str
version: int
level: str
node_id: str
card: str
pinned: Pinned | None = None
reason: str | None = None
@property
def composable(self) -> bool:
return self.pinned is not None and self.reason is None
@dataclass
class Composed:
"""一次选用实例化出来的东西:要加的节点、连线(取数首尾相接、最后接口径卡),交给界面和模型的结果。"""
op_id: str
option: Option
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
metrics: list[dict[str, Any]]
inputs: dict[str, Any] = field(default_factory=dict)
@property
def fetch_ids(self) -> list[str]:
return [str(n["id"]) for n in self.nodes if n.get("type") != "metrics"]
def result(self) -> dict[str, Any]:
"""交给界面(过程里那一行)、记进这一轮的操作流的结果。不放 SQL、不放参数值(参数值助手自己写的,界面不必再给)。"""
from app.ledger.labels import LEVEL_TEXT
return {"status": "ok", "workflow_id": self.option.workflow_id, "workflow_name": self.option.workflow_name,
"version": self.option.version, "level": self.option.level,
"level_text": LEVEL_TEXT.get(self.option.level, ""), "card": self.option.card, "card_id": self.op_id,
"fetches": self.fetch_ids, "metrics": [{"id": m["id"], "name": m["name"]} for m in self.metrics],
"nodes": len(self.nodes)}
def signature(op: Mapping[str, Any]) -> str:
"""一次选用的身份(接着写时重发的同一个操作认得出来):去掉服务端写上的结果。"""
return json.dumps({k: v for k, v in op.items() if k != "result"}, ensure_ascii=False, sort_keys=True, default=str)
def _names(items: list[str]) -> str:
return "、".join(items[:12]) or "无"
def _slot_contexts(pinned: Pinned) -> dict[str, set[str]]:
"""取数 SQL 模板里每个入口字段写在哪:{字段: {quoted / bare / comment}}。认不出的抛 ComposeRefused。"""
return slot_contexts(pinned.fetches)
def slot_contexts(nodes: Any, *, strict: bool = True) -> dict[str, set[str]]:
"""几个调用工具节点的 args.sql 里每个入口字段写在哪(见 _slot_contexts)。按口径正式出具(ledger/issue)也按它查参数:
strict=False 时认不出写法的那条(引用了运行时的变量)跳过,不抛——那张图里别的节点怎么写不归出具管。"""
from app.data.sql_semantics import _NotComparable, _template_slots
out: dict[str, set[str]] = {}
for node in nodes:
if not isinstance(node, dict):
continue
args = ((node.get("data") or {}).get("config") or {}).get("args")
sql = args.get("sql") if isinstance(args, dict) else None
if not isinstance(sql, str):
continue
try:
spans, _ = _template_slots(sql)
except _NotComparable as e:
if not strict:
continue
raise ComposeRefused(TEMPLATE_UNREADABLE.format(reason=str(e))) from None
for _, _, slot in spans:
out.setdefault(slot.field, set()).add(slot.context)
return out
def _value_problem(name: str, value: Any, contexts: set[str]) -> str | None:
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) \
if isinstance(value, (dict, list)) else str(value)
if "quoted" in contexts and any(ch in text for ch in ("'", "\\", "\n", "\r")):
return QUOTED_VALUE.format(name=name)
if "bare" in contexts and not (isinstance(value, (int, float)) and not isinstance(value, bool)) \
and not (isinstance(value, str) and _NUMBER.match(value)):
return BARE_VALUE.format(name=name, value=text[:40])
if "comment" in contexts and ("\n" in text or "\r" in text or "*/" in text):
return COMMENT_VALUE.format(name=name)
return None
def check_inputs(pinned: Pinned, inputs: Any) -> dict[str, Any]:
"""按口径入口的字段校验助手给的参数,补缺省值,交回要渲染进模板的那一份。不合规抛 ComposeRefused(一句一处)。"""
from app.apps.forms import input_schema, validate_form
if inputs is None:
inputs = {}
if not isinstance(inputs, dict):
raise ComposeRefused(INPUTS_NOT_OBJECT)
fields = {str(f.get("name")).strip(): f for f in pinned.entry_fields if isinstance(f.get("name"), str)}
if unknown := [k for k in inputs if k not in fields]:
raise ComposeRefused(FIELD_UNKNOWN.format(workflow=pinned.workflow_name, name=unknown[0],
fields=_names(list(fields))))
values = {name: copy.deepcopy(f.get("default")) for name, f in fields.items()
if f.get("default") not in (None, "")}
values.update({k: v for k, v in inputs.items() if v is not None and v != ""})
if errors := validate_form(input_schema(list(pinned.entry_fields)), values):
raise ComposeRefused(";".join(e["message"] for e in errors))
contexts = _slot_contexts(pinned)
for name, value in values.items():
if problem := _value_problem(name, value, contexts.get(name, set())):
raise ComposeRefused(problem)
return values
def instantiate(option: Option, op: Mapping[str, Any], *, taken: set[str]) -> Composed:
"""按选用的那一版确定性地实例化(见模块说明)。taken 是图里已有的节点 id。不合规抛 ComposeRefused。"""
pinned = option.pinned
if pinned is None or option.reason is not None:
raise ComposeRefused(NOT_COMPOSABLE.format(workflow=option.workflow_name, version=option.version,
card=option.card, reason=option.reason or "读不到这一版"))
op_id = str(op.get("id") or "").strip()
if not _OP_ID.match(op_id):
raise ComposeRefused(BAD_ID.format(id=op_id))
wanted = [str(m) for m in op.get("metrics") or [] if isinstance(m, (str, int))] \
if isinstance(op.get("metrics"), list) else []
on_card = {str(m["id"]): m for m in pinned.metrics}
if not wanted:
raise ComposeRefused(NO_METRICS.format(metrics=_names(list(on_card))))
if missing := [m for m in wanted if m not in on_card]:
raise ComposeRefused(METRIC_UNKNOWN.format(card=option.card, metric=missing[0], metrics=_names(list(on_card))))
values = check_inputs(pinned, op.get("inputs"))
block = caliber_block(pinned, prefix=f"{op_id}_", card_id=op_id, inputs=values)
if clash := next((str(n["id"]) for n in block.nodes if str(n["id"]) in taken), None):
raise ComposeRefused(ID_TAKEN.format(id=op_id, node=clash))
nodes = block.nodes
edges = [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]
metrics = [{"id": m, "name": str(on_card[m].get("name") or m)} for m in wanted]
return Composed(op_id=op_id, option=option, nodes=nodes, edges=edges, metrics=metrics, inputs=values)
class Composer:
"""GraphApplier 用的:这一轮能选用的口径卡(CaliberKind 在开流之前读好),按 use_caliber 实例化。"""
def __init__(self, options: list[Option]) -> None:
self.options = options
def pick(self, op: Mapping[str, Any]) -> Option:
workflow_id = str(op.get("workflow_id") or "").strip()
version = op.get("version")
cards = [o for o in self.options if o.workflow_id == workflow_id]
if not cards:
named = str(op.get("workflow_name") or workflow_id or "(没写工作流)")
raise ComposeRefused(NOT_LISTED.format(workflow=named, version=version))
name = cards[0].workflow_name
latest = cards[0].version
if str(version) != str(latest):
raise ComposeRefused(WRONG_VERSION.format(workflow=name, latest=latest, version=version))
card = str(op.get("card") or "").strip()
listed = [f"{o.card}({o.node_id})" for o in cards]
if card:
hit = next((o for o in cards if card in (o.node_id, o.card)), None)
if hit is None:
raise ComposeRefused(CARD_UNKNOWN.format(workflow=name, version=latest, card=card,
cards=_names(listed)))
return hit
if len(cards) > 1:
raise ComposeRefused(CARD_AMBIGUOUS.format(workflow=name, version=latest, cards=_names(listed)))
return cards[0]
def compose(self, op: Mapping[str, Any], *, taken: set[str]) -> Composed:
return instantiate(self.pick(op), op, taken=taken)
__all__ = ["OP", "REFUSED", "ComposeRefused", "Composed", "Composer", "Option", "check_inputs", "instantiate",
"signature", "slot_contexts", "value_problem"]
value_problem = _value_problem
+84 -16
View File
@@ -16,7 +16,8 @@ cell(nodes.X, 0, '列')、X 是查库的调用工具节点(look/calibers.card_
模板只引用入口字段({{ input.字段 }})——引用 vars.、nodes.、模板函数(file_text……)的,拷出来就拿不到同样的值,照实
拒收、给原因(NotDerivable)。工作流、版本、节点不在了也照实说。
**这一波实现的**:caliber_probe —— 一份口径的取数 + 口径卡(评测口径题的期望)。
**已实现的**:caliber_probe —— 一份口径的取数 + 口径卡(评测口径题的期望);reconcile —— 口径对账(第 3 波,
reconcile_graph);组合式问数用的 caliber_block(card_id, inputs)(第 3 波)。
**扩展点**(第 3 波的账本按这里扩,不另写生成器):
- 口径对账(02 做法 6):assemble("reconcile", [ours, caliber_block(pinned)], tail=[对账卡])。ours 是「这次的取数」
@@ -24,9 +25,10 @@ cell(nodes.X, 0, '列')、X 是查库的调用工具节点(look/calibers.card_
相对差,式子引用 cell(nodes.ours_q1, 0, '列') 和口径卡的指标,on_missing=null),出口的契约写 metrics_from。
- 口径召回(02 做法 7):assemble("recall", [caliber_block(old, prefix="old_"), caliber_block(new, prefix="new_")],
tail=[召回卡])。同一张图里放两份口径,取数节点 id 会撞:caliber_block 带 prefix 时把拷出来的节点改名,口径卡的
caliber_from 带上改名映射 nodes={原 id: 新 id}。**运行时认这个映射是 02 做法 4(metrics._pinned_caliber 按 AST 改写
式子里的 cell(nodes.X…) 和 nodes.X),归第 3 波的账本包**;在那之前带前缀的段跑起来口径卡取不到输入(缺输入,照实
失败,不会算错),这一波只有不带前缀的 caliber_probe 有调用方。
caliber_from 带上改名映射 nodes={原 id: 新 id},运行时 metrics._pinned_caliber 按语法树改写式子里的 cell(nodes.X…)
和 nodes.X(第 3 波起)。
- 组合式问数(02 做法 4,ledger/compose.py):不经 assemble(拷进的是问数据那张图),只用 caliber_block(prefix=…,
card_id=…, inputs=…):取数节点改名、模板按助手给的参数渲染成字面 SQL,口径卡带改名映射。
- 新的派生种类登记进 KINDS,入口字段、出口、首尾相接都走 assemble,不在调用方拼节点。
派生图都是探索运行(正式运行只从已发布未退役的版本发起);来源(origin_kind)由调用方定:评测 eval、对账 reconcile、
@@ -42,7 +44,7 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
#: 派生图的种类。这一波实现 caliber_probe;reconcile、recall 归第 3 波的账本(见模块说明的扩展点)
#: 派生图的种类。caliber_probe、reconcile 已实现;recall 归第 4 波(见模块说明的扩展点)
KINDS = ("caliber_probe", "reconcile", "recall")
#: 不能派生的原因的机器码
@@ -129,10 +131,11 @@ class Pinned:
@dataclass
class Block:
"""派生图里的一段:照抄(或这次执行过)的取数节点 + 一张口径卡。rename 是拷出来的节点改了名的映射(不带前缀时为空)。"""
"""派生图里的一段:照抄(或这次执行过)的取数节点 + 一张口径卡。rename 是拷出来的节点改了名的映射(不带前缀时为空)。
card_id 为 None 的段没有口径卡(对账里「这次的取数」那一段只有一条查询)。"""
nodes: list[dict[str, Any]]
card_id: str
card_id: str | None
rename: dict[str, str] = field(default_factory=dict)
#: 这一段出自哪里(记进 DerivedGraph.origin)
source: dict[str, Any] | None = None
@@ -242,19 +245,39 @@ def _node(nid: str, ntype: str, label: str, **config: Any) -> dict[str, Any]:
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label, "config": config}}
def caliber_block(pinned: Pinned, *, prefix: str = "", upgrade_policy: str | None = None) -> Block:
def render_inputs(value: Any, inputs: Mapping[str, Any]) -> Any:
"""配置里每一段字的 {{ input.字段 }} 按 inputs 渲染成字面值(和运行时同一个渲染器 engine/expressions.render_template)。
load_pinned 已经保证取数节点的模板只引用入口字段。别的值原样。"""
from app.engine.expressions import render_template
if isinstance(value, str):
return render_template(value, {"input": dict(inputs)}) if "{{" in value else value
if isinstance(value, dict):
return {k: render_inputs(v, inputs) for k, v in value.items()}
if isinstance(value, list):
return [render_inputs(v, inputs) for v in value]
return value
def caliber_block(pinned: Pinned, *, prefix: str = "", upgrade_policy: str | None = None,
card_id: str | None = None, inputs: Mapping[str, Any] | None = None) -> Block:
"""一份口径:拷出取数节点(id、配置原样;prefix 不为空时改名为 prefix + 原 id)+ 一张 caliber_from 固定到那一版的
口径卡(id 同原卡,带前缀时同样改名;「缺输入时」照抄原卡)。
upgrade_policy:钉住的版本有了新的已发布版本时这一段怎么处置(recompute / dual / incomparable,没有缺省)。写在
本地口径卡上,判断走和正式运行同一个 engine/governance.unresolved_caliber_upgrades;不给就不写(有新版本时那个
判断报「待处置」)。prefix 的改名映射要等 metrics 运行时认 caliber_from.nodes(见模块说明)。"""
判断报「待处置」)。带前缀时口径卡的 caliber_from 带改名映射 nodes(metrics 运行时按它改写式子,见模块说明)。
组合式问数(ledger/compose.py)另给两项:card_id 是口径卡在新图里的 id(缺省 prefix + 原卡 id);inputs 给了的话,
取数节点配置里的 {{ input.字段 }} 按它渲染成字面值——拷进问数据的图里,那张图的入口没有口径的入口字段。"""
rename = {str(n["id"]): f"{prefix}{n['id']}" for n in pinned.fetches} if prefix else {}
nodes: list[dict[str, Any]] = []
for original in pinned.fetches:
node = copy.deepcopy(original)
node["id"] = rename.get(str(node["id"]), str(node["id"]))
node["position"] = {"x": 0, "y": 0}
if inputs is not None and isinstance(node.get("data"), dict):
node["data"]["config"] = render_inputs(node["data"].get("config") or {}, inputs)
nodes.append(node)
source = pinned.ref()
if rename:
@@ -265,7 +288,7 @@ def caliber_block(pinned: Pinned, *, prefix: str = "", upgrade_policy: str | Non
config["on_missing"] = on_missing
if upgrade_policy:
config["upgrade_policy"] = upgrade_policy
card_id = f"{prefix}{pinned.node_id}"
card_id = card_id or f"{prefix}{pinned.node_id}"
nodes.append(_node(card_id, "metrics", pinned.card_title, **config))
return Block(nodes=nodes, card_id=card_id, rename=rename, source=pinned.describe())
@@ -278,9 +301,11 @@ def _free_id(wanted: str, taken: set[str]) -> str:
def assemble(kind: str, blocks: Sequence[Block], *, fields: Sequence[Mapping[str, Any]] = (),
defaults: Mapping[str, Any] | None = None, tail: Sequence[dict[str, Any]] = ()) -> DerivedGraph:
defaults: Mapping[str, Any] | None = None, tail: Sequence[dict[str, Any]] = (),
contract: Mapping[str, Any] | None = None) -> DerivedGraph:
"""入口 → 各段(首尾相接)→ tail(对账卡、召回卡)→ 出口。出口每张口径卡(含 tail 里的 metrics 节点)一个字段,
取那张卡的指标清单。几段的节点 id 撞了直接报错:两份口径放进同一张图要带前缀(caliber_block 的 prefix)。"""
取那张卡的指标清单;contract 给了就写成出口的出具契约(对账写 metrics_from 指向对账卡,不加报告、不调模型)。几段的
节点 id 撞了直接报错:两份口径放进同一张图要带前缀(caliber_block 的 prefix)。"""
if kind not in KINDS:
raise ValueError(f"不认识的派生图种类:{kind}")
body: list[dict[str, Any]] = [n for b in blocks for n in b.nodes] + [copy.deepcopy(n) for n in tail]
@@ -291,15 +316,18 @@ def assemble(kind: str, blocks: Sequence[Block], *, fields: Sequence[Mapping[str
taken = set(ids)
entry = _free_id(ENTRY_ID, taken)
exit_ = _free_id(EXIT_ID, taken | {entry})
cards = [b.card_id for b in blocks] + [str(n["id"]) for n in tail if n.get("type") == "metrics"]
cards = [b.card_id for b in blocks if b.card_id] + [str(n["id"]) for n in tail if n.get("type") == "metrics"]
titles = {str(n["id"]): str((n.get("data") or {}).get("label") or n["id"]) for n in body}
outputs, seen = [], set()
for cid in cards:
name = _free_id(titles.get(cid, cid), seen)
seen.add(name)
outputs.append({"name": name, "value": f"{{{{ nodes.{cid}.text }}}}"})
exit_config: dict[str, Any] = {"fields": outputs}
if contract:
exit_config["contract"] = copy.deepcopy(dict(contract))
chain = [_node(entry, "input", "入口", fields=[copy.deepcopy(dict(f)) for f in fields]), *body,
_node(exit_, "output", "出口", fields=outputs)]
_node(exit_, "output", "出口", **exit_config)]
for i, node in enumerate(chain):
node["position"] = {"x": i * _STEP_X, "y": 0}
graph: dict[str, Any] = {
@@ -313,6 +341,45 @@ def assemble(kind: str, blocks: Sequence[Block], *, fields: Sequence[Mapping[str
entry_fields=tuple(copy.deepcopy(dict(f)) for f in fields), origin=origin)
#: 对账图里「这次的取数」节点、对账卡、多行时按键对齐的合并查询的 id(细案 02 做法 6)
OURS_ID = "ours_q1"
RECONCILE_CARD = "reconcile"
ALIGN_ID = "align"
def reconcile_prefix(pinned: Pinned) -> str:
"""对账图里口径那一段的前缀:口径的节点 id 和 ours_q1 / align / reconcile / 入口出口撞了才带 cal_,不撞不带。"""
reserved = {OURS_ID, ALIGN_ID, RECONCILE_CARD, ENTRY_ID, EXIT_ID}
clash = any(str(n.get("id")) in reserved for n in pinned.fetches) or pinned.node_id in reserved
return "cal_" if clash else ""
def ours_block(sql: str, tool: str, *, label: str = "这次的取数") -> Block:
"""对账里「这次的取数」:这次执行过的 SQL 原文,放进一个调用工具节点(id ours_q1)。没有口径卡。"""
return Block(nodes=[_node(OURS_ID, "tool", label, tool=tool, args={"sql": sql})], card_id=None,
source={"kind": "executed", "node_id": OURS_ID})
def reconcile_graph(pinned: Pinned, ours: Block, tail: Sequence[dict[str, Any]], *,
inputs: Mapping[str, Any]) -> DerivedGraph:
"""口径对账(细案 02 做法 6):入口(口径的入口字段,缺省值是这次的参数)→ 这次的取数 → 口径的取数(模板里保留
{{ input.x }},运行时按入口渲染)→ 口径卡(caliber_from 固定那一版)→ tail(多行时的对齐合并查询、对账卡)→ 出口
(契约 metrics_from 指向对账卡;不加报告、不调模型)。两边在同一次运行里先后查,差值在对账卡里算。
口径的节点 id 和 ours_q1 / align / reconcile 撞了的,口径那一段带前缀 cal_(改名映射照常写进 caliber_from.nodes)。
同样的输入永远得到同一张图。"""
block = caliber_block(pinned, prefix=reconcile_prefix(pinned))
fields = []
for f in pinned.entry_fields:
field_ = copy.deepcopy(dict(f))
if field_.get("name") in inputs:
field_["default"] = inputs[field_["name"]]
fields.append(field_)
card = next((n for n in tail if n.get("type") == "metrics" and n.get("id") == RECONCILE_CARD), None)
return assemble("reconcile", [ours, block], fields=fields, defaults=pinned.defaults, tail=tail,
contract={"metrics_from": RECONCILE_CARD} if card is not None else None)
def caliber_probe(pinned: Pinned, *, upgrade_policy: str | None = None) -> DerivedGraph:
"""口径探针:一份口径的取数 + 口径卡(评测口径题的期望,细案 01 做法 8)。节点 id 和原图一样,入口字段照抄。
同样的 pinned 永远得到同一张图(确定性)。"""
@@ -320,6 +387,7 @@ def caliber_probe(pinned: Pinned, *, upgrade_policy: str | None = None) -> Deriv
fields=pinned.entry_fields, defaults=pinned.defaults)
__all__ = ["Block", "DerivedGraph", "ENTRY_ID", "EXIT_ID", "GONE", "KINDS", "NOT_CARD", "NOT_REUSABLE", "NotDerivable",
"Pinned", "TEMPLATE", "assemble", "caliber_block", "caliber_probe", "entry_fields", "load_pinned",
__all__ = ["ALIGN_ID", "Block", "DerivedGraph", "ENTRY_ID", "EXIT_ID", "GONE", "KINDS", "NOT_CARD", "NOT_REUSABLE",
"NotDerivable", "OURS_ID", "Pinned", "RECONCILE_CARD", "TEMPLATE", "assemble", "caliber_block",
"caliber_probe", "entry_fields", "load_pinned", "ours_block", "reconcile_graph", "reconcile_prefix", "render_inputs",
"template_problem"]
+395
View File
@@ -0,0 +1,395 @@
"""按口径正式出具:路径一、二a(docs/design/next-round/02-口径账本.md 做法 5,拍板 L1、L5)。
问数据的答案整题等价于一份已发布口径(ledger/labels.whole_of:主报告里每个数都来自同一口径的同一版、参数一致)时,可以
按那份口径发起一次正式运行——同一个问题,从此有了一份按已发布版本出具的、完整走过门禁的答案。
**参数只从比对工件里取。** 发起用的输入 = 那一版入口字段的缺省值 + 比对时绑定出来的参数(whole.binding),再过 apps/forms
同一套表单校验;不收客户端传的参数,前端只能选「按哪份口径」——不然谁都能拿一次问数据去替一份正式口径「签字」任意输入。
绑定的值是这次运行查到过的遮罩值的,不代填出具。
**经 launch_run 发起**(正式运行的全部门禁:已发布未退役、升版处置、子工作流固定版本、工具在不在),来源 ledger_issue
(服务端专用,engine/origin),origin 记 source_run、conversation_id、turn_id、binding_hash、口径和怎么认的、是不是自动的;
按 expect_hash 钉住那一版的 graph_hash(比对时考虑的就是这张图)。出具档位照正式运行原样走。
**能不能出具**(plan_issue,一键和自动同一套,不满足的照实写原因):
- 这次运行是比对过的探索运行,整题等价于点名的这份口径(工作流、版本都对);
- 那一版已发布、未退役;
- 图里没有人工节点、不会写外部系统(apps/backfill.graph_side_effect,和按新口径补算同一个判断):替人重跑一次的正式
运行不该替人去审批、往外部系统写;
- 补了缺省值之后表单校验通过(缺入口字段的照实说缺哪个);
- 权限(拍板 L5):搭建者及以上(关登录时同)都可以;使用者只能按他有权使用的应用固定着的这一版出具(复用应用授权,
apps/access),别的写「请搭建者出具」;都要读得到这次运行;
- 当天没超上限(设置 ledger.issue_daily_limit,自动的、一键的合计)。
**自动出具**(auto_issue,拍板 L1)另要:设置里开着、这次是问数据的答案(origin_kind=chat;画布试运行、蓝图试运行、
个人应用刷新也比对,但只给一键——画布上反复试跑一份等价的草图,每次都是新的来源运行,自动的话每试一次就多一次正式运行,
含报告撰写的模型费用)、认法是独立等价(how=independent:Agent 自己写的 SQL 被证明等价;「助手选用」的只给一键——选哪份
口径是助手凭问法判断的,由人点一下代替证明)、版本是受管级别。只在运行一封存就比完的那一次触发(ledger/match)。不满足的
写进 actions 的 auto_reason。
**同一运行、同一口径、同一组参数只发起一次**:按 input_key(apps/resolve,补算用的同一个指纹)认,已经发起过、没失败的
直接交回那一次。进程内按(运行、工作流、版本)串行,自动和一键同时到也只发一次(单进程部署,同 apps/backfill 的锁)。
结果挂回答案:GET /api/runs/{id}/ledger 的 issued 列出 origin.source_run 等于这次运行的正式运行,档位文字用 TIER_TEXT。
"""
from __future__ import annotations
import asyncio
import logging
import weakref
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select
from app.db.base import SessionLocal
from app.db.models import Run, Workflow
logger = logging.getLogger(__name__)
ORIGIN = "ledger_issue"
#: 出具被拒的机器码(接口回 409 / 403 / 429 时带它)
REFUSED = "ledger_issue_refused"
FORBIDDEN = "ledger_issue_forbidden"
DAILY_LIMIT = "ledger_issue_daily_limit"
#: 算「同一次」时看哪几种状态(失败、取消的可以再发)
_LIVE = ("queued", "running", "interrupted", "succeeded")
#: 不能出具的原因
NOT_MATCHED = "这次运行还没有比对口径,比对完才能按口径出具"
NOT_ELIGIBLE = "这次运行不能按口径出具:{why}"
NOT_WHOLE = "整题没有等价于这份口径:{why}"
OTHER_CALIBER = "整题等价的是另一份口径,不是「{name}」v{version}"
MASKED_BINDING = "参数里有这次运行查到过的遮罩值,不代填出具"
GONE = "口径所在的工作流不存在,可能已被删除"
VERSION_GONE = "「{name}」没有 v{version} 这个版本"
RETIRED = "「{name}」v{version} 已退役,不再接受新的正式运行"
UNPUBLISHED = "「{name}」v{version} 没有发布过,正式运行只能从已发布的版本发起"
SIDE_EFFECT = "「{name}」v{version}{reason},按口径出具不代人做这些事"
INPUTS = "按这次的参数补上缺省值后,入口字段还不齐:{problems}"
NO_RUN_ACCESS = "没有权限查看这次运行"
ASK_BUILDER = "这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具"
OVER_LIMIT = "今天的按口径出具已达到上限({limit} 次),请明天再试,或请管理员调整上限"
#: 不自动出具的原因(能一键出具时写进 actions 的 auto_reason)
AUTO_OFF = "设置里关了自动按口径出具"
AUTO_NOT_CHAT = "这次是{what},不是问数据的答案:自动出具只给问数据的答案,这里只给一键出具"
#: 参与比对、但不是问数据的那几种来源(ledger/match.EXPLORE_ORIGINS)怎么称呼
_NOT_CHAT_TEXT = {"canvas": "画布试运行", "trial": "蓝图试运行", "personal_app": "个人应用的刷新"}
AUTO_COMPOSED = "数来自助手选用的口径,只给一键出具:选用哪份口径是助手按问法判断的,要由人确认"
AUTO_LEVEL = "这一版是已发布级别,自动出具只限受管级别的口径"
AUTO_RETRO = "这次比对是事后按现在的口径回看的,不自动出具"
class IssueRefused(Exception):
def __init__(self, status_code: int, message: str, code: str = REFUSED) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
@dataclass
class Plan:
"""按一份口径出具的打算:能不能(reason 为空就能)、发起用的输入、为什么不自动(auto_reason,能自动的为空)。"""
run_id: str
workflow_id: str
version: int
caliber: str
how: str | None = None
level: str | None = None
workflow_name: str = ""
inputs: dict[str, Any] = field(default_factory=dict)
binding: dict[str, Any] = field(default_factory=dict)
expect_hash: str | None = None
match_artifact: str | None = None
#: 不能出具的原因、HTTP 状态码、机器码
reason: str | None = None
status: int = 409
code: str = REFUSED
auto_reason: str | None = None
#: 使用者靠哪个应用的授权出具(apps/access):记进 origin
granted_by_app: str | None = None
#: 已经发起过的同一次(没失败的)
existing: str | None = None
@property
def available(self) -> bool:
return self.reason is None
def refuse(self, message: str, status: int = 409, code: str = REFUSED) -> "Plan":
if self.reason is None:
self.reason, self.status, self.code = message, status, code
return self
def action(self) -> dict[str, Any]:
"""交给界面的那一项动作(GET 账本的 actions)。"""
from app.ledger.labels import LEVEL_TEXT
out: dict[str, Any] = {"kind": "issue", "workflow_id": self.workflow_id, "version": self.version,
"caliber": self.caliber, "workflow_name": self.workflow_name, "how": self.how,
"level": self.level, "level_text": LEVEL_TEXT.get(str(self.level or ""), ""),
"available": self.available, "auto": self.available and self.auto_reason is None}
if self.reason:
out["reason"] = self.reason
elif self.auto_reason:
out["auto_reason"] = self.auto_reason
if self.existing:
out["issued_run"] = self.existing
return out
def _local_midnight() -> datetime:
return datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
async def issued_today(session: Any) -> int:
return int(await session.scalar(select(func.count(Run.id)).where(
Run.origin_kind == ORIGIN, Run.created_at >= _local_midnight())) or 0)
async def issued_runs(session: Any, run_id: str) -> list[Run]:
"""这次运行按口径出具过的正式运行(origin.source_run 是它),新的在前。origin_kind 有索引,先按它筛再看 origin
(json_extract,同 auth/keys 认幂等键的写法)。"""
rows = (await session.execute(select(Run).where(
Run.origin_kind == ORIGIN, func.json_extract(Run.origin, "$.source_run") == run_id)
.order_by(Run.created_at.desc()))).scalars()
return list(rows)
def issued_view(run: Run) -> dict[str, Any]:
"""GET 账本 issued 里的一项:哪一次正式运行、状态、出具档位(TIER_TEXT,完整出具 / 降档出具 / 不予出具)。"""
from app.apps.resolve import TIER_TEXT
output = run.output if isinstance(run.output, dict) else {}
issuance = output.get("_issuance") if isinstance(output.get("_issuance"), dict) else {}
tier = issuance.get("tier")
origin = run.origin if isinstance(run.origin, dict) else {}
return {"run_id": run.id, "status": run.status, "end_reason": getattr(run, "end_reason", None),
"workflow_id": run.workflow_id, "version": run.version,
"tier": tier if tier in TIER_TEXT else None, "tier_text": TIER_TEXT.get(str(tier)),
"auto": bool(origin.get("auto")),
"created_at": run.created_at.isoformat() if run.created_at else None}
async def _starter(session: Any, run: Run) -> Any:
"""自动出具时按谁的权限判(拍板 L5):这次运行的发起人。关登录时同管理员;账号不在了的按使用者算(宁可少出具)。"""
from app.api.deps import Principal
from app.auth import current_mode
from app.auth.sessions import principal_of
from app.db.models import User
mode = current_mode()
if mode == "off":
return Principal(username=run.started_by, role="admin", verified=False, mode="off", via="off")
user = (await session.execute(select(User).where(User.username == run.started_by))).scalar_one_or_none() \
if run.started_by else None
if user is None:
return Principal(username=run.started_by, role="user", verified=False, mode=mode)
return await principal_of(session, user, mode)
async def _granting_app(session: Any, principal: Any, workflow_id: str, version: int) -> str | None:
"""使用者能按这一版出具,靠的是哪个应用的授权:固定着这一版、他至少能使用的应用(apps/access)。没有为 None。"""
from app.apps.access import app_role, at_least
from app.apps.versions import pinning_apps
from app.db.models_apps import App
for item in await pinning_apps(session, workflow_id, version):
app = await session.get(App, item["app_id"])
if app is not None and at_least(await app_role(session, principal, app), "use"):
return app.id
return None
async def plan_issue(session: Any, run: Run | None, content: dict[str, Any] | None, workflow_id: str, version: int, *,
principal: Any, hide: Any = None, artifact: str | None = None, retro: bool = False) -> Plan:
"""按 workflow_id@version 出具这次运行的打算(见模块说明)。content 是这次运行最新的比对工件内容(读不出为 None),
hide 判一个绑定值是不是这次运行查到过的遮罩值。只读,不发起。"""
from app.api.deps import can_read_run
from app.api.settings import ledger_settings
from app.apps import versions as app_versions
from app.apps.backfill import graph_side_effect
from app.apps.forms import input_schema, validate_form
from app.apps.resolve import input_key
from app.ledger import match as ledger_match
from app.ledger.derive import entry_fields
from app.ledger.labels import caliber_key
plan = Plan(run_id=run.id if run else "", workflow_id=workflow_id, version=int(version),
caliber=caliber_key(workflow_id, int(version)), match_artifact=artifact)
if run is None:
return plan.refuse(NOT_ELIGIBLE.format(why=ledger_match.NOT_FOUND), 404)
if principal.mode != "off" and not await can_read_run(principal, run):
return plan.refuse(NO_RUN_ACCESS, 403, FORBIDDEN)
if (why := ledger_match.ineligible(run)) is not None:
return plan.refuse(NOT_ELIGIBLE.format(why=why))
if content is None:
return plan.refuse(NOT_MATCHED)
whole = content.get("whole") or {}
workflow = await session.get(Workflow, workflow_id)
plan.workflow_name = workflow.name if workflow is not None else ""
if not whole.get("equivalent"):
return plan.refuse(NOT_WHOLE.format(why=whole.get("reason") or "没有比对结论"))
if (whole.get("workflow_id"), whole.get("version")) != (workflow_id, int(version)):
return plan.refuse(OTHER_CALIBER.format(name=plan.workflow_name or workflow_id, version=version))
plan.how, plan.level = whole.get("how"), whole.get("level")
plan.binding = dict(whole.get("binding") or {})
if hide is not None and any(hide(v) for v in plan.binding.values()):
return plan.refuse(MASKED_BINDING)
if workflow is None:
return plan.refuse(GONE, 404)
row = await app_versions.version_row(session, workflow_id, int(version))
if row is None:
return plan.refuse(VERSION_GONE.format(name=workflow.name, version=version), 404)
if row.retired_at is not None:
return plan.refuse(RETIRED.format(name=workflow.name, version=version))
if not app_versions.is_published(row, current=workflow.published_version):
return plan.refuse(UNPUBLISHED.format(name=workflow.name, version=version))
plan.level = row.level or plan.level
plan.expect_hash = row.graph_hash
if reason := await graph_side_effect(session, row.graph, level=row.level):
return plan.refuse(SIDE_EFFECT.format(name=workflow.name, version=version, reason=reason))
fields = list(entry_fields(row.graph or {}))
values = {str(f["name"]): f.get("default") for f in fields if f.get("default") not in (None, "")}
values.update({k: v for k, v in plan.binding.items() if v not in (None, "")})
plan.inputs = values
if errors := validate_form(input_schema(fields), values):
return plan.refuse(INPUTS.format(problems=";".join(e["message"] for e in errors)))
# 参数运行时要渲染进口径的 SQL 模板:写在引号里的不能带引号、裸写的只能是数(ledger/compose 同一个判断)
from app.ledger.compose import slot_contexts, value_problem
contexts = slot_contexts([n for n in (row.graph or {}).get("nodes") or [] if n.get("type") == "tool"],
strict=False)
if problem := next((p for k, v in values.items() if (p := value_problem(k, v, contexts.get(k, set())))), None):
return plan.refuse(f"{problem},不代填出具")
if principal.mode != "off" and not principal.at_least("builder"):
plan.granted_by_app = await _granting_app(session, principal, workflow_id, int(version))
if plan.granted_by_app is None:
return plan.refuse(ASK_BUILDER, 403, FORBIDDEN)
key = input_key(values)
plan.existing = next((r.id for r in await issued_runs(session, run.id)
if (r.workflow_id, r.version) == (workflow_id, int(version)) and r.status in _LIVE
and input_key(r.input) == key), None)
settings = await ledger_settings(session)
if plan.existing is None and await issued_today(session) >= int(settings["issue_daily_limit"]):
return plan.refuse(OVER_LIMIT.format(limit=settings["issue_daily_limit"]), 429, DAILY_LIMIT)
if not settings["auto_issue"]:
plan.auto_reason = AUTO_OFF
elif run.origin_kind != "chat":
plan.auto_reason = AUTO_NOT_CHAT.format(what=_NOT_CHAT_TEXT.get(run.origin_kind or "", "别处发起的运行"))
elif plan.how != "independent":
plan.auto_reason = AUTO_COMPOSED
elif plan.level != "governed":
plan.auto_reason = AUTO_LEVEL
elif retro:
plan.auto_reason = AUTO_RETRO
return plan
async def _conversation_of(session: Any, run: Run) -> dict[str, str]:
"""这次运行出自哪段对话的哪一轮:运行来源里封存的(origin_ref,第 2 波);老运行退回 conversation_turns.run_id。"""
from app.db.models import ConversationTurn
origin = run.origin if isinstance(run.origin, dict) else {}
if origin.get("conversation_id") and origin.get("turn_id"):
return {"conversation_id": str(origin["conversation_id"]), "turn_id": str(origin["turn_id"])}
turn = (await session.execute(select(ConversationTurn).where(ConversationTurn.run_id == run.id)
.order_by(ConversationTurn.created_at.desc()))).scalars().first()
return {"conversation_id": turn.conversation_id, "turn_id": turn.id} if turn is not None else {}
#: 同一(运行、工作流、版本)的出具串行:{事件循环: {键: 锁}}(asyncio.Lock 不能跨循环用,测试每个用例一个循环)
_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[tuple[str, str, int], asyncio.Lock]]" = \
weakref.WeakKeyDictionary()
def _lock(key: tuple[str, str, int]) -> asyncio.Lock:
group = _locks.setdefault(asyncio.get_running_loop(), {})
return group.setdefault(key, asyncio.Lock())
async def _hider(run_id: str) -> Any:
from app.api.evidence import _sealed
from app.ledger.view import _hider as hider_of
return (await hider_of(await _sealed(run_id))).hide
async def issue(run_id: str, workflow_id: str, version: int, *, principal: Any, auto: bool = False) -> tuple[Run, bool]:
"""按 workflow_id@version 出具这次运行:(正式运行, 是不是这一次新发起的)。不能出具抛 IssueRefused;已经发起过同一次的
交回那一次(第二项为 False)。auto:自动出具(自动的条件也要满足)。"""
from app.engine.launch import LaunchRefused, launch_run
from app.ledger import store
async with _lock((run_id, workflow_id, int(version))):
recorded = await store.latest(run_id)
hide = await _hider(run_id)
async with SessionLocal() as session:
run = await session.get(Run, run_id)
plan = await plan_issue(session, run, recorded.content if recorded else None, workflow_id, version,
principal=principal, hide=hide, artifact=recorded.artifact if recorded else None,
retro=bool(recorded and recorded.retro))
if plan.reason is not None:
raise IssueRefused(plan.status, plan.reason, plan.code)
if plan.existing is not None:
return await session.get(Run, plan.existing), False
if auto and plan.auto_reason is not None:
raise IssueRefused(409, plan.auto_reason)
from app.apps.resolve import input_key
origin = {"source_run": run_id, **await _conversation_of(session, run), "caliber": plan.caliber,
"how": plan.how, "binding_hash": input_key(plan.binding), "match": plan.match_artifact,
"auto": auto, **({"granted_by_app": plan.granted_by_app} if plan.granted_by_app else {})}
actor = run.started_by if auto else principal.username
try:
launched = await launch_run(session, workflow_id=workflow_id, run_class="formal", version=int(version),
input_payload=plan.inputs, actor=actor, origin_kind=ORIGIN, origin=origin,
expect_hash=plan.expect_hash)
except LaunchRefused as e:
raise IssueRefused(e.status_code, e.message, e.code or REFUSED) from e
return launched, True
async def plans_for(run_id: str, content: dict[str, Any] | None, *, principal: Any, hide: Any, artifact: str | None,
retro: bool) -> list[Plan]:
"""GET 账本的 actions 里出具那几项:整题等价的那一份口径能不能出具、能不能自动(整题不等价的没有这一项)。"""
whole = (content or {}).get("whole") or {}
if not whole.get("equivalent") or not whole.get("workflow_id") or whole.get("version") is None:
return []
async with SessionLocal() as session:
run = await session.get(Run, run_id)
return [await plan_issue(session, run, content, str(whole["workflow_id"]), int(whole["version"]),
principal=principal, hide=hide, artifact=artifact, retro=retro)]
async def auto_issue(run_id: str) -> str | None:
"""比对完、整题等价时看能不能自动出具(拍板 L1),能就发起;返回新发起的正式运行 id。不能的、出错的只记日志:自动出具
是辅助步骤,不影响运行本身和答案(照实说为什么没自动出具在 GET 账本的 actions 里)。"""
from app.ledger import store
try:
recorded = await store.latest(run_id)
whole = ((recorded.content if recorded else None) or {}).get("whole") or {}
if not whole.get("equivalent"):
return None
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
return None
principal = await _starter(session, run)
launched, fresh = await issue(run_id, str(whole["workflow_id"]), int(whole["version"]), principal=principal,
auto=True)
return launched.id if fresh else None
except IssueRefused as e:
logger.info("运行 %s 没有自动按口径出具:%s", run_id, e.message)
return None
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - 辅助步骤
logger.exception("运行 %s 自动按口径出具出错", run_id)
return None
__all__ = ["AUTO_NOT_CHAT", "DAILY_LIMIT", "FORBIDDEN", "ORIGIN", "REFUSED", "IssueRefused", "Plan", "auto_issue",
"issue", "issued_runs", "issued_today", "issued_view", "plan_issue", "plans_for"]
+18 -8
View File
@@ -2,7 +2,7 @@
标签四种(KINDS):
- caliber:来自口径「X」vN。how 是怎么认的:independent「等价已证明」(这次 Agent 自己写的 SQL 被证明和口径的取数等价)、
composed「助手选用」(助手按口径组合出来的;组合在第 3 波,这一波只留这个值)。
composed「助手选用」(助手 use_caliber 按口径组合出来的取数,证明了和那一版一致:ledger/match 的 composed)。
- related:相关口径——同一个数据源、查了同样的表的已发布口径,但不等价;附差在哪(explain_sql_diff 的条目)。
- reasoning:这次的推理——没有相关的已发布口径。
- unknown:没比对、比对没做完(超时、解析失败)、快照读不出来。
@@ -30,6 +30,8 @@ HOWS = ("independent", "composed")
#: 界面上标签要写明的两句:比对在封存之后做,不在封存范围内;事后补比的是按现在的口径回看
POST_SEAL_NOTE = "封存后比对,不在封存范围内"
RETRO_NOTE = "按现在的口径回看"
#: 固化出的口径发布之后对来源运行补比的(store 的 trigger=certified)
CERTIFIED_NOTE = "按之后发布的口径回看"
#: 发布级别的叫法(同 blueprints/publish_plan.LEVEL_TEXT、前端 lib/terms.ts)
LEVEL_TEXT = {"published": "已发布", "governed": "受管"}
MASKED = "已遮罩"
@@ -72,7 +74,8 @@ def _related(query: Mapping[str, Any], *, reason: str | None = None) -> dict[str
def _caliber(cand: Mapping[str, Any], **extra: Any) -> dict[str, Any]:
return {"kind": "caliber", "how": "independent", "caliber": cand.get("caliber"),
return {"kind": "caliber", "how": "composed" if cand.get("how") == "composed" else "independent",
"caliber": cand.get("caliber"),
"workflow_id": cand.get("workflow_id"), "version": cand.get("version"), "level": cand.get("level"),
"binding": dict(cand.get("binding") or {}), **extra}
@@ -183,13 +186,15 @@ def metric_label(card: Mapping[str, Any] | None, metric_id: Any, queries: Mappin
common = set.intersection(*proven_keys) if proven_keys else set()
nearby = _brief([c for q in queries_found for c in q.get("candidates") or []
if c.get("status") in ("proven", "related")])
queried = {"queries": [str(i["artifact"]) for i in from_queries]}
if len(from_queries) < len(inputs):
return {"kind": "related", "related": nearby, "reason": INPUTS_ELSEWHERE} if nearby else {"kind": "reasoning"}
return {"kind": "related", "related": nearby, "reason": INPUTS_ELSEWHERE, **queried} if nearby \
else {"kind": "reasoning", **queried}
if not common:
if all(proven_keys):
return {"kind": "related", "related": nearby, "reason": MIXED_CALIBERS}
return {"kind": "related", "related": nearby, "reason": MIXED_CALIBERS, **queried}
related = next((q for q in queries_found if q.get("status") == "related"), None)
return _related(related) if related is not None else {"kind": "reasoning"}
return {**_related(related), **queried} if related is not None else {"kind": "reasoning", **queried}
reason = FORMULA_DIFFERS
for key in sorted(common):
chosen = [next(c for c in _proven(q) if c.get("caliber") == key) for q in queries_found]
@@ -218,9 +223,14 @@ def metric_label(card: Mapping[str, Any] | None, metric_id: Any, queries: Mappin
if theirs == mine), None)
if hit is None:
continue
return {**_caliber(chosen[0], card=hit[0], metric=hit[1]), "binding": binding}
label = {**_caliber(chosen[0], card=hit[0], metric=hit[1]), "binding": binding,
"queries": [str(i["artifact"]) for i in from_queries]}
if any(c.get("how") == "composed" for c in chosen):
# 几个输入里有一个是助手选用的,整个指标就按助手选用说(不能说成都是独立写出来的)
label["how"] = "composed"
return label
return {"kind": "related", "related": _brief([c for q in queries_found for c in _proven(q)
if c.get("caliber") in common]), "reason": reason}
if c.get("caliber") in common]), "reason": reason, **queried}
def _brief(cands: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
@@ -348,6 +358,6 @@ def related_view(item: Mapping[str, Any], *, hide: Callable[[Any], bool], shown:
return out
__all__ = ["HOWS", "KINDS", "LEVEL_TEXT", "POST_SEAL_NOTE", "RETRO_NOTE", "binding_view", "caliber_key",
__all__ = ["CERTIFIED_NOTE", "HOWS", "KINDS", "LEVEL_TEXT", "POST_SEAL_NOTE", "RETRO_NOTE", "binding_view", "caliber_key",
"card_expressions", "cell_label", "diff_view", "label_doc", "metric_label", "related_view", "view_label",
"whole_of"]
+75 -15
View File
@@ -10,10 +10,16 @@ version 记。系统派生的运行(评测、对账、召回)不比:它们
MAX_CANDIDATES 份(按工作流、版本、节点排好的先后)。
**怎么比。** data/sql_semantics.match_template:先绑定参数(只是启发),再按绑定渲染口径的取数 SQL 交 explain_sql_diff 证明。
**助手选用的(composed)。** 问数据的助手 use_caliber 选用口径时,取数节点是服务端照那一版拷进来的(ledger/compose.py),
口径卡的 caliber_from 带着改名映射 {原 id: 新 id}。这几个节点交回的查询照样要证明:证明了的那一份记 how=composed,查询
状态记 composed——数确实来自口径,但「这份口径答的就是这个问题」是助手凭问法判断的,不能说成「等价已证明」(拍板 L1:
自动正式出具只给独立等价的)。没能证明的(拷进来之后在画布上被改过)照常是相关口径,另写一句为什么。映射从这次运行
封存的图里读(composed_nodes),比对时把那一份口径排在最前面,不会因为候选太多被挤掉。
schema 用这条查询当时冻结的表结构(表结构快照),列的中文名取同一份快照里冻结的数据目录。只读封存范围内的快照(api/evidence
的 _sealed 读法),读不出、被改过的快照不比,记 skipped。
**资源。** 每次运行限时 TIMEOUT_S(缺省 8 秒),在线程里跑,同时最多 CONCURRENCY(2)个(照 scheduler/auto_compare 的做法:
**资源。** 每次运行限时(设置 ledger.match_timeout_s,缺省 TIMEOUT_S = 8 秒),在线程里跑,同时最多 CONCURRENCY(2)个(照 scheduler/auto_compare 的做法:
信号量按事件循环分开,线程池两个线程)。时限按绑定逐组看:过了时限,还没比的口径记 skipped(比对超时);整次比对卡在线程里
过了时限再加 GRACE_S 还没回来,整次记 skipped。解析失败同样照实记 skipped 和原因。都只记日志和 skipped,不影响运行本身。
@@ -68,6 +74,7 @@ SNAPSHOT_MISSING = "查询快照读不出来,不比对"
NO_SQL = "查询快照里没有 SQL,不比对"
PARSE_FAILED = "这次的 SQL 无法解析,没有比对"
MERGED = "合并查询的格不和口径比对(它的数来自各个输入的查询)"
COMPOSED_UNPROVEN = "这条查询出自助手选用的口径,但和口径的取数没能证明一致(可能被改过)"
MORE_CANDIDATES = "另有 {n} 份相关口径没有比对(每条查询最多比 {max} 份)"
#: 不比对的原因(GET 账本时照实说)
@@ -304,11 +311,32 @@ def _skipped(caliber: Caliber, fetch: Fetch, reason: str) -> dict[str, Any]:
"level": caliber.level, "node_id": fetch.node_id, "status": "skipped", "reason": reason}
def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: float | None = None
) -> list[dict[str, Any]]:
def composed_nodes(graph: Any) -> dict[str, tuple[str, str]]:
"""这次运行的图里助手选用口径拷进来的取数节点:{这张图里的节点 id: (「工作流@版本」, 那一版里的原 id)}。从口径卡的
caliber_from.nodes 读(ledger/compose.py 实例化时写的);没有改名映射的口径卡不算(蓝图复用口径时取数是模型写的)。"""
out: dict[str, tuple[str, str]] = {}
for node in (graph or {}).get("nodes") or [] if isinstance(graph, dict) else []:
if not isinstance(node, dict) or node.get("type") != "metrics":
continue
cfg = (node.get("data") or {}).get("config") if isinstance(node.get("data"), dict) else None
ref = cfg.get("caliber_from") if isinstance(cfg, dict) else None
rename = ref.get("nodes") if isinstance(ref, dict) else None
version = ref.get("workflow_version") if isinstance(ref, dict) else None
if not isinstance(rename, dict) or not ref.get("workflow_id") or not str(version or "").isdigit():
continue
key = ledger_labels.caliber_key(str(ref["workflow_id"]), int(version))
for old, new in rename.items():
if isinstance(old, str) and isinstance(new, str):
out.setdefault(new, (key, old))
return out
def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: float | None = None,
composed: dict[str, tuple[str, str]] | None = None) -> list[dict[str, Any]]:
"""每条查询和预筛出来的口径取数逐个比(match_template),给出 [{artifact, node_id, source, tables, status, reason?,
candidates}]。status:proven(和某一份等价)/ related(有同源同表的口径,都不等价)/ none(没有相关的口径)/ skipped
(没比完:超时、解析失败、快照读不出)。纯计算,从不抛异常。评测逐题比 SQL 也调它。"""
candidates}]。status:proven(和某一份等价)/ composed(助手选用的那一份,证明了)/ related(有同源同表的口径,都不
等价)/ none(没有相关的口径)/ skipped(没比完:超时、解析失败、快照读不出)。composed 是 composed_nodes 的结果,
不给就没有助手选用的。纯计算,从不抛异常。评测逐题比 SQL 也调它。"""
from app.data.sql_semantics import match_template, normalize_sql
out: list[dict[str, Any]] = []
@@ -318,7 +346,11 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline:
if query.problem:
out.append({**row, "status": "skipped", "reason": query.problem, "candidates": []})
continue
chosen = (composed or {}).get(str(query.node_id or ""))
pool = [(c, f) for c in calibers for f in c.fetches if f.source == query.source and f.tables & query.tables]
if chosen is not None:
# 助手选用的那一份排最前面:候选多的时候也一定比到它
pool.sort(key=lambda cf: (cf[0].key, cf[1].node_id) != chosen)
if not pool:
out.append({**row, "status": "none", "candidates": []})
continue
@@ -337,9 +369,18 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline:
logger.exception("口径比对出错(运行查询 %s,口径 %s)", query.artifact[:12], caliber.key)
candidates.append(_skipped(caliber, fetch, "比对出错"))
continue
candidates.append(_candidate(caliber, fetch, m))
cand = _candidate(caliber, fetch, m)
if chosen is not None and (caliber.key, fetch.node_id) == chosen:
if cand["status"] == "proven":
cand["how"] = "composed"
else:
cand.setdefault("reason", COMPOSED_UNPROVEN)
candidates.append(cand)
statuses = {c["status"] for c in candidates}
status = "proven" if "proven" in statuses else "skipped" if "skipped" in statuses else "related"
if status == "proven" and any(c.get("how") == "composed" for c in candidates):
# 证明了的是助手选用的那一份:数来自口径,但不是这次独立写出来的(见模块说明)。同时和别的口径等价的也按选用的算
status = "composed"
if len(pool) > MAX_CANDIDATES:
row["note"] = MORE_CANDIDATES.format(n=len(pool) - MAX_CANDIDATES, max=MAX_CANDIDATES)
if status == "skipped":
@@ -359,7 +400,7 @@ def compute(sealed: Any, calibers: list[Caliber], kinds: dict[str, str | None],
results = [{"artifact": q.artifact, "node_id": q.node_id, "source": q.source, "tables": sorted(q.tables),
"status": "skipped", "reason": SEAL_BROKEN, "candidates": []} for q in queries]
else:
results = compare_queries(queries, calibers, deadline=deadline)
results = compare_queries(queries, calibers, deadline=deadline, composed=composed_nodes(sealed.graph))
used = {c["caliber"] for q in results for c in q["candidates"]}
considered = [c for c in calibers if c.key in used]
by_key = {c.key: {"level": c.level, "fields": c.summary()["fields"], "expressions": list(c.expressions)}
@@ -436,15 +477,21 @@ async def _kinds(session: Any) -> dict[str, str | None]:
return {name: kind for name, kind in (await session.execute(select(DataSource.name, DataSource.kind))).all()}
async def match_run(run_id: str, *, trigger: str = "sealed", timeout: float = TIMEOUT_S) -> str | None:
async def match_run(run_id: str, *, trigger: str = "sealed", timeout: float | None = None) -> str | None:
"""比对一次运行,存工件、追加 ledger.matched;返回工件 id。不比对(不是探索运行、没成功、没封存、没有查询)、比对期间
运行换了封存时返回 None。比对本身出的问题都记成 skipped,不抛。"""
运行换了封存时返回 None。比对本身出的问题都记成 skipped,不抛。timeout 不给按设置(ledger.match_timeout_s)。
运行一封存就比的(trigger=sealed)比完接着看能不能按口径自动正式出具(ledger/issue.auto_issue,拍板 L1):事后补比、
按需比的不自动出具——那是按现在的口径回看老答案,不该替人补发正式运行。"""
from app.api.evidence import _sealed
from app.api.settings import ledger_settings
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if ineligible(run) is not None:
return None
if timeout is None:
timeout = float((await ledger_settings(session))["match_timeout_s"])
sealed = await _sealed(run_id)
if not sealed.queries:
return None
@@ -459,7 +506,12 @@ async def match_run(run_id: str, *, trigger: str = "sealed", timeout: float = TI
except TimeoutError:
logger.warning("运行 %s 的口径比对超过 %g 秒仍未完成,记为没比完", run_id, timeout + GRACE_S)
content = _timed_out(sealed)
return await store.save(run_id, content, seal=(sealed.run_manifest_seq, sealed.manifest_hash), trigger=trigger)
artifact = await store.save(run_id, content, seal=(sealed.run_manifest_seq, sealed.manifest_hash), trigger=trigger)
if artifact is not None and trigger == "sealed" and (content.get("whole") or {}).get("equivalent"):
from app.ledger import issue
await issue.auto_issue(run_id)
return artifact
def schedule(run_id: str, *, trigger: str = "sealed") -> bool:
@@ -544,9 +596,17 @@ def _mine() -> list[asyncio.Task[Any]]:
async def drain() -> None:
"""等这个事件循环里的后台比对都做完(测试用)。"""
while tasks := _mine():
await asyncio.gather(*tasks, return_exceptions=True)
"""等这个事件循环里的后台比对都做完(测试用)。只等还没做完的:已经做完、还留在登记里的(完成回调还没来得及跑)顺手
去掉——不然对着做完的任务反复 gather,事件循环原地空转(并行跑全量测试时偶发过)。"""
while True:
tasks = _mine()
for task in tasks:
if task.done():
_background.discard(task)
live = [task for task in tasks if not task.done()]
if not live:
return
await asyncio.gather(*live, return_exceptions=True)
async def shutdown() -> None:
@@ -558,5 +618,5 @@ async def shutdown() -> None:
__all__ = ["BACKFILL_DAYS", "CONCURRENCY", "EXPLORE_ORIGINS", "TIMEOUT_S", "Caliber", "Fetch", "Query", "backfill",
"caliber_of", "compare_queries", "compute", "drain", "ineligible", "load_calibers", "match_run", "pending",
"read_queries", "schedule", "shutdown", "start_backfill", "wanted"]
"caliber_of", "compare_queries", "composed_nodes", "compute", "drain", "ineligible", "load_calibers",
"match_run", "pending", "read_queries", "schedule", "shutdown", "start_backfill", "wanted"]
+524
View File
@@ -0,0 +1,524 @@
"""口径对账:路径二b,差在哪、差多少(docs/design/next-round/02-口径账本.md 做法 6,总方案 X3)。
**差在哪**在比对时就有(ledger/match:explain_sql_diff 的条目,这次 / 口径两边说),不跑查询。**差多少**要点开才跑:
按那一版口径确定性派生一张只读小图(ledger/derive.reconcile_graph,全站唯一的派生图生成器),跑一次探索运行
(来源 reconcile,服务端专用、系统派生、不进使用统计):
入口(口径的入口字段,缺省值是这次的参数)→「这次的取数」ours_q1(执行过的 SQL 原文)→ 口径的取数(模板里保留
{{ input.x }})→ 口径卡(caliber_from 固定那一版)→(多行时)按维度对齐的合并查询 align → 对账卡(这次、口径、差值、
相对差)→ 出口(契约 metrics_from 指向对账卡;不加报告、不调模型)
两边在同一次运行里查,差值在对账卡里算(叙述层无算术,也不在 Python 里算:compare.py 是同一工作流两次运行配对用的)。
面板上每个数都点得开 /evidence/metrics 的出处链。
**参数。** 默认用比对时绑定出来的参数(缺的补缺省值)。参数是启发得来的(绑定在 heuristic 一级配上)、有没绑上的、补完
缺省值表单还不齐、或者里面有遮罩值时,要人先核对:动作里写 needs_check 和原因,发起时要带 confirm 和 inputs(按口径入口
的字段校验,ledger/compose.check_inputs 同一套),界面写「参数按这次的 SQL 推断,请核对」。
**列怎么配。** 只有一行结果时:口径卡上的指标式就是取数第一行某一列(cell(nodes.取数, 0, '列'))的,按那一列在口径取数
结果里的位置配这次结果同一位置的列(等价时已实测列的先后计;不等价的两条 SQL 选择列表一样时位置照样对得上)。差异
有兜底说明(fallback:两条 SQL 对不上结构)、列数不同、一个都配不上时不猜,要人选(pairs:{ours: 这次的列, metric: 口径卡
上的指标 id}),这个选择记进对账运行的 origin。
**多行结果**(这次的结果不止一行):被报告引用了的那几行(最多 MAX_ROWS 行),按维度列(不是数的列)经合并查询和口径
取数的结果按键对齐(LEFT JOIN,按引用的先后排),每个被引用的行、每个被引用的列出一组对账指标。两边的列按位置配(列数
不同时不猜,照实拒绝)。维度列是遮罩列、或者这次的结果里合并键不唯一时照实拒绝:前者对齐要用到遮住的值,后者对不准
是哪一行。被引用的列是遮罩列的同样不对账。
只读:派生图只查库、不写;使用者发起时带上 MASK_RESULTS / READ_ONLY(同问数据),搭建者也带 READ_ONLY。对账运行记在
使用者名下,但它的原始记录、事件、运行时的图不给使用者(api/deps.run_events_readable 只给问数据的运行):图里原样是口径
取数节点的 SQL 模板,他经账本和证据接口看结果(展示的 SQL 按遮罩换过)。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, Workflow
ORIGIN = "reconcile"
#: 被拒的机器码
REFUSED = "ledger_reconcile_refused"
CHECK_INPUTS = "ledger_reconcile_check_inputs"
NEEDS_PAIRS = "ledger_reconcile_needs_pairs"
FORBIDDEN = "ledger_reconcile_forbidden"
#: 多行对账最多几行、几列(每行每列出四个对账指标)
MAX_ROWS = 10
MAX_COLUMNS = 5
#: 给人看的说法
NOT_FOUND = "运行记录不存在"
NO_ACCESS = "没有权限查看这次运行"
NOT_MATCHED = "这次运行还没有比对口径,比对完才能对账"
NO_QUERY = "这次运行的比对记录里没有这条查询"
NOT_RELATED = "这条查询没有和「{name}」v{version} 比出差异(只有相关口径才对账)"
NO_CARD = "「{name}」v{version} 里没有读这个取数节点的口径卡,对不了账"
SNAPSHOT = "这条查询的快照读不出来(文件缺失或与哈希不一致),对不了账"
CHECK_REASON = "参数按这次的 SQL 推断,请核对"
CHECK_MASKED = "参数里有遮罩值,请核对参数"
CHECK_INCOMPLETE = "按这次的 SQL 没能推断出全部参数,请补全并核对"
CONFIRM_FIRST = "参数按这次的 SQL 推断,发起前请先核对参数"
PAIRS_FALLBACK = "两条 SQL 的结构对不上,认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标"
PAIRS_NONE = "认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标"
PAIR_COLUMN = "这次的结果里没有列「{column}」"
PAIR_MASKED = "列「{column}」设置了遮罩,不能对账"
PAIR_METRIC = "口径卡「{card}」上没有指标「{metric}」"
PAIRS_SHAPE = "要对照的指标需要写成一组「这次的列 → 口径卡上的指标」的对照"
ROWS_NONE = "报告里没有引用这条查询的格,不知道要对哪几行"
ROWS_NO_KEY = "这次的结果有多行,但没有可以用来对齐的维度列(不是数的列)"
ROWS_MASKED_KEY = "用来对齐的维度列「{column}」设置了遮罩,不能对账"
ROWS_DUPLICATE = "这次的结果里按维度列({columns})有重复的行,对不准是哪一行"
ROWS_COLUMNS = "这次的结果和口径的取数列数不同({ours} 列对 {theirs} 列),按位置对不上"
ROWS_CALIBER_COLUMNS = "认不出口径取数的结果列,按位置对不上"
class ReconcileRefused(Exception):
def __init__(self, status_code: int, message: str, code: str = REFUSED) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
# ==========================================================================
# 动作:GET 账本里能对的账
# ==========================================================================
def _needs_check(cand: dict[str, Any], fields: list[dict[str, Any]], hide: Any) -> str | None:
"""参数要不要人先核对:要的回原因(见模块说明),不要的为 None。fields 是比对工件里记的入口字段(name、required、
has_default)。"""
binding = cand.get("binding") or {}
if hide is not None and any(hide(v) for v in binding.values()):
return CHECK_MASKED
if cand.get("unbound") or not binding and any(f.get("name") for f in fields):
return CHECK_INCOMPLETE
if any(f.get("required") and not f.get("has_default") and f.get("name") not in binding for f in fields):
return CHECK_INCOMPLETE
if cand.get("via") in (None, "heuristic"):
return CHECK_REASON
return None
def actions_of(content: dict[str, Any], names: dict[str, str], hide: Any = None) -> list[dict[str, Any]]:
"""比对工件里每条有相关口径的查询、每份相关口径一项「看差多少」:{kind: reconcile, query, workflow_id, version, node_id,
needs_check, check_reason?, binding(遮过的)}。界面据此摆按钮、要不要先核对参数。"""
from app.ledger.labels import LEVEL_TEXT, binding_view
hide = hide or (lambda _value: False)
fields = {c.get("caliber"): c.get("fields") or [] for c in content.get("calibers") or []}
out: list[dict[str, Any]] = []
for query in content.get("queries") or []:
if query.get("status") not in ("related",):
continue
for cand in query.get("candidates") or []:
if cand.get("status") != "related" or cand.get("comparable") is False:
continue
why = _needs_check(cand, list(fields.get(cand.get("caliber")) or []), hide)
item = {"kind": "reconcile", "query": query.get("artifact"), "caliber": cand.get("caliber"),
"workflow_id": cand.get("workflow_id"), "version": cand.get("version"),
"node_id": cand.get("node_id"), "workflow_name": names.get(str(cand.get("workflow_id") or "")),
"level_text": LEVEL_TEXT.get(str(cand.get("level") or ""), ""),
"needs_check": why is not None, "binding": binding_view(cand.get("binding"), hide),
"fallback": bool((cand.get("diff") or {}).get("fallback"))}
if why is not None:
item["check_reason"] = why
out.append(item)
return out
async def reconciled_runs(session: Any, run_id: str) -> list[Run]:
"""对这次运行对过的账(origin.source_run 是它),新的在前。"""
from sqlalchemy import func
rows = (await session.execute(select(Run).where(
Run.origin_kind == ORIGIN, func.json_extract(Run.origin, "$.source_run") == run_id)
.order_by(Run.created_at.desc()))).scalars()
return list(rows)
def reconciled_view(run: Run) -> dict[str, Any]:
origin = run.origin if isinstance(run.origin, dict) else {}
return {"run_id": run.id, "status": run.status, "query": origin.get("query"), "caliber": origin.get("caliber"),
"workflow_id": origin.get("workflow_id"), "version": origin.get("version"),
"inputs_from": origin.get("inputs_from"), "rows": origin.get("rows"),
"created_at": run.created_at.isoformat() if run.created_at else None}
# ==========================================================================
# 派生图
# ==========================================================================
@dataclass
class Pair:
"""一组对照:这次结果的哪一列,对口径卡上的哪个指标(metric)或口径取数结果的哪一列(column)。"""
ours: str
metric: str | None = None
column: str | None = None
#: 口径卡上那个指标的位置(metric 配对时)
index: int | None = None
name: str = ""
unit: str = ""
decimals: Any = None
def describe(self) -> dict[str, Any]:
return {"ours": self.ours, **({"metric": self.metric} if self.metric else {}),
**({"caliber": self.column} if self.column else {})}
@dataclass
class Plan:
"""一次对账要派生的东西(纯计算的结果,测试锁住确定性)。"""
pairs: list[Pair]
rows: list[int] | None = None
keys: list[tuple[str, str]] = field(default_factory=list)
key_values: list[tuple[Any, ...]] = field(default_factory=list)
def _lit(value: Any) -> str:
"""一个值写进合并查询(SQLite)的字面量。"""
if value is None:
return "NULL"
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return repr(value)
return "'" + str(value).replace("'", "''") + "'"
def _ident(name: str) -> str:
return '"' + str(name).replace('"', '""') + '"'
def _equals(alias: str, column: str, value: Any) -> str:
return f"{alias}.{_ident(column)} IS NULL" if value is None else f"{alias}.{_ident(column)} = {_lit(value)}"
def align_sql(keys: list[tuple[str, str]], pairs: list[Pair], values: list[tuple[Any, ...]]) -> str:
"""多行对账的合并查询:这次(o)LEFT JOIN 口径(c)按维度列对齐,只留被引用的那几行,按引用的先后排。"""
select_ = [f"o.{_ident(ours)} AS {_ident(ours)}" for ours, _ in keys]
select_ += [f"o.{_ident(p.ours)} AS {_ident(f'ours_{j}')}, c.{_ident(p.column or '')} AS {_ident(f'cal_{j}')}"
for j, p in enumerate(pairs)]
on = " AND ".join(f"o.{_ident(ours)} = c.{_ident(theirs)}" for ours, theirs in keys)
rows = ["(" + " AND ".join(_equals("o", ours, v) for (ours, _), v in zip(keys, row)) + ")" for row in values]
order = " ".join(f"WHEN {cond} THEN {i}" for i, cond in enumerate(rows))
return (f"SELECT {', '.join(select_)} FROM o LEFT JOIN c ON {on} WHERE {' OR '.join(rows)} "
f"ORDER BY CASE {order} END")
def _metric(mid: str, name: str, expression: str, *, unit: str = "", decimals: Any = None,
fmt: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {"id": mid, "name": name, "unit": unit, "expression": expression}
if decimals is not None:
out["decimals"] = decimals
if fmt:
out["format"] = fmt
return out
def _four(n: str, label: str, ours: str, theirs: str, pair: Pair) -> list[dict[str, Any]]:
"""一组对照的四个对账指标:这次、口径、差值(这次 − 口径)、相对差(差值 ÷ 口径,口径是 0 时没有值)。"""
diff = f"({ours}) - ({theirs})"
return [
_metric(f"ours_{n}", f"{label} · 这次", ours, unit=pair.unit, decimals=pair.decimals),
_metric(f"cal_{n}", f"{label} · 口径", theirs, unit=pair.unit, decimals=pair.decimals),
_metric(f"diff_{n}", f"{label} · 差值", diff, unit=pair.unit, decimals=pair.decimals),
_metric(f"rel_{n}", f"{label} · 相对差", f"({diff}) / ({theirs}) if ({theirs}) else None", decimals=4,
fmt="percent_of_ratio"),
]
def tail_nodes(plan: Plan, *, card_id: str, fetch_id: str) -> list[dict[str, Any]]:
"""派生图的 tail:(多行时)对齐的合并查询 + 对账卡。card_id、fetch_id 是图里口径卡、口径取数节点的 id。"""
from app.ledger.derive import ALIGN_ID, OURS_ID, RECONCILE_CARD, _node
metrics: list[dict[str, Any]] = []
nodes: list[dict[str, Any]] = []
if plan.rows is None:
for j, pair in enumerate(plan.pairs):
ours = f"cell(nodes.{OURS_ID}, 0, {pair.ours!r})"
theirs = (f"nodes.{card_id}.metrics[{pair.index}].value" if pair.metric is not None
else f"cell(nodes.{fetch_id}, 0, {pair.column!r})")
metrics += _four(str(j), pair.name or pair.ours, ours, theirs, pair)
else:
nodes.append(_node(ALIGN_ID, "merge", "按维度对齐", inputs={"o": OURS_ID, "c": fetch_id},
sql=align_sql(plan.keys, plan.pairs, plan.key_values)))
for i, row in enumerate(plan.key_values):
where = ",".join(f"{ours}={v}" for (ours, _), v in zip(plan.keys, row))
for j, pair in enumerate(plan.pairs):
metrics += _four(f"{i}_{j}", f"{where} · {pair.name or pair.ours}",
f"cell(nodes.{ALIGN_ID}, {i}, 'ours_{j}')", f"cell(nodes.{ALIGN_ID}, {i}, 'cal_{j}')",
pair)
nodes.append(_node(RECONCILE_CARD, "metrics", "对账卡", caliber="口径对账", caliber_version="v1", on_missing="null",
metrics=metrics))
return nodes
def _numeric(rows: list[Any], index: int) -> bool:
values = [r[index] for r in rows if isinstance(r, (list, tuple)) and index < len(r) and r[index] is not None]
return bool(values) and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values)
def _plain_cell(expression: str, fetch: str) -> str | None:
"""指标式就是取数第一行某一列(cell(nodes.<fetch>, 0, '列'))时交回列名,别的写法为 None。"""
import ast
from app.engine.expressions import cell_parts
parts = cell_parts(expression.strip())
if parts is None or parts[0] != f"nodes.{fetch}" or parts[1].strip() != "0":
return None
try:
column = ast.literal_eval(parts[2])
except (ValueError, SyntaxError):
return None
return column if isinstance(column, str) else None
def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str], caliber_columns: list[str] | None,
metrics: list[dict[str, Any]], fetch: str, card: str, fallback: bool, chosen: Any,
referenced: list[tuple[int, str]]) -> Plan:
"""配列、(多行时)定行和键:交回 Plan,配不了的抛 ReconcileRefused(见模块说明)。纯计算。"""
def check_column(column: str) -> None:
if column not in ours_columns:
raise ReconcileRefused(422, PAIR_COLUMN.format(column=column))
if column.lower() in hidden:
raise ReconcileRefused(409, PAIR_MASKED.format(column=column))
by_id = {str(m.get("id")): (i, m) for i, m in enumerate(metrics)}
multi = len(ours_rows) > 1
if multi:
return _plan_rows(ours_columns, ours_rows, hidden, caliber_columns, referenced, check_column)
if chosen is not None:
if not isinstance(chosen, list) or not chosen or not all(isinstance(p, dict) for p in chosen):
raise ReconcileRefused(422, PAIRS_SHAPE, NEEDS_PAIRS)
pairs = []
for item in chosen:
ours, metric = str(item.get("ours") or ""), str(item.get("metric") or "")
check_column(ours)
if metric not in by_id:
raise ReconcileRefused(422, PAIR_METRIC.format(card=card, metric=metric))
index, m = by_id[metric]
pairs.append(Pair(ours=ours, metric=metric, index=index, name=str(m.get("name") or metric),
unit=str(m.get("unit") or ""), decimals=m.get("decimals")))
return Plan(pairs=pairs)
if fallback:
raise ReconcileRefused(409, PAIRS_FALLBACK, NEEDS_PAIRS)
pairs = []
if caliber_columns is not None and len(caliber_columns) == len(ours_columns):
for index, m in enumerate(metrics):
column = _plain_cell(str(m.get("expression") or ""), fetch)
if column is None or column not in caliber_columns:
continue
ours = ours_columns[caliber_columns.index(column)]
if ours.lower() in hidden:
continue
pairs.append(Pair(ours=ours, metric=str(m.get("id")), index=index, name=str(m.get("name") or m.get("id")),
unit=str(m.get("unit") or ""), decimals=m.get("decimals")))
if not pairs:
raise ReconcileRefused(409, PAIRS_NONE, NEEDS_PAIRS)
return Plan(pairs=pairs)
def _plan_rows(ours_columns: list[str], rows: list[Any], hidden: set[str], caliber_columns: list[str] | None,
referenced: list[tuple[int, str]], check_column: Any) -> Plan:
if caliber_columns is None:
raise ReconcileRefused(409, ROWS_CALIBER_COLUMNS)
if len(caliber_columns) != len(ours_columns):
raise ReconcileRefused(409, ROWS_COLUMNS.format(ours=len(ours_columns), theirs=len(caliber_columns)))
keys = [i for i in range(len(ours_columns)) if not _numeric(rows, i)]
if not keys:
raise ReconcileRefused(409, ROWS_NO_KEY)
if masked := [ours_columns[i] for i in keys if ours_columns[i].lower() in hidden]:
raise ReconcileRefused(409, ROWS_MASKED_KEY.format(column=masked[0]))
tuples = [tuple(r[i] if isinstance(r, (list, tuple)) and i < len(r) else None for i in keys) for r in rows]
if len(set(map(json.dumps, [list(t) for t in tuples]))) != len(tuples):
raise ReconcileRefused(409, ROWS_DUPLICATE.format(columns="、".join(ours_columns[i] for i in keys)))
wanted_rows = list(dict.fromkeys(r for r, _ in referenced if isinstance(r, int) and 0 <= r < len(rows)))[:MAX_ROWS]
columns = list(dict.fromkeys(c for _, c in referenced if c in ours_columns and ours_columns.index(c) not in keys))
if not wanted_rows or not columns:
raise ReconcileRefused(409, ROWS_NONE)
for column in columns:
check_column(column)
pairs = [Pair(ours=c, column=caliber_columns[ours_columns.index(c)], name=c) for c in columns[:MAX_COLUMNS]]
return Plan(pairs=pairs, rows=wanted_rows, keys=[(ours_columns[i], caliber_columns[i]) for i in keys],
key_values=[tuples[r] for r in wanted_rows])
def _referenced(sealed: Any, artifact: str) -> list[tuple[int, str]]:
"""报告里引用了这条查询的哪几格:[(行, 列)],按正文的先后(单元格、表格里的格)。"""
from app.api.evidence import _reports
from app.engine.evidence import iter_units
out: list[tuple[int, str]] = []
for report in _reports(sealed):
doc = report.doc if report.hash_ok is True else None
if not isinstance(doc, dict):
continue
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
cite = seg.get("cite") if isinstance(seg, dict) and isinstance(seg.get("cite"), dict) else {}
entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {}
locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
if cite.get("kind") == "cell" and entry.get("artifact") == artifact \
and isinstance(locator.get("row"), int) and isinstance(locator.get("column"), str):
out.append((locator["row"], locator["column"]))
return out
def _caliber_columns(template: str, inputs: dict[str, Any], fields: list[dict[str, Any]], dialect: str | None,
schema: dict[str, Any] | None) -> list[str] | None:
"""口径取数按这次的参数渲染之后选出来的列名(按位置);认不出的为 None。"""
from app.data.sql_semantics import _read, _result_columns, _schema_info, render_bound
try:
rendered = render_bound(template, inputs, fields)
return _result_columns(rendered, _read(dialect), _schema_info(schema))
except Exception: # noqa: BLE001 - 认不出就按认不出说
return None
# ==========================================================================
# 发起
# ==========================================================================
@dataclass
class Request:
query: str
workflow_id: str
version: int
inputs: dict[str, Any] | None = None
confirm: bool = False
pairs: Any = None
async def reconcile(run_id: str, req: Request, *, principal: Any) -> Run:
"""对这次运行的一条查询和一份相关口径对一次账:派生只读图、发起探索运行(来源 reconcile)。不能对的抛 ReconcileRefused。"""
from app.api.deps import can_read_run
from app.api.evidence import _Masks, _sealed
from app.core.artifact_store import load
from app.engine.launch import LaunchRefused, launch_run
from app.ledger import compose, derive, store
from app.ledger.issue import _conversation_of
from app.ledger.labels import caliber_key
from app.ledger.match import _frozen
from app.ledger.view import _hider
from app.tools.datasource import MASK_RESULTS_OPTION, QUERY_PREFIX, READ_ONLY_OPTION
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise ReconcileRefused(404, NOT_FOUND)
if principal.mode != "off" and not await can_read_run(principal, run):
raise ReconcileRefused(403, NO_ACCESS, FORBIDDEN)
recorded = await store.latest(run_id)
if recorded is None or recorded.content is None:
raise ReconcileRefused(409, NOT_MATCHED)
content = recorded.content
key = caliber_key(req.workflow_id, int(req.version))
query = next((q for q in content.get("queries") or [] if q.get("artifact") == req.query), None)
if query is None:
raise ReconcileRefused(404, NO_QUERY)
async with SessionLocal() as session:
workflow = await session.get(Workflow, req.workflow_id)
name = workflow.name if workflow is not None else req.workflow_id
cand = next((c for c in query.get("candidates") or [] if c.get("caliber") == key and c.get("status") == "related"),
None)
if cand is None:
raise ReconcileRefused(409, NOT_RELATED.format(name=name, version=req.version))
summary = next((c for c in content.get("calibers") or [] if c.get("caliber") == key), {})
pinned = None
async with SessionLocal() as session:
for card_id in summary.get("cards") or []:
try:
candidate = await derive.load_pinned(session, req.workflow_id, int(req.version), str(card_id))
except derive.NotDerivable:
continue
if any(node == cand.get("node_id") for node, _, _ in candidate.reads):
pinned = candidate
break
if pinned is None:
raise ReconcileRefused(409, NO_CARD.format(name=name, version=req.version))
# 参数:比对时绑定出来的(补缺省值),要核对的要人带着核对过的参数再来
sealed = await _sealed(run_id)
hider = await _hider(sealed)
why = _needs_check(cand, list(summary.get("fields") or []), hider.hide)
if req.confirm and req.inputs is not None:
try:
inputs = compose.check_inputs(pinned, req.inputs)
except compose.ComposeRefused as e:
raise ReconcileRefused(422, e.message, CHECK_INPUTS) from e
inputs_from = "checked"
elif why is not None:
raise ReconcileRefused(409, CONFIRM_FIRST if why == CHECK_REASON else why, CHECK_INPUTS)
else:
try:
inputs = compose.check_inputs(pinned, dict(cand.get("binding") or {}))
except compose.ComposeRefused as e:
raise ReconcileRefused(409, f"{CHECK_INCOMPLETE}:{e.message}", CHECK_INPUTS) from e
inputs_from = "binding"
# 这次的那条查询:快照、遮罩列、被引用的格
try:
snap = load(req.query)
except ValueError:
snap = None
if not isinstance(snap, dict) or not isinstance(snap.get("sql"), str) or req.query not in sealed.queries:
raise ReconcileRefused(409, SNAPSHOT)
source = str(snap.get("source") or query.get("source") or "")
columns = [str(c) for c in snap.get("columns") or []]
rows = snap.get("rows") if isinstance(snap.get("rows"), list) else []
masks = _Masks(sealed)
hidden = set((await masks.sets(req.query, source, snap)).hidden)
kind = await masks.kind(source)
schema, _ = _frozen(snap.get("schema_artifact"))
fetch = next(n for n in pinned.fetches if n["id"] == cand.get("node_id"))
template = str(((fetch.get("data") or {}).get("config") or {}).get("args", {}).get("sql") or "")
plan = plan_pairs(ours_columns=columns, ours_rows=rows, hidden=hidden,
caliber_columns=_caliber_columns(template, inputs, list(pinned.entry_fields), kind, schema),
metrics=pinned.metrics, fetch=str(fetch["id"]), card=pinned.card_title,
fallback=bool((cand.get("diff") or {}).get("fallback")), chosen=req.pairs,
referenced=_referenced(sealed, req.query))
graph = build(pinned, sql=snap["sql"], tool=f"{QUERY_PREFIX}{source}", plan=plan, inputs=inputs,
fetch=str(fetch["id"]))
async with SessionLocal() as session:
origin = {"source_run": run_id, **await _conversation_of(session, run), "query": req.query, "caliber": key,
"workflow_id": req.workflow_id, "version": int(req.version), "inputs_from": inputs_from,
"binding_via": cand.get("via"), "pairs": [p.describe() for p in plan.pairs],
**({"rows": plan.rows} if plan.rows is not None else {}),
**({"pairs_chosen": True} if req.pairs is not None else {}), "derive": graph.origin}
options = {READ_ONLY_OPTION: True}
if principal.mode != "off" and not principal.at_least("builder"):
options[MASK_RESULTS_OPTION] = True
try:
return await launch_run(session, graph=graph.graph, input_payload=inputs, run_class="exploratory",
actor=principal.username, origin_kind=ORIGIN, origin=origin, options=options,
workflow_name=f"口径对账:{name} v{req.version}")
except LaunchRefused as e:
raise ReconcileRefused(e.status_code, e.message, e.code or REFUSED) from e
def build(pinned: Any, *, sql: str, tool: str, plan: Plan, inputs: dict[str, Any], fetch: str) -> Any:
"""按 Plan 派生对账图(ledger/derive.reconcile_graph)。fetch 是这条查询比的那个口径取数节点(那一版里的 id)。
纯计算,同样的输入同一张图。"""
from app.ledger import derive
prefix = derive.reconcile_prefix(pinned)
tail = tail_nodes(plan, card_id=f"{prefix}{pinned.node_id}", fetch_id=f"{prefix}{fetch}")
return derive.reconcile_graph(pinned, derive.ours_block(sql, tool), tail, inputs=inputs)
__all__ = ["CHECK_INPUTS", "MAX_COLUMNS", "MAX_ROWS", "NEEDS_PAIRS", "ORIGIN", "REFUSED", "Pair", "Plan",
"ReconcileRefused", "Request", "actions_of", "align_sql", "build", "plan_pairs", "reconcile",
"reconciled_runs", "reconciled_view", "tail_nodes"]
+40 -7
View File
@@ -10,11 +10,13 @@
- E1 等价命中:比对过的查询里 proven / composed / related / none / skipped 各几条(skipped 单独列,不混进 none),有候选口径
的查询里各自的比率;证明等价的绑定是在哪一级配上的;没证明的原因(不可比、参数绑不上、配上了但不等价);不等价的差异类型
分布(time / filter / aggregate / join / other / parse,每份口径的差异每类只计一次);逐个数的标签各几个、整题等价几次。
- E2 对账:对账运行(origin=reconcile)发起、完成、失败各几次;相关口径的差异说明里兜底说明的占比。「说明有用 / 看不懂」的
反馈要等对账面板(第 3 波)记成对账运行上的封存后事件,这之前照实写没有数据。
- E2 对账:对账运行(origin=reconcile,第 3 波起由 ledger/reconcile 发起)发起、完成、失败各几次,对过账的答案几个,其中参数
经人核对过的、对照的指标由人选的各几次;相关口径的差异说明里兜底说明的占比。「说明有用 / 看不懂」的反馈要等对账面板
(第 4 波)记成对账运行上的封存后事件,这之前照实写没有数据。
- E3 重复度:问数据运行里执行过的查询按(数据源、normalize_sql 文本)分组,出现在 ≥ REPEAT_RUNS 次运行、来自 ≥ REPEAT_CONVERSATIONS
段对话的算重复的形状,给出这样的形状几个、有重复形状的运行占几成;问题聚类(app/chat/questions:全站唯一的聚类,只取问题
原文、不含 history、排除系统派生的运行)里 ≥ REPEAT_RUNS 个问题的类占几成。认证队列的转化(固化、发布、驳回)在第 3 波。
原文、不含 history、排除系统派生的运行)里 ≥ REPEAT_RUNS 个问题的类占几成。认证队列的转化(第 3 波,ledger/certify):固化成
口径草稿几份(origin.freeze 的工作流)、其中发布了几份、点了「不是口径」几项。
- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数)。每次发布召回了几组、其中数变了的比例要等
召回(第 4 波),这之前照实写没有数据,只给召回运行的次数。
"""
@@ -40,8 +42,7 @@ ASPECT_GROUP = {"time": "time", "time_range": "time", "filter": "filter", "aggre
DIFF_GROUPS = ("time", "filter", "aggregate", "join", "other", "parse")
STATUSES = ("proven", "composed", "related", "none", "skipped")
#: 没有数据时的说法(这一块的功能还没做)
NOT_YET_RECONCILE = "对账面板的反馈在第 3 波接上,目前没有数据"
NOT_YET_CERTIFY = "认证队列在第 3 波接上,目前没有数据"
NOT_YET_RECONCILE = "对账面板的反馈在第 4 波接上,目前没有数据"
NOT_YET_RECALL = "召回在第 4 波接上,目前没有数据"
#: 聚类最多取几个问题(同治理接口的上限)
QUESTION_LIMIT = 500
@@ -154,6 +155,8 @@ async def _e2(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dic
if scope.get("until") is not None:
stmt = stmt.where(Run.created_at < scope["until"])
by_status = {s: int(n) for s, n in (await session.execute(stmt.group_by(Run.status))).all()}
origins = [o for (o,) in (await session.execute(stmt.with_only_columns(Run.origin))).all() if isinstance(o, dict)]
answers = {o.get("source_run") for o in origins if o.get("source_run")}
explained = fallback = 0
for content in contents:
for query in content.get("queries") or []:
@@ -162,7 +165,10 @@ async def _e2(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dic
explained += 1
fallback += bool(cand["diff"].get("fallback"))
return {"launched": sum(by_status.values()), "succeeded": by_status.get("succeeded", 0),
"failed": by_status.get("failed", 0), "explained": explained, "fallback": fallback,
"failed": by_status.get("failed", 0), "answers": len(answers),
"inputs_checked": sum(1 for o in origins if o.get("inputs_from") == "checked"),
"pairs_chosen": sum(1 for o in origins if o.get("pairs_chosen")),
"explained": explained, "fallback": fallback,
"fallback_rate": _ratio(fallback, explained),
"feedback": {"available": False, "note": NOT_YET_RECONCILE}}
@@ -233,10 +239,37 @@ async def _e3(session: Any, *, principal: Any, **scope: Any) -> dict[str, Any]:
"threshold": {"runs": REPEAT_RUNS, "conversations": REPEAT_CONVERSATIONS},
"questions": len(questions), "clusters": len(clusters), "questions_in_repeated_clusters": clustered,
"cluster_share": _ratio(clustered, len(questions)), "embedder": embedder(),
"certify": {"available": False, "note": NOT_YET_CERTIFY},
"certify": await _certify(session, **scope),
}
async def _certify(session: Any, **scope: Any) -> dict[str, Any]:
"""认证队列的转化:固化成口径草稿几份、其中发布了几份、点了「不是口径」几项(按固化、点选的时间筛窗口)。"""
from app.db.models import AuditEvent, Workflow
from app.ledger.certify import DISMISS_ACTION
since, until = scope.get("since"), scope.get("until")
frozen = published = 0
for created, origin, current in (await session.execute(
select(Workflow.created_at, Workflow.origin, Workflow.published_version)
.where(Workflow.origin.is_not(None)))).all():
if not isinstance(origin, dict) or not origin.get("freeze"):
continue
if (since is not None and created is not None and created < since) or \
(until is not None and created is not None and created >= until):
continue
frozen += 1
published += current is not None
stmt = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == DISMISS_ACTION)
if since is not None:
stmt = stmt.where(AuditEvent.at >= since)
if until is not None:
stmt = stmt.where(AuditEvent.at < until)
dismissed = int(await session.scalar(stmt) or 0)
return {"available": True, "frozen": frozen, "published": published, "dismissed": dismissed,
"publish_rate": _ratio(published, frozen)}
async def _e4(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dict[str, Any]:
from app.db.models import Run
from app.engine.origin import counted_runs
+6 -4
View File
@@ -28,10 +28,12 @@ from app.db.models import Run, RunEvent
#: 工件的种类和内容格式
KIND = "caliber_match"
FORMAT = "agentlab.caliber_match/1"
#: 比对算法的版本:绑定、证明、标签的规则改了就加一,界面和统计据此分得清是哪一版算法比的
ALGO = "1"
#: 触发方式(见模块说明)
TRIGGERS = ("sealed", "backfill", "on_demand")
#: 比对算法的版本:绑定、证明、标签的规则改了就加一,界面和统计据此分得清是哪一版算法比的。
#: 2(第 3 波):认出助手选用的口径(查询 composed、标签 how=composed),指标的标签记它取自哪几条查询(queries,认证队列
#: 认「被报告引用的查询」用)
ALGO = "2"
#: 触发方式(见模块说明)。certified(第 3 波):从问数据固化出的口径发布之后,对来源运行补比的(ledger/certify)
TRIGGERS = ("sealed", "backfill", "on_demand", "certified")
@dataclass(frozen=True)
+20 -4
View File
@@ -4,7 +4,9 @@ ledger 键、报告导出的片段面板,三处读同一份比对工件、按
遮罩按读的那一刻重滤(同证据面板,api/evidence._Masks):绑定的值是这次运行查到过的遮罩值时不给值(也就不能拿它代填出具),
差异条目里的 SQL 片段走 _Shown 同一套换法。工作流名按现在的取(工件里不存名字)。
界面层不放逻辑:标签、整题判断、能做的动作(这一波还没有出具和对账,actions 是空的)都由这里给。
界面层不放逻辑:标签、整题判断、能做的动作都由这里给。actions(第 3 波):整题等价时按那份口径正式出具(能不能、能不能
自动、不能的原因,ledger/issue.plan_issue,按看的这个人的权限判);有相关口径的查询看差多少(口径对账,ledger/reconcile)。
issued 是这次运行按口径出具过的正式运行,reconciled 是对过的账(各自的运行、状态)。
"""
from __future__ import annotations
@@ -73,6 +75,8 @@ def _query_view(query: dict[str, Any], hider: _Hide, names: dict[str, str]) -> d
for cand in query.get("candidates") or []:
item = ledger_labels.related_view(cand, hide=hider.hide, shown=hider.shown, names=names)
item["status"] = cand.get("status")
if cand.get("how"):
item["how"] = cand["how"]
if cand.get("columns") is not None:
item["columns"] = cand["columns"]
cands.append(item)
@@ -94,7 +98,8 @@ def _stamp(recorded: store.Recorded) -> dict[str, Any]:
if isinstance(recorded.ts, (int, float)) else None
return {"artifact": recorded.artifact, "algo": recorded.algo, "trigger": recorded.trigger, "retro": recorded.retro,
"matched_at": at, "post_seal": True, "note": ledger_labels.POST_SEAL_NOTE,
**({"retro_note": ledger_labels.RETRO_NOTE} if recorded.retro else {})}
**({"retro_note": ledger_labels.CERTIFIED_NOTE if recorded.trigger == "certified"
else ledger_labels.RETRO_NOTE} if recorded.retro else {})}
async def run_view(run_id: str) -> dict[str, Any]:
@@ -120,10 +125,20 @@ async def run_view(run_id: str) -> dict[str, Any]:
return {**base, "state": "pending"}
if recorded.content is None:
return {**base, "state": "unreadable", "reason": recorded.problem, **_stamp(recorded)}
from app.api.deps import current_principal
from app.ledger import issue as ledger_issue
from app.ledger import reconcile as ledger_reconcile
content = recorded.content
sealed = await _sealed(run_id)
hider = await _hider(sealed)
names = await _names(_workflow_ids(content))
principal = current_principal()
plans = await ledger_issue.plans_for(run_id, content, principal=principal, hide=hider.hide,
artifact=recorded.artifact, retro=recorded.retro)
async with SessionLocal() as session:
issued = [ledger_issue.issued_view(r) for r in await ledger_issue.issued_runs(session, run_id)]
reconciled = [ledger_reconcile.reconciled_view(r) for r in await ledger_reconcile.reconciled_runs(session, run_id)]
return {
**base, "state": "matched", **_stamp(recorded),
"calibers": [{**{k: c.get(k) for k in ("caliber", "workflow_id", "version", "level", "graph_hash")},
@@ -134,8 +149,9 @@ async def run_view(run_id: str) -> dict[str, Any]:
"reports": _labels_view(content, hider, names),
"primary": content.get("primary"),
"whole": _whole_view(content.get("whole") or {}, hider, names),
# 能做的动作(按口径正式出具、对账)在第 3 波;这一波照实给空的
"actions": [],
"actions": [*(p.action() for p in plans), *ledger_reconcile.actions_of(content, names, hider.hide)],
"issued": issued,
"reconciled": reconciled,
}
+91 -6
View File
@@ -9,6 +9,11 @@
读别的(整形、沙箱、模型写的、按别的行号取)的卡照实说不能复用,请按表重新对。
点名写法:source 写工作流名(或 id),name 写指标名(或指标 id);只写 source 列那个工作流的全部指标。
**问数据里**(compose=True,next-round 02 做法 4):同一套口径,另给每张卡的入口字段(类型、必填、可选值、格式、缺省值,
同 apps/describe 的写法)和「能不能组合」,能组合的写出 use_caliber 的写法;仍然不给 SQL(SQL 里可能写着遮罩值,组合
时取数节点由服务端照抄,见 ledger/compose.py)。能不能组合由 ledger/derive.load_pinned 判(和对账、评测同一个认法),
读好的版本快照交给 GraphApplier(流里不碰库)。蓝图助手不走这一支,行为不变。
"""
from __future__ import annotations
@@ -32,6 +37,13 @@ DETAIL_REUSE_PROMPT = (" 复用写法(「看哪些数」这一项的 reuse
" 这张卡读:{inputs}。实例化时那个库的取数节点就叫这个名字、交出这几列,复用才算得出来")
DETAIL_INPUT_PROMPT = "{node}({tool})的列 {columns}"
DETAIL_NOT_REUSABLE_PROMPT = " 不能复用:{reason}。请按表重新对"
#: 问数据里(compose)每张卡写一次:入口字段、能不能组合
DETAIL_FIELDS_PROMPT = " 口径「{workflow}」v{version} 的入口字段:{fields}"
DETAIL_FIELD_PROMPT = "{name}({bits}){desc}"
DETAIL_NO_FIELDS_PROMPT = " 口径「{workflow}」v{version} 的入口没有字段"
DETAIL_COMPOSE_PROMPT = (" 能组合:选用写法 {op}(id 自己起,它就是口径卡的节点 id;inputs 按上面的入口字段填,没写的取缺省值;"
"metrics 写要用的指标 id)")
DETAIL_NOT_COMPOSABLE_PROMPT = " 不能组合:{reason}。这份口径只能作为参考,查库交给 agent"
BRIEF_PROMPT = "· {name}({workflow} v{version}):字数超过上限,只列名字,要看指标式时点名再 look"
MISSING_PROMPT = "没找到口径:{items}(只能查看已发布且未退役的版本里的口径卡;别拿名字相近的顶替)"
INDEX_LINE_PROMPT = "{workflow}|{card}|{name}({metric})|{unit}"
@@ -145,17 +157,36 @@ def views_of(workflow_id: str, workflow_name: str, version: Any) -> list[Caliber
return out
def field_text(f: dict[str, Any]) -> str:
"""入口字段写给模型的一段:名字(类型,必填,可选值,格式,缺省值):说明。同 apps/describe 的写法,多写缺省值。"""
bits = [str(f.get("type") or "string")]
if f.get("required") and f.get("default") in (None, ""):
bits.append("必填")
if isinstance(f.get("enum"), list):
bits.append("可选值 " + " / ".join(str(v) for v in f["enum"][:10]))
if f.get("format"):
bits.append(f"格式 {f['format']}")
if f.get("default") not in (None, ""):
bits.append(f"缺省 {f['default']}")
desc = f":{f['description']}" if f.get("description") else ""
return DETAIL_FIELD_PROMPT.format(name=f.get("name"), bits=",".join(bits), desc=desc)
class CaliberKind:
kind = "caliber"
label = "口径"
def __init__(self, items: list[CaliberView], *, picker: Picker | None = None) -> None:
def __init__(self, items: list[CaliberView], *, picker: Picker | None = None,
options: list[Any] | None = None) -> None:
self.items = items
self.picker = picker
#: 问数据里(compose):每张卡能不能组合(ledger/compose.Option),没开组合时为 None
self.options = options
@classmethod
async def load(cls, session: Any, *, picker: Picker | None = None) -> CaliberKind:
"""读每个工作流最新的已发布且未退役的版本里的口径卡。用调用方的会话,开流之前读好。"""
async def load(cls, session: Any, *, picker: Picker | None = None, compose: bool = False) -> CaliberKind:
"""读每个工作流最新的已发布且未退役的版本里的口径卡。用调用方的会话,开流之前读好。compose:问数据里,另把每张卡
的那一版读好、判能不能组合(见模块说明)。"""
from sqlalchemy import select
from app.apps import versions as app_versions
@@ -166,7 +197,13 @@ class CaliberKind:
latest = await app_versions.latest_published(session, wf.id)
if latest is not None:
items += views_of(wf.id, wf.name, latest)
return cls(items, picker=picker)
return cls(items, picker=picker, options=await _options(session, items) if compose else None)
def option_of(self, item: CaliberView) -> Any | None:
if self.options is None:
return None
return next((o for o in self.options if (o.workflow_id, o.version, o.node_id) ==
(item.workflow_id, item.version, item.node_id)), None)
def _workflows(self) -> dict[str, list[CaliberView]]:
out: dict[str, list[CaliberView]] = {}
@@ -243,14 +280,18 @@ class CaliberKind:
missing: list[LookTarget] = ()) -> tuple[str, list[dict[str, Any]]]:
lines: list[str] = []
used = 0
#: 问数据里每张卡的入口字段、组合写法只写一次(同一张卡的几个指标挨着列)
seen: set[tuple[str, int, str]] = set()
for item, _how in objects:
block = [DETAIL_HEAD_PROMPT.format(name=item.name, metric=item.metric_id,
unit=f",单位 {item.unit}" if item.unit else "",
workflow=item.workflow_name, version=item.version, card=item.card,
caliber=item.caliber),
DETAIL_EXPR_PROMPT.format(expression=item.expression)]
reuse = item.reuse()
if reuse is not None:
option = self.option_of(item)
if option is not None:
block += _compose_lines(item, option, seen)
elif (reuse := item.reuse()) is not None:
block.append(DETAIL_REUSE_PROMPT.format(
reuse=json.dumps({k: v for k, v in reuse.items() if k != "inputs"}, ensure_ascii=False),
inputs=";".join(DETAIL_INPUT_PROMPT.format(node=n, tool=t, columns="、".join(cols))
@@ -281,6 +322,50 @@ class CaliberKind:
return None, "只能复用已发布且未退役的版本里的口径卡(look 给出的那一版)"
async def _options(session: Any, items: list[CaliberView]) -> list[Any]:
"""每张卡(工作流、版本、卡)读一次那一版、判能不能组合:ledger/derive.load_pinned(和对账、评测同一个认法),再看
取数 SQL 模板里的参数认不认得出写在哪(ledger/compose 校验参数要用)。"""
from app.ledger.compose import ComposeRefused, Option, _slot_contexts
from app.ledger.derive import NotDerivable, load_pinned
out: list[Any] = []
for item in items:
if any((o.workflow_id, o.version, o.node_id) == (item.workflow_id, item.version, item.node_id) for o in out):
continue
base = {"workflow_id": item.workflow_id, "workflow_name": item.workflow_name, "version": item.version,
"level": item.level, "node_id": item.node_id, "card": item.card}
try:
pinned = await load_pinned(session, item.workflow_id, item.version, item.node_id)
_slot_contexts(pinned)
except NotDerivable as e:
out.append(Option(**base, reason=e.message))
continue
except ComposeRefused as e:
out.append(Option(**base, reason=e.message))
continue
out.append(Option(**base, pinned=pinned))
return out
def _compose_lines(item: CaliberView, option: Any, seen: set[tuple[str, int, str]]) -> list[str]:
"""问数据里一张卡的入口字段和组合写法(同一张卡只写一次)。"""
key = (item.workflow_id, item.version, item.node_id)
if key in seen:
return []
seen.add(key)
if not option.composable:
return [DETAIL_NOT_COMPOSABLE_PROMPT.format(reason=option.reason)]
fields = [f for f in option.pinned.entry_fields if f.get("name")]
lines = [DETAIL_FIELDS_PROMPT.format(workflow=item.workflow_name, version=item.version,
fields=";".join(field_text(f) for f in fields))
if fields else DETAIL_NO_FIELDS_PROMPT.format(workflow=item.workflow_name, version=item.version)]
example = {"op": "use_caliber", "id": "c1", "workflow_id": item.workflow_id, "version": item.version,
"card": item.node_id, "metrics": [m["id"] for m in option.pinned.metrics][:6],
"inputs": {str(f["name"]): "…" for f in fields}}
lines.append(DETAIL_COMPOSE_PROMPT.format(op=json.dumps(example, ensure_ascii=False)))
return lines
async def verify_reuse(session: Any, ref: dict[str, Any]) -> dict[str, Any]:
"""蓝图存的时候按库里那一版核对复用的口径卡:已发布且未退役、是源头的口径卡、有这个指标、能复用。交回补上 inputs
的 reuse;不行抛 BpRefused(bp.data_unmapped)。用户在卡片上选的、助手提的都过这一道。"""
+247
View File
@@ -0,0 +1,247 @@
"""口径卡钉住别处口径卡时的节点改名映射(caliber_from.nodes,next-round 02 做法 4 ③)。
那一版的口径卡读 cell(nodes.fetch, 0, 'gmv');拷进这张图时取数节点改名成 c1_fetch(组合式问数、对账、召回同一张图里放
两份口径会撞 id),caliber_from 带 nodes={fetch: c1_fetch}。要一起认这个映射的几处:
- 运行时(metrics._pinned_caliber):式子按语法树把 nodes.fetch 的根换成 c1_fetch 再算,指标集的 source 照记映射;
- 画布校验(schema._caliber_from_issues):映射的形状、改名后的节点在不在图里、在不在口径卡上游;
- 升版检查(governance.unresolved_caliber_upgrades):照常挡、照常记事件,事件带上映射;
- 证据(指标步骤的 source、输入链到改名后的那条查询)、推演(按改名后的式子原样重算);
- 运行对比(compare):同一份口径换了节点名不算口径变了。
没写 nodes 的老图行为一字不差:式子原文、指标集、升版事件都和以前一样(单独测)。数据是虚构店铺库。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.core import artifact_store
from app.db.base import SessionLocal
from app.db.models import RunEvent
from app.engine.nodes.metrics import rename_expression, rename_problem, source_expression
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, validate_graph
from app.main import app
from tests import ledger_samples as S
RENDERED = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'")
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
from app.ledger import match as ledger_match
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
yield
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def composed(wf: str, *, rename: dict | None = None, fetch_id: str = "c1_fetch", card_id: str = "c1",
version: int = 1, **card) -> dict:
ref = {"workflow_id": wf, "workflow_version": version, "node_id": "card"}
if rename is not None:
ref["nodes"] = rename
return S.chain(S.node("start", "input"), S.node(fetch_id, "tool", tool=S.tool(), args={"sql": RENDERED}),
S.node(card_id, "metrics", caliber_from=ref, **card),
S.node("out", "output", fields=[{"name": "r", "value": f"{{{{ nodes.{card_id}.text }}}}"}]))
async def finished(run_id: str, node_id: str) -> RunEvent:
async with SessionLocal() as session:
return (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "node.finished", RunEvent.node_id == node_id))).scalars().one()
async def run(graph: dict):
return await S.wait((await run_manager.start(graph=graph, input_payload={})).id)
# --------------------------------------------------------------------------
# 改写式子
# --------------------------------------------------------------------------
def test_renaming_rewrites_only_the_roots_of_node_chains():
rename = {"fetch": "c1_fetch"}
assert rename_expression("cell(nodes.fetch, 0, 'gmv')", rename) == "cell(nodes.c1_fetch, 0, 'gmv')"
assert rename_expression("nodes.fetch.rows[0]['gmv'] / 2", rename) == "nodes.c1_fetch.rows[0]['gmv'] / 2"
assert rename_expression("nodes['fetch'].row_count", rename) == "nodes['c1_fetch'].row_count"
# 字符串里的字、别的根、别的节点不动
assert rename_expression("cell(nodes.other, 0, 'nodes.fetch') + vars.fetch", rename) == \
"cell(nodes.other, 0, 'nodes.fetch') + vars.fetch"
# 一处都没换到的原样交回(不按 unparse 改写空白)
assert rename_expression("cell(nodes.other,0,'gmv')", rename) == "cell(nodes.other,0,'gmv')"
assert rename_expression("cell(nodes.fetch,", rename) == "cell(nodes.fetch,"
# 反着换回那一版里的写法:对比口径按它比
assert source_expression("cell(nodes.c1_fetch, 0, 'gmv')", {"nodes": rename}) == "cell(nodes.fetch, 0, 'gmv')"
assert source_expression("cell(nodes.c1_fetch, 0, 'gmv')", {"workflow_id": "w"}) == "cell(nodes.c1_fetch, 0, 'gmv')"
def test_mapping_shape_problems_are_named():
assert rename_problem({"fetch": "c1_fetch"}) is None
assert rename_problem([]) and rename_problem({})
assert "节点 id" in rename_problem({"fetch": "c1 fetch"})
assert "两个节点" in rename_problem({"a": "x", "b": "x"})
# --------------------------------------------------------------------------
# 运行时、证据、推演
# --------------------------------------------------------------------------
async def test_a_renamed_fetch_feeds_the_pinned_card(client):
wf = await S.publish()
row = await run(composed(wf, rename={"fetch": "c1_fetch"}))
assert row.status == "succeeded", row.error
event = await finished(row.id, "c1")
card = artifact_store.load(event.data["artifact"])
assert (card["caliber"], card["caliber_version"]) == ("周销售", "v1")
[metric] = card["metrics"]
assert metric["value"] == 170.5 and metric["rendered"] == "170.50元"
# 卡上记的是这张图里实际算的式子;来源照记映射
assert metric["expression"] == "cell(nodes.c1_fetch, 0, 'gmv')"
assert card["source"] == {"workflow_id": wf, "workflow_version": 1, "node_id": "card",
"nodes": {"fetch": "c1_fetch"}}
[entry] = event.data["evidence"]
assert entry["source"]["nodes"] == {"fetch": "c1_fetch"}
[item] = metric["inputs"]
assert item["via"] == "tool_cell" and item["node_id"] == "c1_fetch" and item["artifact"]
# 证据:指标步骤的来源带映射,输入链到改名后的那条查询的那一格
resp = await client.get(f"/api/runs/{row.id}/evidence/metrics/c1/gmv")
assert resp.status_code == 200, resp.text
body = resp.json()
[step] = [s for s in body["chain"] if s["step"] == "metric"]
assert step["source"]["nodes"] == {"fetch": "c1_fetch"} and step["source"]["workflow_name"] == "周销售"
[inp] = [s for s in body["chain"] if s["step"] == "input"]
assert inp["node_id"] == "c1_fetch" and inp["artifact"] == item["artifact"]
# 推演:按改名后的式子原样重算,改一个输入照常算
resp = await client.post(f"/api/runs/{row.id}/evidence/whatif",
json={"card": "c1", "overrides": {"inputs": {"cell(nodes.c1_fetch, 0, 'gmv')": 200}}})
assert resp.status_code == 200, resp.text
[m] = resp.json()["metrics"]
assert m["old"] == 170.5 and m["new"] == 200 and m["rendered_new"] == "200.00元"
async def test_without_the_mapping_the_renamed_fetch_is_not_found():
"""没写映射:式子照那一版读 nodes.fetch,这张图里没有它——缺输入,照实失败,不会算错。"""
wf = await S.publish()
row = await run(composed(wf))
assert row.status == "failed" and "口径卡计算失败" in (row.error or "")
async def test_old_graphs_without_the_mapping_are_unchanged():
"""没写 nodes:式子原文一个字不改(不经 unparse),来源只有那三项。"""
graph = S.caliber_graph(expression="cell(nodes.fetch,0,'gmv')")
wf = await S.publish(graph=graph)
pinned = {"workflow_id": wf, "workflow_version": 1, "node_id": "card"}
row = await run(S.chain(S.node("start", "input"), S.node("fetch", "tool", tool=S.tool(), args={"sql": RENDERED}),
S.node("mine", "metrics", caliber_from=pinned),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.mine.text }}"}])))
assert row.status == "succeeded", row.error
[entry] = (await finished(row.id, "mine")).data["evidence"]
card = artifact_store.load(entry["artifact"])
assert card["source"] == pinned and entry["source"] == pinned
assert [m["expression"] for m in card["metrics"]] == ["cell(nodes.fetch,0,'gmv')"]
assert set(card) == {"kind", "caliber", "caliber_version", "metrics", "source"}
async def test_a_malformed_mapping_is_refused_before_running():
wf = await S.publish()
with pytest.raises(ValueError, match="节点改名映射有误"):
await run_manager.start(graph=composed(wf, rename={"fetch": "c1 fetch"}), input_payload={})
# --------------------------------------------------------------------------
# 画布校验、升版检查
# --------------------------------------------------------------------------
def _codes(graph: dict) -> list[tuple[str | None, str]]:
return [(i.field, i.message) for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.level == "error"]
def test_validation_checks_the_mapping():
good = composed("wf1", rename={"fetch": "c1_fetch"})
assert not [f for f, _ in _codes(good) if (f or "").startswith("caliber_from")]
shape = _codes(composed("wf1", rename={"fetch": ""}))
assert any(f == "caliber_from.nodes" and "映射有误" in m for f, m in shape)
missing = _codes(composed("wf1", rename={"fetch": "nowhere"}))
assert any(f == "caliber_from.nodes" and "没有节点「nowhere」" in m for f, m in missing)
# 改名后的节点在口径卡下游:算卡的时候它还没执行
late = composed("wf1", rename={"fetch": "c1_fetch"})
late["edges"] = [{"source": "start", "target": "c1"}, {"source": "c1", "target": "c1_fetch"},
{"source": "c1_fetch", "target": "out"}]
assert any(f == "caliber_from.nodes" and "不在口径卡的上游" in m for f, m in _codes(late))
async def test_upgrade_checks_treat_renamed_cards_like_any_pinned_card():
from app.engine.governance import unresolved_caliber_upgrades
wf = await S.publish()
await S.publish(version=2, workflow_id=wf)
async with SessionLocal() as session:
errors, _ = await unresolved_caliber_upgrades(
session, GraphSpec.model_validate(composed(wf, rename={"fetch": "c1_fetch"})))
assert errors and "上游已有 v2" in errors[0]
_, [event] = await unresolved_caliber_upgrades(session, GraphSpec.model_validate(
composed(wf, rename={"fetch": "c1_fetch"}, upgrade_policy="dual")))
assert event["caliber_nodes"] == {"fetch": "c1_fetch"} and event["pinned"] == 1 and event["latest"] == 2
# 没改名的卡:事件和以前一样,没有这个键
plain = composed(wf, fetch_id="fetch", card_id="mine", upgrade_policy="dual")
_, [old] = await unresolved_caliber_upgrades(session, GraphSpec.model_validate(plain))
assert "caliber_nodes" not in old
# --------------------------------------------------------------------------
# 运行对比
# --------------------------------------------------------------------------
def test_compare_sees_the_same_caliber_under_different_node_names():
from app.engine.compare import _caliber_reasons
def side(prefix: str) -> tuple[dict, dict]:
metric = {"id": "gmv", "expression": f"cell(nodes.{prefix}fetch, 0, 'gmv')", "decimals": 2,
"format": "thousands", "unit": "元"}
card = {"caliber": "周销售", "caliber_version": "v1", "metrics": [metric],
"source": {"workflow_id": "w", "workflow_version": 1, "node_id": "card",
**({"nodes": {"fetch": f"{prefix}fetch"}} if prefix else {})}}
return metric, card
(bm, bc), (tm, tc) = side(""), side("c1_")
assert _caliber_reasons(bm, tm, bc, tc) == []
# 映射反着换回来之后式子还是不同的,照旧说表达式改了
tm2 = {**tm, "expression": "cell(nodes.c1_fetch, 0, 'gmv') * 2"}
assert any("表达式由" in r for r in _caliber_reasons(bm, tm2, bc, tc))
async def test_runs_of_the_same_composed_graph_compare_unchanged():
from app.db.models import Workflow
wf = await S.publish()
graph = composed(wf, rename={"fetch": "c1_fetch"})
async with SessionLocal() as session:
saved = Workflow(name="组合的答案", graph=graph)
session.add(saved)
await session.commit()
saved_id = saved.id
a, b = [await S.wait((await run_manager.start(graph=graph, workflow_id=saved_id, input_payload={})).id)
for _ in range(2)]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.get(f"/api/runs/{b.id}/compare", params={"base": a.id})
assert resp.status_code == 200, resp.text
[metric] = resp.json()["metrics"]
assert metric["classification"] == "unchanged", metric
+2 -1
View File
@@ -61,7 +61,8 @@ async def test_a_matched_run_gives_labels_and_the_whole_judgement(client, monkey
body = await ledger_of(client, row.id)
assert body["state"] == "matched" and body["note"] == "封存后比对,不在封存范围内" and body["post_seal"] is True
assert body["trigger"] == "sealed" and body["retro"] is False and "retro_note" not in body
assert body["actions"] == [] and body["primary"] == "write"
# 第 3 波起整题等价时有「按口径出具」这一项(详细的在 tests/test_ledger_issue.py)
assert [a["kind"] for a in body["actions"]] == ["issue"] and body["primary"] == "write"
[caliber] = body["calibers"]
assert caliber["workflow_name"] == "周销售" and caliber["level_text"] == "受管" and caliber["caliber"] == f"{wf}@1"
[label] = body["reports"]["write"]["labels"].values()
+224
View File
@@ -0,0 +1,224 @@
"""沉淀:可沉淀的问题(认证队列)和「固化成口径草稿」(next-round 02 做法 8,app/ledger/certify.py)。
真的跑三次问数据(两段对话、同一个 SQL 形状、日期不同,虚构店铺库),等后台比对做完:
- 队列:确定性重复进队(3 次运行、2 段对话),变了的字面量是参数候选、不变的是常量,形状里不带取值;同一个问题问了 3 次
进问题聚类;导出过两次的、做成过应用的答案进「被反复用的答案」;点「不是口径」写审计后不再进队;
- 固化:报告引用的查询变成固定 SQL 的取数节点,跨轮变化的日期成为入口参数(缺省值取代表运行的),常量 'paid' 照写;单行
结果的数值列成口径卡指标;出口契约 report_from + metrics_from;过 validate_graph 和受管级别的发布门禁;origin.sources 记
三个来源问题;
- 按受管级别发布之后:来源运行在后台按它补比(trigger=certified),查询变成等价已证明、出了队;再问一次同样的形状,标
「等价已证明」;
- 统计 E3:认证队列的转化(固化、发布、不是口径)有数了。
数据是虚构店铺库。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Conversation, ConversationTurn, Run, Workflow
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, validate_graph
from app.ledger import certify
from app.ledger import match as ledger_match
from app.ledger import store
from app.main import app
from tests import ledger_samples as S
QUESTION = "九月第一周卖了多少"
WEEKS = [("2026-09-01", "2026-09-08"), ("2026-09-02", "2026-09-09"), ("2026-09-03", "2026-09-10")]
def weekly(start: str, end: str) -> str:
return (f"SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '{start}' "
f"AND paid_at < '{end}'")
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
yield
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def conversation() -> str:
async with SessionLocal() as session:
conv = Conversation(title="问销售", kind="chat")
session.add(conv)
await session.commit()
return conv.id
async def ask(monkeypatch, conversation_id: str, sql: str, question: str = QUESTION) -> Run:
"""问数据的一轮:建轮次、跑一次问数据运行(来源记着对话和轮次)、挂回轮次、等比对做完。"""
async with SessionLocal() as session:
turn = ConversationTurn(conversation_id=conversation_id, question=question)
session.add(turn)
await session.commit()
turn_id = turn.id
S.script(monkeypatch, "这一周销售额 [[v:Q1.r0.gmv]]。")
run = await run_manager.start(graph=S.explore_graph(sql), input_payload={"question": question}, origin_kind="chat",
origin={"conversation_id": conversation_id, "turn_id": turn_id})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
(await session.get(ConversationTurn, turn_id)).run_id = row.id
await session.commit()
await ledger_match.drain()
return row
async def three_asks(monkeypatch) -> list[Run]:
a, b = await conversation(), await conversation()
return [await ask(monkeypatch, conv, weekly(*week)) for conv, week in zip((a, a, b), WEEKS)]
def shape_items(body: dict) -> list[dict]:
return [i for i in body["items"] if i["kind"] == "shape" and i["source"] == S.CURRENT["source"]]
# --------------------------------------------------------------------------
# 队列
# --------------------------------------------------------------------------
def test_varying_literals_are_parameters_and_the_template_puts_them_back():
shape = certify.Shape("shop", "SELECT SUM(amount) AS gmv FROM orders WHERE status = ? AND paid_at >= ? AND x = ?",
("'paid'", "'2026-09-01'", "3"))
assert certify.varying([shape.literals, ("'paid'", "'2026-09-02'", "4")]) == [1, 2]
template, fields = certify.template_of(shape, [1, 2], "sqlite", start=3)
assert template == ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '{{ input.p3 }}' "
"AND x = {{ input.p4 }}")
assert fields == [{"name": "p3", "type": "string", "required": True, "default": "2026-09-01"},
{"name": "p4", "type": "number", "required": True, "default": 3}]
async def test_repeated_shapes_questions_and_reused_answers_queue_up(client, monkeypatch):
runs = await three_asks(monkeypatch)
body = (await client.get("/api/ledger/certify-queue")).json()
[item] = shape_items(body)
assert item["runs"] == 3 and item["conversations"] == 2 and item["question"] == QUESTION
assert item["params"] == [{"index": 1, "distinct": 3}, {"index": 2, "distinct": 3}] and item["constants"] == 1
assert "2026-09" not in item["shape"] and "'paid'" not in item["shape"] and "?" in item["shape"]
assert {t["run_id"] for t in item["turns"]} == {r.id for r in runs}
assert all(t["turn_id"] and t["conversation_id"] for t in item["turns"])
assert item["representative"]["run_id"] in {r.id for r in runs} and len(item["peers"]) == 2
assert item["related"] == [] and item["frozen_into"] == []
clusters = [i for i in body["items"] if i["kind"] == "cluster" and i["question"] == QUESTION]
assert clusters and clusters[0]["count"] >= 3
# 被反复用的答案:导出过两次的、做成过应用的
from app.db.models_blueprints import Blueprint
async with SessionLocal() as session:
from app.auth.audit import record
for _ in range(2):
await record(session, "report.exported", "run", runs[0].id, data={"format": "html"})
session.add(Blueprint(name="周销售应用", source={"kind": "run", "id": runs[1].id}, spec={}))
await session.commit()
body = (await client.get("/api/ledger/certify-queue")).json()
reused = {i["representative"]["run_id"]: i for i in body["items"] if i["kind"] == "reused"}
assert reused[runs[0].id]["uses"]["exports"] == 2 and reused[runs[1].id]["uses"]["app"] is True
assert runs[2].id not in reused
# 「不是口径」:记审计,之后不再进队
res = await client.post("/api/ledger/certify-queue/dismiss", json={"item_id": item["id"], "kind": "shape"})
assert res.status_code == 200
assert shape_items((await client.get("/api/ledger/certify-queue")).json()) == []
stats = (await client.get("/api/ledger/stats")).json()["e3"]["certify"]
assert stats["available"] is True and stats["dismissed"] >= 1
async def test_two_runs_or_one_conversation_are_not_enough(client, monkeypatch):
conv = await conversation()
for week in WEEKS:
await ask(monkeypatch, conv, weekly(*week))
assert shape_items((await client.get("/api/ledger/certify-queue")).json()) == [], "只来自一段对话"
# --------------------------------------------------------------------------
# 固化、发布、再问
# --------------------------------------------------------------------------
async def test_freezing_makes_a_fixed_sql_draft_that_passes_the_governed_gate(client, monkeypatch):
from app.engine.governance import publish_issues
runs = await three_asks(monkeypatch)
[item] = shape_items((await client.get("/api/ledger/certify-queue")).json())
rep = item["representative"]["run_id"]
res = await client.post("/api/copilot/from-run", json={"run_id": rep, "freeze": True, "peers": item["peers"]})
assert res.status_code == 200, res.text
made = res.json()
assert made["frozen"] is True and made["params"] == ["p1", "p2"] and made["metrics"] == ["m1"]
async with SessionLocal() as session:
wf = await session.get(Workflow, made["workflow_id"])
assert wf.status == "draft" and "certified" in wf.tags
nodes = {n["id"]: n for n in wf.graph["nodes"]}
assert set(nodes) == {"start", "fetch", "card", "write", "out"}
rep_run = next(r for r in runs if r.id == rep)
start, end = WEEKS[[r.id for r in runs].index(rep)]
assert nodes["start"]["data"]["config"]["fields"] == [
{"name": "p1", "type": "string", "required": True, "default": start},
{"name": "p2", "type": "string", "required": True, "default": end}]
sql = nodes["fetch"]["data"]["config"]["args"]["sql"]
assert "'{{ input.p1 }}'" in sql and "'{{ input.p2 }}'" in sql and "'paid'" in sql
assert nodes["fetch"]["data"]["config"]["tool"] == S.tool()
assert nodes["card"]["data"]["config"]["metrics"] == [
{"id": "m1", "name": "gmv", "expression": "cell(nodes.fetch, 0, 'gmv')"}]
assert nodes["out"]["data"]["config"]["contract"] == {"report_from": "write", "metrics_from": "card",
"required": ["m1"]}
assert {k: nodes["write"]["data"]["config"].get(k) for k in ("numbers", "on_violation", "claims")} == {
"numbers": "strict", "on_violation": "fail", "claims": "require_citation"}
assert nodes["write"]["data"]["config"]["instructions"] == f"回答:{QUESTION}"
spec = GraphSpec.model_validate(wf.graph)
assert not [i for i in validate_graph(spec).issues if i.level == "error"]
assert not [i for i in publish_issues(spec, level="governed") if i.level == "error"], \
[i.message for i in publish_issues(spec, level="governed") if i.level == "error"]
assert [s["run_id"] for s in wf.origin["sources"]][0] == rep and len(wf.origin["sources"]) == 3
assert all(s["question"] == QUESTION for s in wf.origin["sources"])
assert wf.origin["run_id"] == rep_run.id and wf.origin["freeze"] is True
# 队列上标着已经固化过
[again] = shape_items((await client.get("/api/ledger/certify-queue")).json())
assert {w["workflow_id"] for w in again["frozen_into"]} == {wf.id}
# 按受管级别发布:来源运行在后台补比,变成等价已证明、出了队
res = await client.post(f"/api/workflows/{wf.id}/publish", json={"level": "governed"})
assert res.status_code == 200, res.text
await ledger_match.drain()
for run in runs:
recorded = await store.latest(run.id)
assert recorded.trigger == "certified" and recorded.retro is True
[query] = recorded.content["queries"]
assert query["status"] == "proven" and query["candidates"][0]["caliber"] == f"{wf.id}@1"
body = (await client.get(f"/api/runs/{runs[0].id}/ledger")).json()
assert body["retro_note"] == "按之后发布的口径回看"
assert shape_items((await client.get("/api/ledger/certify-queue")).json()) == []
# 再问一次同样的形状:标等价已证明
later = await ask(monkeypatch, await conversation(), weekly("2026-09-05", "2026-09-12"))
[label] = (await store.latest(later.id)).content["reports"]["write"]["labels"].values()
assert label["kind"] == "caliber" and label["how"] == "independent" and label["caliber"] == f"{wf.id}@1"
assert label["binding"] == {"p1": "2026-09-05", "p2": "2026-09-12"}
stats = (await client.get("/api/ledger/stats")).json()["e3"]["certify"]
assert stats["frozen"] >= 1 and stats["published"] >= 1
async def test_freezing_needs_a_matched_successful_run(client, monkeypatch):
res = await client.post("/api/copilot/from-run", json={"run_id": "nope", "freeze": True})
assert res.status_code == 404
row = await S.explore(monkeypatch, S.ELSEWHERE, text="一共几家店,没有引用。")
res = await client.post("/api/copilot/from-run", json={"run_id": row.id, "freeze": True})
assert res.status_code == 409 and res.json()["detail"] == certify.NO_QUERIES
+294
View File
@@ -0,0 +1,294 @@
"""组合式问数:use_caliber 选用已发布口径,服务端按那一版确定性内联实例化(next-round 02 做法 4,ledger/compose.py)。
- 实例化是确定的:同一版、同一个操作得到一模一样的节点;取数节点照抄、id 加前缀、模板按参数渲染成字面 SQL,口径卡 id
就是操作 id、caliber_from 带改名映射;取数首尾相接再接口径卡,入口连到第一个取数节点;
- 参数按口径入口的字段校验(未知字段、缺必填、类型、引号里的值带引号、裸写的值不是数),不合规拒收、原因交回模型;
只能选这一轮查看到的那一版、能组合的卡、卡上的指标,id 不能撞;
- 问数据的查看对象多了口径:给入口字段和「能不能组合」,不给 SQL;「什么时候该看」换成问数据那一份;使用者不给;
- 走一遍问数据的生成(剧本模型)再真跑出来:比对器把这些数标成 composed「助手选用」,整题 how=composed。
数据是虚构店铺库。
"""
from __future__ import annotations
import json
import pytest
from httpx import ASGITransport, AsyncClient
from app.api import copilot_context
from app.api.copilot_gen import GraphApplier, GraphChecker
from app.db.base import SessionLocal
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, validate_graph
from app.ledger import compose
from app.ledger import match as ledger_match
from app.ledger import store
from app.look.calibers import CaliberKind
from app.main import app
from tests import ledger_samples as S
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
yield
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def use(wf: str, **extra) -> dict:
return {"op": "use_caliber", "id": "c1", "workflow_id": wf, "version": 1, "metrics": ["gmv"],
"inputs": {"start": "2026-09-01", "end": "2026-09-08"}, **extra}
async def composer() -> compose.Composer:
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
return compose.Composer(list(kind.options))
# --------------------------------------------------------------------------
# 实例化
# --------------------------------------------------------------------------
async def test_instantiation_is_deterministic_and_copies_the_pinned_fetch():
wf = await S.publish()
made = (await composer()).compose(use(wf), taken={"start"})
again = (await composer()).compose(use(wf), taken={"start"})
assert made.nodes == again.nodes and made.edges == again.edges
fetch, card = made.nodes
assert fetch["id"] == "c1_fetch" and fetch["type"] == "tool"
assert fetch["data"]["config"]["tool"] == S.tool()
assert fetch["data"]["config"]["args"]["sql"] == S.TEMPLATE.replace("{{ input.start }}", "2026-09-01").replace(
"{{ input.end }}", "2026-09-08")
assert card["id"] == "c1" and card["type"] == "metrics" and card["data"]["label"] == "card"
assert card["data"]["config"]["caliber_from"] == {"workflow_id": wf, "workflow_version": 1, "node_id": "card",
"nodes": {"fetch": "c1_fetch"}}
assert made.edges == [{"source": "c1_fetch", "target": "c1"}]
# 缺省值补上了(store 有缺省 all),不进结果
assert made.inputs == {"start": "2026-09-01", "end": "2026-09-08", "store": "all"}
result = made.result()
assert result["status"] == "ok" and result["card_id"] == "c1" and result["fetches"] == ["c1_fetch"]
assert result["level_text"] == "受管" and result["metrics"] == [{"id": "gmv", "name": "销售额"}]
assert "sql" not in json.dumps(result).lower()
@pytest.mark.parametrize(("change", "words"), [
({"workflow_id": "nope"}, "只能选用这一轮查看到的已发布口径"),
({"version": 2}, "能选用的是 v1"),
({"card": "other"}, "没有口径卡「other」"),
({"id": "C 1"}, "简短的英文小写"),
({"metrics": []}, "metrics 要写"),
({"metrics": ["profit"]}, "没有指标「profit」"),
({"inputs": {"start": "2026-09-01", "end": "2026-09-08", "city": "x"}}, "入口没有字段「city」"),
({"inputs": {"start": "2026-09-01"}}, "请填写「end」"),
({"inputs": {"start": "2026-09-01' OR '1'='1", "end": "2026-09-08"}}, "引号里,值里不能有引号"),
({"inputs": "start=2026-09-01"}, "inputs 要写成"),
])
async def test_bad_choices_are_refused_with_a_reason(change, words):
wf = await S.publish()
op = use(wf, **change) if change.get("workflow_id") != "nope" else {**use(wf), **change}
with pytest.raises(compose.ComposeRefused) as e:
(await composer()).compose(op, taken={"start"})
assert words in e.value.message, e.value.message
async def test_bare_slots_take_numbers_only_and_ids_must_not_clash():
graph = S.caliber_graph(sql="SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND id > {{ input.after }}")
# 字段没写类型(按文字收):表单校验放行,裸写进 SQL 的只能是数
graph["nodes"][0]["data"]["config"]["fields"] = [{"name": "after", "default": "0"}]
wf = await S.publish(graph=graph)
pool = await composer()
with pytest.raises(compose.ComposeRefused, match="只能是数"):
pool.compose({**use(wf), "inputs": {"after": "1 OR 1=1"}}, taken=set())
made = pool.compose({**use(wf), "inputs": {"after": "3"}}, taken=set())
assert made.nodes[0]["data"]["config"]["args"]["sql"].endswith("id > 3")
with pytest.raises(compose.ComposeRefused, match="撞了"):
pool.compose({**use(wf), "inputs": {"after": "3"}}, taken={"c1"})
async def test_a_card_that_reads_model_output_is_listed_but_not_composable():
graph = S.chain(S.node("start", "input"), S.node("ask", "agent", tools=[S.tool()], assign_to="kpi"),
S.node("card", "metrics", caliber="周销售", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
wf = await S.publish(graph=graph)
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
[option] = [o for o in kind.options if o.workflow_id == wf]
assert not option.composable and "不能复用" in option.reason
text, _ = await kind.detail([(i, "named") for i in kind.items if i.workflow_id == wf], 4000)
assert "不能组合" in text
with pytest.raises(compose.ComposeRefused, match="不能组合"):
compose.Composer(list(kind.options)).compose(use(wf), taken=set())
# --------------------------------------------------------------------------
# 查看:入口字段、能不能组合,不给 SQL
# --------------------------------------------------------------------------
async def test_the_answer_look_shows_fields_and_how_to_compose_but_no_sql():
wf = await S.publish()
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
plain = await CaliberKind.load(session)
objects = [(i, "named") for i in kind.items if i.workflow_id == wf]
text, [record] = await kind.detail(objects, 4000)
assert "入口字段:start(string,必填);end(string,必填);store(string,缺省 all)" in text
assert '"op": "use_caliber"' in text and f'"workflow_id": "{wf}"' in text and '"card": "card"' in text
assert "SELECT" not in text.upper() and "orders" not in text
assert record["kind"] == "caliber" and record["named"]
# 蓝图助手那一支(不组合)照旧给复用写法,没有组合写法
plain_text, _ = await plain.detail([(i, "named") for i in plain.items if i.workflow_id == wf], 4000)
assert "复用写法" in plain_text and "use_caliber" not in plain_text
async def test_the_answer_prompt_switches_only_when_calibers_can_be_composed():
from app.look.service import LookService
assert copilot_context.LOOK_WHEN_ANSWER_PROMPT != copilot_context.LOOK_WHEN_PROMPT
assert "use_caliber" in copilot_context.LOOK_WHEN_ANSWER_PROMPT and "含退款" in copilot_context.LOOK_WHEN_ANSWER_PROMPT
async with SessionLocal() as session:
composing = LookService([await CaliberKind.load(session, compose=True)])
plain = LookService([await CaliberKind.load(session)])
assert copilot_context.look_when(composing) == copilot_context.LOOK_WHEN_ANSWER_PROMPT
assert copilot_context.look_when(plain) == copilot_context.LOOK_WHEN_PROMPT
assert copilot_context.composer(plain) is None and copilot_context.composer(composing) is not None
# --------------------------------------------------------------------------
# 落地:GraphApplier
# --------------------------------------------------------------------------
async def test_the_applier_lands_the_nodes_wires_the_entry_and_reports_refusals():
wf = await S.publish()
nodes: dict = {}
edges: list = []
applier = GraphApplier(nodes, edges, composer=await composer())
op = use(wf)
applied = applier.apply(op)
assert applied.changed and applied.forward and op["result"]["status"] == "ok"
assert set(nodes) == {"c1_fetch", "c1"} and edges == [{"source": "c1_fetch", "target": "c1", "sourceHandle": None}]
# 入口后加的:加上时接到第一个取数节点;模型再发一条同样的连线不重复
applier.apply({"op": "add_node", "node": {"id": "start", "type": "input", "label": "入口", "config": {}}})
assert {"source": "start", "target": "c1_fetch", "sourceHandle": None} in edges
assert not applier.apply({"op": "add_edge", "edge": {"source": "start", "target": "c1_fetch"}}).changed
assert len([e for e in edges if e["target"] == "c1_fetch"]) == 1
# 接着写时重发同一个选用:不再落地
applier.continuing()
assert not applier.apply(use(wf)).changed
assert "c1" in applier.landed()
bad = use(wf, id="c2", inputs={"start": "2026-09-01"})
refused = applier.apply(bad)
assert not refused.changed and refused.forward and bad["result"]["status"] == "refused"
checker = GraphChecker(applier)
first = await checker.issues()
assert [i["code"] for i in first if i["code"] == compose.REFUSED] == [compose.REFUSED]
assert "请填写「end」" in first[0]["message"] and "重新输出 use_caliber" in first[0]["for_model"]
# 交回过一次就不再交
assert not [i for i in await checker.issues() if i["code"] == compose.REFUSED]
# 兜底重放到上一轮的图上:拷进来的节点和接线照样在
applier.replay_onto({"nodes": [{"id": "start", "type": "input", "data": {"config": {}}}], "edges": []})
assert {"c1_fetch", "c1"} <= set(nodes) and any(e["target"] == "c1_fetch" for e in edges)
async def test_without_a_composer_use_caliber_is_dropped_like_any_unknown_op():
nodes: dict = {}
applier = GraphApplier(nodes, [])
applied = applier.apply({"op": "use_caliber", "id": "c1", "workflow_id": "w", "version": 1})
assert not applied.changed and not applied.forward and nodes == {}
# --------------------------------------------------------------------------
# 问数据走一遍:生成 → 运行 → 比对标 composed
# --------------------------------------------------------------------------
def _answer_script(wf: str) -> list[dict]:
from tests.test_copilot_answer_ops import _edge, _node
return [
{"op": "plan", "summary": "选用周销售口径"},
_node("start", "input", {}),
use(wf),
_node("write", "report", {"instructions": "回答:{{ input.question }}"}),
_edge("c1", "write"),
_node("out", "output", {"fields": [{"name": "答案", "value": "{{ nodes.write.text }}"}]}),
_edge("write", "out"),
{"op": "done", "explanation": "按口径周销售回答", "run": True},
]
async def test_an_answer_built_with_use_caliber_is_labelled_composed(client, monkeypatch):
from tests.test_copilot_answer_ops import BODY, _Scripted, _sse, _use
wf = await S.publish()
_use(monkeypatch, _Scripted(_answer_script(wf), []))
ops = await _sse(client, {**BODY, "instruction": "九月第一周卖了多少"})
[chosen] = [o for o in ops if o["op"] == "use_caliber"]
assert chosen["result"]["status"] == "ok" and chosen["result"]["workflow_name"] == "周销售"
assert not [o for o in ops if o["op"] == "add_node" and o["node"]["id"] in ("c1", "c1_fetch")], \
"拷进来的节点不另外转给界面:过程里只有 use_caliber 那一行"
[final] = [o for o in ops if o["op"] == "final"]
assert final["next"] == "run", final["issues"]
graph = final["graph"]
assert {n["id"] for n in graph["nodes"]} == {"start", "c1_fetch", "c1", "write", "out"}
assert not [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.level == "error"]
S.script(monkeypatch, "九月第一周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
await ledger_match.drain()
recorded = await store.latest(row.id)
content = recorded.content
assert content["algo"] == store.ALGO == "2"
[query] = content["queries"]
assert query["status"] == "composed" and query["node_id"] == "c1_fetch"
[cand] = query["candidates"]
assert cand["status"] == "proven" and cand["how"] == "composed" and cand["caliber"] == f"{wf}@1"
[label] = content["reports"]["write"]["labels"].values()
assert label["kind"] == "caliber" and label["how"] == "composed" and label["queries"] == [query["artifact"]]
assert content["whole"]["equivalent"] is True and content["whole"]["how"] == "composed"
assert recorded.content and (await store.latest(row.id)).trigger == "sealed"
async with SessionLocal() as session:
from app.db.models import Artifact
ref = await session.get(Artifact, (recorded.artifact, row.id))
assert ref.meta["calibers"] == [f"{wf}@1"] and ref.meta["counts"]["composed"] == 1
async def test_users_do_not_get_calibers_in_the_answer_look(monkeypatch):
"""使用者(开登录后的 user 角色):问数据里不看口径、不能选用(拷进来的取数 SQL 在他的图里看得到)。"""
import app.api.copilot as copilot
from app.api.copilot import GenerateIn
seen: dict = {}
async def spy(session, payload, sources, **kw):
seen["kinds"] = kw.get("kinds")
raise RuntimeError("到这里就够了")
monkeypatch.setattr(copilot, "_look_service", spy)
monkeypatch.setattr("app.api.deps.restricted", lambda: True)
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="answer")).__anext__()
assert seen["kinds"] == ("table", "capability")
monkeypatch.setattr("app.api.deps.restricted", lambda: False)
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="answer")).__anext__()
assert seen["kinds"] == ("table", "capability", "caliber")
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="build")).__anext__()
assert seen["kinds"] == ("table", "capability")
+371
View File
@@ -0,0 +1,371 @@
"""按口径正式出具(next-round 02 做法 5,拍板 L1、L5,app/ledger/issue.py)。
真的跑探索运行(虚构店铺库、mock 模型写报告),等后台比对做完,看自动出具、一键出具、拒绝原因、去重:
- 问数据的答案整题独立等价于受管口径:比对完自动发起一次正式运行(来源 ledger_issue),origin 记来源运行、口径、绑定
哈希,参数是绑定 + 缺省值,按 graph_hash 钉住;GET 账本的 actions 写能自动、issued 列出它;再点一次交回同一次;
- 已发布级别、助手选用的、不是问数据的(画布试运行等):不自动,只给一键(写明为什么),一键照常发起;设置关了自动出具
也不自动;
- 拒绝写明原因:整题不等价、点名别的口径、版本退役、图里有人工节点、缺入口字段、超过每日上限、使用者没有应用授权;
使用者有授权(应用固定着这一版)就能出具,origin 记靠的是哪个应用;
- 设置 ledger 一组:缺省值、写坏了拒收。
数据是虚构店铺库。
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.api.deps import Principal
from app.db.base import SessionLocal
from app.db.models import Run, Setting, WorkflowVersion
from app.engine.runner import run_manager
from app.ledger import issue as ledger_issue
from app.ledger import match as ledger_match
from app.main import app
from tests import ledger_samples as S
ADMIN = Principal(username=None, role="admin", mode="off", via="off")
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
# 测试库按进程共用:每日上限数的是今天全部的按口径出具,别的用例发起的也算。每个用例从缺省设置起步、上限放宽,
# 要测上限的用例自己改
await reset_ledger(issue_daily_limit=100_000)
yield
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def reset_ledger(**values) -> None:
async with SessionLocal() as session:
row = await session.get(Setting, "ledger")
if row is not None:
await session.delete(row)
if values:
session.add(Setting(key="ledger", value=values))
await session.commit()
async def set_ledger(**values) -> None:
async with SessionLocal() as session:
row = await session.get(Setting, "ledger")
merged = {**(row.value if row else {}), **values}
if row:
row.value = merged
else:
session.add(Setting(key="ledger", value=merged))
await session.commit()
async def issued(run_id: str) -> list[Run]:
async with SessionLocal() as session:
return await ledger_issue.issued_runs(session, run_id)
async def settle(run_id: str) -> list[Run]:
"""等自动出具发起的正式运行跑完。"""
# 自动出具在比对里就发起了(ledger/match.match_run 等它),drain 之后有没有已经定了:只等发起了的跑完
for _ in range(200):
found = await issued(run_id)
if all(r.status in ("succeeded", "failed", "cancelled") for r in found):
return found
await asyncio.sleep(0.05)
return await issued(run_id)
def two_writers(monkeypatch, answer: str = "本周销售额 [[v:Q1.r0.销售额]]。") -> None:
"""mock 模型:口径那张图的报告(指令「写周销售」)引用口径卡的指标,问数据那张图的报告照 answer 写。"""
from langchain_core.messages import AIMessage
from app.providers import mock_model
def decide(self, messages):
text = "\n".join(str(getattr(m, "content", "")) for m in messages)
return AIMessage(content="周销售 [[m:gmv]]。" if "写周销售" in text else answer)
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
async def equivalent_run(monkeypatch, origin: str = "chat"):
"""一次整题独立等价于「周销售」v1 的探索运行(缺省是问数据的答案),等它封存、比对完。"""
from app.engine.runner import run_manager as manager
two_writers(monkeypatch)
run = await manager.start(graph=S.explore_graph(S.EQUIVALENT), input_payload={}, origin_kind=origin)
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
await ledger_match.drain()
return row
# --------------------------------------------------------------------------
# 自动出具
# --------------------------------------------------------------------------
async def test_an_independent_equivalent_answer_to_a_governed_caliber_is_issued_automatically(client, monkeypatch):
wf = await S.publish()
row = await equivalent_run(monkeypatch)
[formal] = await settle(row.id)
assert formal.run_class == "formal" and formal.origin_kind == "ledger_issue" and formal.status == "succeeded"
assert (formal.workflow_id, formal.version) == (wf, 1)
assert formal.input == {"start": "2026-09-01", "end": "2026-09-08", "store": "all"}
async with SessionLocal() as session:
version = (await session.execute(select(WorkflowVersion).where(
WorkflowVersion.workflow_id == wf, WorkflowVersion.version == 1))).scalar_one()
assert formal.version_hash == version.graph_hash
origin = formal.origin
assert origin["source_run"] == row.id and origin["caliber"] == f"{wf}@1" and origin["how"] == "independent"
assert origin["auto"] is True and origin["binding_hash"] and origin["match"]
body = (await client.get(f"/api/runs/{row.id}/ledger")).json()
[action] = [a for a in body["actions"] if a["kind"] == "issue"]
assert action["available"] is True and action["auto"] is True and action["issued_run"] == formal.id
assert action["level_text"] == "受管" and action["workflow_name"] == "周销售"
[item] = body["issued"]
assert item["run_id"] == formal.id and item["auto"] is True and item["status"] == "succeeded"
# 再点一次「按口径出具」:同一运行、同一口径、同一组参数,交回已经发起的那一次
again = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": wf, "version": 1})
assert again.status_code == 200 and again.json()["created"] is False and again.json()["run_id"] == formal.id
assert len(await issued(row.id)) == 1
async def test_only_answers_in_chat_are_issued_automatically(client, monkeypatch):
"""自动出具只给问数据的答案(拍板 L1、做法 5):画布上反复试跑一份和受管口径等价的草图,每次都是新的来源运行,
自动的话每试一次就多一次正式运行(含报告撰写的模型费用)。画布试运行只给一键,写明为什么,一键照常发起。"""
wf = await S.publish()
row = await equivalent_run(monkeypatch, origin="canvas")
assert await settle(row.id) == []
[action] = [a for a in (await client.get(f"/api/runs/{row.id}/ledger")).json()["actions"] if a["kind"] == "issue"]
assert action["available"] is True and action["auto"] is False
assert action["auto_reason"] == ledger_issue.AUTO_NOT_CHAT.format(what="画布试运行")
assert await ledger_issue.auto_issue(row.id) is None and await issued(row.id) == []
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": wf, "version": 1})
assert res.status_code == 200 and res.json()["created"] is True
[formal] = await settle(row.id)
assert formal.origin["auto"] is False and formal.status == "succeeded"
async def test_published_level_calibers_only_get_one_click(client, monkeypatch):
wf = await S.publish(level="published")
row = await equivalent_run(monkeypatch)
assert await settle(row.id) == []
body = (await client.get(f"/api/runs/{row.id}/ledger")).json()
[action] = [a for a in body["actions"] if a["kind"] == "issue"]
assert action["available"] is True and action["auto"] is False and action["auto_reason"] == ledger_issue.AUTO_LEVEL
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": wf, "version": 1})
assert res.status_code == 200 and res.json()["created"] is True
[formal] = await settle(row.id)
assert formal.origin["auto"] is False and formal.status == "succeeded"
assert res.json()["issued"]["run_id"] == formal.id
async def test_switching_auto_issue_off_keeps_one_click(client, monkeypatch):
await S.publish()
await set_ledger(auto_issue=False)
row = await equivalent_run(monkeypatch)
assert await settle(row.id) == []
[action] = (await client.get(f"/api/runs/{row.id}/ledger")).json()["actions"]
assert action["auto"] is False and action["auto_reason"] == ledger_issue.AUTO_OFF
async def test_composed_answers_are_never_issued_automatically(monkeypatch):
from app.ledger import compose
from app.look.calibers import CaliberKind
wf = await S.publish()
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
made = compose.Composer(list(kind.options)).compose(
{"op": "use_caliber", "id": "c1", "workflow_id": wf, "version": 1, "metrics": ["gmv"],
"inputs": {"start": "2026-09-01", "end": "2026-09-08"}}, taken={"start"})
graph = S.chain(S.node("start", "input"), *made.nodes, S.node("write", "report", instructions="写答案"),
S.node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}]))
two_writers(monkeypatch, "本周销售额 [[m:gmv]]。")
row = await S.wait((await run_manager.start(graph=graph, input_payload={}, origin_kind="chat")).id)
await ledger_match.drain()
assert await settle(row.id) == []
async with SessionLocal() as session:
recorded = await ledger_match.store.latest(row.id)
plan = await ledger_issue.plan_issue(session, await session.get(Run, row.id), recorded.content, wf, 1,
principal=ADMIN)
assert plan.available and plan.how == "composed" and plan.auto_reason == ledger_issue.AUTO_COMPOSED
# --------------------------------------------------------------------------
# 拒绝
# --------------------------------------------------------------------------
async def test_refusals_say_why(client, monkeypatch):
wf = await S.publish(level="published")
other = await S.publish(name="另一份口径", level="published")
unrelated = await S.explore(monkeypatch, S.WITH_REFUNDS)
res = await client.post(f"/api/ledger/answers/{unrelated.id}/issue", json={"workflow_id": wf, "version": 1})
assert res.status_code == 409 and res.json()["code"] == ledger_issue.REFUSED
assert res.json()["detail"].startswith("整题没有等价于这份口径")
row = await equivalent_run(monkeypatch)
whole_on = (await ledger_match.store.latest(row.id)).content["whole"]["workflow_id"]
named = other if whole_on == wf else wf
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": named, "version": 1})
assert res.status_code == 409 and "整题等价的是另一份口径" in res.json()["detail"]
# 版本退役
async with SessionLocal() as session:
version = (await session.execute(select(WorkflowVersion).where(
WorkflowVersion.workflow_id == whole_on, WorkflowVersion.version == 1))).scalar_one()
version.retired_at = datetime.now(timezone.utc)
await session.commit()
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": whole_on, "version": 1})
assert res.status_code == 409 and "已退役" in res.json()["detail"]
async def test_a_caliber_with_a_human_step_is_not_issued_on_anyones_behalf(client, monkeypatch):
graph = S.caliber_graph()
graph["nodes"].insert(3, S.node("ok", "human", mode="approve", prompt="确认"))
graph["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(graph["nodes"], graph["nodes"][1:])]
wf = await S.publish(graph=graph)
row = await equivalent_run(monkeypatch)
assert await settle(row.id) == []
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": wf, "version": 1})
assert res.status_code == 409 and "人工节点" in res.json()["detail"] and "不代人做这些事" in res.json()["detail"]
async def test_missing_entry_fields_are_named(client, monkeypatch):
graph = S.caliber_graph()
graph["nodes"][0]["data"]["config"]["fields"].append({"name": "region", "required": True})
wf = await S.publish(graph=graph)
row = await equivalent_run(monkeypatch)
[action] = (await client.get(f"/api/runs/{row.id}/ledger")).json()["actions"]
assert action["available"] is False and "请填写「region」" in action["reason"]
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": wf, "version": 1})
assert res.status_code == 409 and "入口字段还不齐" in res.json()["detail"]
async def test_the_daily_limit_counts_every_issue(client, monkeypatch):
wf = await S.publish(level="published")
await set_ledger(issue_daily_limit=0)
row = await equivalent_run(monkeypatch)
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": wf, "version": 1})
assert res.status_code == 429 and res.json()["code"] == ledger_issue.DAILY_LIMIT and "上限(0 次)" in res.json()["detail"]
async def test_users_issue_only_versions_pinned_by_apps_they_can_use(monkeypatch):
from app.db.models_apps import App, AppGrant, AppVersion
wf = await S.publish(level="published")
row = await equivalent_run(monkeypatch)
async with SessionLocal() as session:
run = await session.get(Run, row.id)
run.started_by = "carol"
await session.commit()
carol = Principal(username="carol", role="user", verified=True, mode="local")
with pytest.raises(ledger_issue.IssueRefused) as e:
await ledger_issue.issue(row.id, wf, 1, principal=carol)
assert e.value.status_code == 403 and e.value.message == ledger_issue.ASK_BUILDER
# 别人的运行读不到:先说读不到
dave = Principal(username="dave", role="user", verified=True, mode="local")
with pytest.raises(ledger_issue.IssueRefused) as e:
await ledger_issue.issue(row.id, wf, 1, principal=dave)
assert e.value.status_code == 403 and e.value.message == ledger_issue.NO_RUN_ACCESS
async with SessionLocal() as session:
board = App(name="周销售看板", published_version=1, created_by="bob")
session.add(board)
await session.flush()
session.add(AppVersion(app_id=board.id, version=1, spec={}, spec_hash="x",
pins=[{"alias": "week", "workflow_id": wf, "version": 1}]))
session.add(AppGrant(app_id=board.id, principal_kind="user", principal="carol", role="use"))
await session.commit()
board_id = board.id
formal, created = await ledger_issue.issue(row.id, wf, 1, principal=carol)
assert created and formal.started_by == "carol" and formal.origin["granted_by_app"] == board_id
async def test_concurrent_requests_issue_once(monkeypatch):
wf = await S.publish(level="published")
row = await equivalent_run(monkeypatch)
results = await asyncio.gather(*(ledger_issue.issue(row.id, wf, 1, principal=ADMIN) for _ in range(3)))
assert sorted(created for _, created in results) == [False, False, True]
assert len({r.id for r, _ in results}) == 1
# --------------------------------------------------------------------------
# 设置
# --------------------------------------------------------------------------
async def test_ledger_settings_have_defaults_and_refuse_bad_values(client):
await reset_ledger()
body = (await client.get("/api/settings")).json()
assert body["ledger"] == {"auto_issue": True, "issue_daily_limit": 20, "match_timeout_s": 8}
bad = await client.put("/api/settings", json={"values": {"ledger": {"auto_issue": "yes"}}})
assert bad.status_code == 422 and "自动按口径正式出具" in bad.json()["detail"]
bad = await client.put("/api/settings", json={"values": {"ledger": {"match_timeout_s": 600}}})
assert bad.status_code == 422 and "口径比对时限" in bad.json()["detail"]
ok = await client.put("/api/settings", json={"values": {"ledger": {"auto_issue": False, "issue_daily_limit": 5,
"match_timeout_s": 3}}})
assert ok.status_code == 200 and ok.json()["ledger"] == {"auto_issue": False, "issue_daily_limit": 5,
"match_timeout_s": 3}
async def test_auto_issue_goes_by_the_askers_permissions_when_login_is_on(monkeypatch):
"""开登录时自动出具按这次运行的发起人判(拍板 L5):使用者没有应用授权就不自动出具,有了才出具;账号不在了的按使用者算。"""
from app.db.models import User
from app.db.models_apps import App, AppGrant, AppVersion
monkeypatch.setattr("app.auth.current_mode", lambda: "local")
await set_ledger(auto_issue=False)
wf = await S.publish()
row = await equivalent_run(monkeypatch)
await set_ledger(auto_issue=True)
async with SessionLocal() as session:
session.add(User(username=f"erin_{row.id[:6]}", role="user", password_hash="x"))
(await session.get(Run, row.id)).started_by = f"erin_{row.id[:6]}"
await session.commit()
assert await ledger_issue.auto_issue(row.id) is None and await issued(row.id) == []
async with SessionLocal() as session:
board = App(name="周销售看板", published_version=1, created_by="bob")
session.add(board)
await session.flush()
session.add(AppVersion(app_id=board.id, version=1, spec={}, spec_hash="x",
pins=[{"alias": "week", "workflow_id": wf, "version": 1}]))
session.add(AppGrant(app_id=board.id, principal_kind="user", principal=f"erin_{row.id[:6]}", role="use"))
await session.commit()
launched = await ledger_issue.auto_issue(row.id)
[formal] = await settle(row.id)
assert launched == formal.id and formal.started_by == f"erin_{row.id[:6]}" and formal.origin["auto"] is True
async def test_binding_values_that_would_break_the_sql_are_not_filled_in(client, monkeypatch):
"""绑定出来的值要渲染进口径的 SQL 模板:写在引号里的值带着引号(这次的 SQL 里写的是转义过的 '',绑定照原样取出)就不代填
出具——和组合时校验助手给的参数是同一个判断,宁可少出具。"""
template = "SELECT SUM(amount) AS gmv FROM orders WHERE status = '{{ input.status }}'"
graph = S.caliber_graph(template)
graph["nodes"][0]["data"]["config"]["fields"] = [{"name": "status", "required": True}]
wf = await S.publish(graph=graph, level="published")
two_writers(monkeypatch, "本周销售额 [[v:Q1.r0.gmv]]。")
run = await run_manager.start(graph=S.explore_graph("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'pa''id'"),
input_payload={}, origin_kind="canvas")
row = await S.wait(run.id)
await ledger_match.drain()
whole = (await ledger_match.store.latest(row.id)).content["whole"]
assert whole["equivalent"] is True and whole["binding"] == {"status": "pa''id"}
res = await client.post(f"/api/ledger/answers/{row.id}/issue", json={"workflow_id": wf, "version": 1})
assert res.status_code == 409 and "不能有引号" in res.json()["detail"] and "不代填出具" in res.json()["detail"]
+250
View File
@@ -0,0 +1,250 @@
"""口径对账:差多少(next-round 02 做法 6,app/ledger/reconcile.py、ledger/derive.reconcile_graph)。
真的跑探索运行(虚构店铺库、mock 模型写报告)、等后台比对做完,再点开对账:
- 动作:有相关口径的查询给「看差多少」;参数是按「同一列、同一运算符」猜的,要先核对(needs_check);
- 不核对直接发起回 409(要核对参数);核对过的参数发起:派生图是确定的、过 validate、出口契约 metrics_from 指向对账卡、
不加报告;跑出来的这次、口径、差值、相对差都对,差值的出处链点得开;GET 账本列出对过的账;统计 E2 数到它;
- 认不出哪一列对哪个指标(口径的指标式不是取数的一格):要人选,选了照记进 origin;
- 多行结果:被引用的行按维度列经合并查询对齐,每行一组对账指标;维度是遮罩列、合并键不唯一时照实拒绝。
数据是虚构店铺库。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Run
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, validate_graph
from app.ledger import match as ledger_match
from app.ledger import reconcile as ledger_reconcile
from app.main import app
from tests import ledger_samples as S
BY_REGION = ("SELECT region AS 区域, SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}' GROUP BY region ORDER BY region")
BY_REGION_ALL = ("SELECT region AS 区域, SUM(amount) AS gmv FROM orders WHERE paid_at >= '2026-09-01' "
"AND paid_at < '2026-09-08' GROUP BY region ORDER BY region")
CHECKED = {"start": "2026-09-01", "end": "2026-09-08"}
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
path = tmp_path / "shop.db"
await S.fresh_source(path)
yield path
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def ledger_of(client, run_id: str) -> dict:
res = await client.get(f"/api/runs/{run_id}/ledger")
assert res.status_code == 200, res.text
return res.json()
async def card_of(run_id: str, node_id: str = "reconcile") -> dict:
from app.api.evidence import _sealed, _sealed_cards
sealed = await _sealed(run_id)
[(_, card)] = [(a, c) for n, a, c in _sealed_cards(sealed) if n == node_id]
return {m["id"]: m for m in card["metrics"]}
async def reconcile(client, run_id: str, body: dict) -> Run:
res = await client.post(f"/api/ledger/answers/{run_id}/reconcile", json=body)
assert res.status_code == 201, res.text
return await S.wait(res.json()["run_id"])
async def test_reconcile_the_refunds_answer_against_the_weekly_caliber(client, monkeypatch):
wf = await S.publish()
row = await S.explore(monkeypatch, S.WITH_REFUNDS)
body = await ledger_of(client, row.id)
[action] = [a for a in body["actions"] if a["kind"] == "reconcile"]
assert action["workflow_id"] == wf and action["version"] == 1 and action["node_id"] == "fetch"
assert action["needs_check"] is True and action["check_reason"] == ledger_reconcile.CHECK_REASON
assert action["binding"] == [{"field": "end", "value": "2026-09-08"}, {"field": "start", "value": "2026-09-01"}]
query = action["query"]
# 参数是猜的:不核对就发起,照实拒绝
res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json={"query": query, "workflow_id": wf,
"version": 1})
assert res.status_code == 409 and res.json()["code"] == ledger_reconcile.CHECK_INPUTS
assert res.json()["detail"] == ledger_reconcile.CONFIRM_FIRST
done = await reconcile(client, row.id, {"query": query, "workflow_id": wf, "version": 1, "inputs": CHECKED,
"confirm": True})
assert done.status == "succeeded", done.error
assert done.origin_kind == "reconcile" and done.run_class == "exploratory" and done.workflow_id is None
origin = done.origin
assert origin["source_run"] == row.id and origin["query"] == query and origin["inputs_from"] == "checked"
assert origin["pairs"] == [{"ours": "gmv", "metric": "gmv"}] and origin["caliber"] == f"{wf}@1"
assert done.input == {"start": "2026-09-01", "end": "2026-09-08", "store": "all"}
graph = done.graph
ids = [n["id"] for n in graph["nodes"]]
assert ids == ["derived_in", "ours_q1", "fetch", "card", "reconcile", "derived_out"]
nodes = {n["id"]: n for n in graph["nodes"]}
assert nodes["ours_q1"]["data"]["config"]["args"]["sql"] == S.WITH_REFUNDS
assert "{{ input.start }}" in nodes["fetch"]["data"]["config"]["args"]["sql"], "口径的取数保留模板,运行时按入口渲染"
assert nodes["derived_out"]["data"]["config"]["contract"] == {"metrics_from": "reconcile"}
assert not [n for n in graph["nodes"] if n["type"] in ("report", "llm", "agent")]
assert not [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.level == "error"]
metrics = await card_of(done.id)
assert metrics["ours_0"]["value"] == 221.0 and metrics["cal_0"]["value"] == 170.5
assert metrics["diff_0"]["value"] == 50.5 and metrics["rel_0"]["value"] == 0.2962
assert metrics["rel_0"]["rendered"] == "29.62%" and metrics["diff_0"]["rendered"] == "50.50元"
# 三个数各自点得开:差值的出处链追到两边的查询
res = await client.get(f"/api/runs/{done.id}/evidence/metrics/reconcile/diff_0")
assert res.status_code == 200, res.text
inputs = [s for s in res.json()["chain"] if s["step"] == "input"]
assert {i["node_id"] for i in inputs} == {"ours_q1", "card"}
listed = (await ledger_of(client, row.id))["reconciled"]
assert [r["run_id"] for r in listed] == [done.id] and listed[0]["inputs_from"] == "checked"
# 统计 E2:对账运行数到了(它不进使用统计,E2 专门数它)
stats = (await client.get("/api/ledger/stats")).json()["e2"]
assert stats["launched"] >= 1 and stats["succeeded"] >= 1
async def test_the_derived_graph_is_deterministic():
from app.ledger import derive
wf = await S.publish()
async with SessionLocal() as session:
pinned = await derive.load_pinned(session, wf, 1, "card")
plan = ledger_reconcile.Plan(pairs=[ledger_reconcile.Pair(ours="gmv", metric="gmv", index=0, name="销售额",
unit="元", decimals=2)])
a = ledger_reconcile.build(pinned, sql=S.WITH_REFUNDS, tool=S.tool(), plan=plan, inputs=CHECKED, fetch="fetch")
b = ledger_reconcile.build(pinned, sql=S.WITH_REFUNDS, tool=S.tool(), plan=plan, inputs=CHECKED, fetch="fetch")
assert a.graph == b.graph and a.kind == "reconcile"
assert a.origin["from"][0] == {"kind": "executed", "node_id": "ours_q1"} and a.origin["from"][1]["node_id"] == "card"
async def test_metrics_that_are_not_a_plain_cell_need_a_person_to_pick(client, monkeypatch):
wf = await S.publish(graph=S.caliber_graph(expression="cell(nodes.fetch, 0, 'gmv') * 1"))
row = await S.explore(monkeypatch, S.WITH_REFUNDS)
[action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"]
body = {"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED, "confirm": True}
res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json=body)
assert res.status_code == 409 and res.json()["code"] == ledger_reconcile.NEEDS_PAIRS
bad = await client.post(f"/api/ledger/answers/{row.id}/reconcile",
json={**body, "pairs": [{"ours": "nope", "metric": "gmv"}]})
assert bad.status_code == 422 and "没有列「nope」" in bad.json()["detail"]
done = await reconcile(client, row.id, {**body, "pairs": [{"ours": "gmv", "metric": "gmv"}]})
assert done.status == "succeeded", done.error
assert done.origin["pairs_chosen"] is True
assert (await card_of(done.id))["diff_0"]["value"] == 50.5
async def test_multi_row_answers_are_aligned_by_their_dimensions(client, monkeypatch):
wf = await S.publish(graph=S.caliber_graph(BY_REGION))
row = await S.explore(monkeypatch, BY_REGION_ALL, text="东区 [[v:Q1.r0.gmv]],西区 [[v:Q1.r1.gmv]]。")
[action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"]
done = await reconcile(client, row.id, {"query": action["query"], "workflow_id": wf, "version": 1,
"inputs": CHECKED, "confirm": True})
assert done.status == "succeeded", done.error
assert done.origin["rows"] == [0, 1] and done.origin["pairs"] == [{"ours": "gmv", "caliber": "gmv"}]
align = next(n for n in done.graph["nodes"] if n["id"] == "align")
assert align["type"] == "merge" and align["data"]["config"]["inputs"] == {"o": "ours_q1", "c": "fetch"}
metrics = await card_of(done.id)
assert metrics["ours_0_0"]["name"] == "区域=east · gmv · 这次"
assert (metrics["ours_0_0"]["value"], metrics["cal_0_0"]["value"], metrics["diff_0_0"]["value"]) == (163.0, 150.5, 12.5)
assert (metrics["ours_1_0"]["value"], metrics["cal_1_0"]["value"], metrics["diff_1_0"]["value"]) == (58.0, 20.0, 38.0)
async def test_masked_dimensions_and_duplicate_keys_are_refused(client, monkeypatch, engine_up):
wf = await S.publish(graph=S.caliber_graph(BY_REGION))
duplicated = ("SELECT region AS 区域, amount AS gmv FROM orders WHERE paid_at >= '2026-09-01' "
"AND paid_at < '2026-09-08' ORDER BY id")
row = await S.explore(monkeypatch, duplicated, text="第一单 [[v:Q1.r0.gmv]],第二单 [[v:Q1.r1.gmv]]。")
[action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"]
res = await client.post(f"/api/ledger/answers/{row.id}/reconcile",
json={"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED,
"confirm": True})
assert res.status_code == 409 and "有重复的行" in res.json()["detail"]
await S.set_source(engine_up, options={"mask_columns": ["region"]})
row = await S.explore(monkeypatch, BY_REGION_ALL, text="东区 [[v:Q1.r0.gmv]],西区 [[v:Q1.r1.gmv]]。")
[action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"]
res = await client.post(f"/api/ledger/answers/{row.id}/reconcile",
json={"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED,
"confirm": True})
assert res.status_code == 409 and "设置了遮罩" in res.json()["detail"]
def test_align_sql_keeps_the_referenced_rows_in_order():
pairs = [ledger_reconcile.Pair(ours="gmv", column="销售额")]
sql = ledger_reconcile.align_sql([("区域", "区域")], pairs, [("west",), ("o'east",)])
assert sql == ('SELECT o."区域" AS "区域", o."gmv" AS "ours_0", c."销售额" AS "cal_0" FROM o LEFT JOIN c ON '
'o."区域" = c."区域" WHERE (o."区域" = \'west\') OR (o."区域" = \'o\'\'east\') '
'ORDER BY CASE WHEN (o."区域" = \'west\') THEN 0 WHEN (o."区域" = \'o\'\'east\') THEN 1 END')
async def test_users_reconcile_their_own_answers_read_only_and_masked(client, monkeypatch):
"""拍板 L5:对账(只读的探索运行)使用者都可以,带上问数据同一套 MASK_RESULTS / READ_ONLY;别人的答案读不到就不给对。"""
from app.api.deps import Principal
from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION
wf = await S.publish()
row = await S.explore(monkeypatch, S.WITH_REFUNDS)
async with SessionLocal() as session:
(await session.get(Run, row.id)).started_by = "carol"
await session.commit()
[action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"]
req = ledger_reconcile.Request(query=action["query"], workflow_id=wf, version=1, inputs=CHECKED, confirm=True)
dave = Principal(username="dave", role="user", verified=True, mode="local")
with pytest.raises(ledger_reconcile.ReconcileRefused) as e:
await ledger_reconcile.reconcile(row.id, req, principal=dave)
assert e.value.status_code == 403
carol = Principal(username="carol", role="user", verified=True, mode="local")
run = await ledger_reconcile.reconcile(row.id, req, principal=carol)
assert run.started_by == "carol"
assert run.options[MASK_RESULTS_OPTION] is True and run.options[READ_ONLY_OPTION] is True
done = await S.wait(run.id)
assert done.status == "succeeded", done.error
async def test_users_read_their_reconcile_runs_only_through_the_evidence(client, monkeypatch):
"""使用者发起的对账运行带 MASK_RESULTS、记在他名下,但派生图里有口径取数节点的 SQL 模板(口径的 SQL 可能写着遮罩值,
所以问数据里使用者不看口径、不能 use_caliber):运行的原始记录、事件、运行时的图(/api/runs/{id}、/events、/graph)只给
他自己的问数据运行,对账运行经证据接口读(那里按遮罩渲染)。"""
from app.tools.datasource import MASK_RESULTS_OPTION
from tests.fakes import auth_env
wf = await S.publish()
row = await S.explore(monkeypatch, S.WITH_REFUNDS, origin="chat")
# 当作开登录后使用者 carol 问的:问数据运行在工具边界遮
async with SessionLocal() as session:
source = await session.get(Run, row.id)
source.started_by, source.options = "carol", {MASK_RESULTS_OPTION: True}
await session.commit()
[action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"]
await auth_env.reset()
try:
admin = await auth_env.turn_on_local()
await auth_env.make_user("carol", "user")
assert (await admin.put("/api/auth/settings", json={"user_can_chat": True})).status_code == 200
carol = await auth_env.login("carol")
res = await carol.post(f"/api/ledger/answers/{row.id}/reconcile", json={
"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED, "confirm": True})
assert res.status_code == 201, res.text
done = await S.wait(res.json()["run_id"])
assert done.status == "succeeded" and done.started_by == "carol" and done.options[MASK_RESULTS_OPTION] is True
for rest in ("", "/events", "/graph"):
assert (await carol.get(f"/api/runs/{done.id}{rest}")).status_code == 403, rest
assert (await carol.get(f"/api/runs/{row.id}{rest}")).status_code == 200, f"自己的问数据运行照旧:{rest}"
assert (await carol.get(f"/api/runs/{done.id}/evidence")).status_code == 200
finally:
await auth_env.reset()
+14
View File
@@ -4552,10 +4552,24 @@ export const LEDGER_TEXT = {
/** 副标题:比对在封存之后;事后补比、打开时按需比的另写一句 */
sub: '封存后比对,不在封存范围内',
retro: '按现在的口径回看',
/** 从问数据固化出的口径发布之后,对来源运行补比的那一次(服务端 trigger=certified) */
certified: '按之后发布的口径回看',
/** 标签的四种、认法的两种(第 4 波的证据面板、轮次摘要用同一套) */
kind: { caliber: '来自口径', related: '相关口径', reasoning: '这次的推理', unknown: '没有比对' },
how: { independent: '等价已证明', composed: '助手选用' },
level: { governed: '受管', published: '已发布' },
/**
* 问数据的助手选用已发布口径(use_caliber,服务端 ledger/compose.py 按那一版生成取数节点和口径卡):过程里照实写一行。
* 拒收的(参数不对、版本不对……)同样一行,写服务端给的原因
*/
use: {
title: (name: string, version: number | null, level: string) =>
`选用口径「${name}」${version != null ? ` v${version}` : ''}${level ? `(${level})` : ''}`,
detail: (fetches: number, card: string, metrics: string) =>
`按这一版生成取数节点 ${formatNumber(fetches)} 个和口径卡「${card}」${metrics ? `,用到的指标:${metrics}` : ''}`,
refused: '没有选用口径',
status: '正在选用口径…',
},
}
/**
+21 -1
View File
@@ -2412,7 +2412,8 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
id: `lm-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_matched',
level: typeof counts.skipped === 'number' && counts.skipped > 0 ? 'warn' : 'info',
title: LEDGER_TEXT.timeline(counts),
sub: [LEDGER_TEXT.sub, retro ? LEDGER_TEXT.retro : ''].filter(Boolean).join(' · '),
sub: [LEDGER_TEXT.sub, d.trigger === 'certified' ? LEDGER_TEXT.certified : retro ? LEDGER_TEXT.retro : '']
.filter(Boolean).join(' · '),
})
break
}
@@ -3623,6 +3624,25 @@ export function decodeCopilot(ops: CopilotOp[], opts?: {
.join('\n') })
break
}
case 'use_caliber': {
// 问数据的助手选用已发布口径(服务端 ledger/compose.py):取数节点和口径卡由服务端按那一版生成,不另转 add_node,
// 这里一行说清选用了哪一版、生成了几个节点;拒收的照实写原因(原因同样交回了模型去改)
const r = op.result && typeof op.result === 'object' ? op.result : null
if (r?.status === 'ok') {
nodeCount += num(r.nodes) ?? 0
const metrics = Array.isArray(r.metrics) ? r.metrics.map((m: any) => String(m?.name ?? m?.id ?? '')).filter(Boolean) : []
add({ id: `uc-${i}`, seq: i, kind: 'node', status: 'done', code: 'use_caliber',
title: clip(LEDGER_TEXT.use.title(String(r.workflow_name ?? r.workflow_id ?? ''), num(r.version) ?? null,
String(r.level_text ?? ''))),
detail: LEDGER_TEXT.use.detail(Array.isArray(r.fetches) ? r.fetches.length : 0, String(r.card ?? r.card_id ?? ''),
metrics.join('、')),
...(r.card_id ? { nodeId: String(r.card_id) } : {}) })
} else if (r?.status === 'refused') {
add({ id: `uc-${i}`, seq: i, kind: 'note', status: 'done', level: 'warn', code: 'use_caliber_refused',
title: LEDGER_TEXT.use.refused, detail: String(r.reason ?? '') || undefined })
}
break
}
case 'plan':
add({ id: `cp-${i}`, seq: i, kind: 'note', status: 'done',
title: clip(String(op.summary ?? '已完成规划'), 120) })
+5 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { ApiError, api, streamCopilot, streamRun } from '../api/client'
import type { CopilotOp, FixKind, RunFinal } from '../run/decode'
import { copilotContext, copilotLook, decodeRun, looksOf } from '../run/decode'
import { CHAT_BLUEPRINT_TEXT, LOOK_TEXT, REJECTED_TEXT } from '../lib/terms'
import { CHAT_BLUEPRINT_TEXT, LEDGER_TEXT, LOOK_TEXT, REJECTED_TEXT } from '../lib/terms'
import type { LookEvent } from '../typesApp'
import { errorMessage, humanizeError, isNetworkError } from '../lib/errors'
import { guardReview, hasEvidence } from '../lib/evidence'
@@ -1492,6 +1492,10 @@ function plan(flow: Flow, args: PlanArgs) {
status: `正在生成工作流…(${t.ops.filter((o) => o.op === 'add_node').length} 步)`,
}))
break
case 'use_caliber':
// 助手选用了一份已发布口径(取数节点和口径卡由服务端生成,过程里那一行由 decodeCopilot 写)
patch(() => ({ phase: 'building', status: LEDGER_TEXT.use.status }))
break
case 'done':
patch(() => ({ explanation: op.explanation ?? '' }))
break
+29
View File
@@ -1877,6 +1877,9 @@ await section('口径账本:封存后比对已发布口径(ledger.matched)
const late = run({ counts: { proven: 0, related: 0, none: 1, skipped: 1 }, trigger: 'backfill' }).find((s) => s.code === 'ledger_matched')
check('事后补比的写明按现在的口径回看;没比完的是提醒', late?.title === '比对已发布口径:1 条没有相关的已发布口径,1 条没有比完'
&& late?.sub === '封存后比对,不在封存范围内 · 按现在的口径回看' && late?.level === 'warn', `${late?.title} / ${late?.sub} / ${late?.level}`)
const certified = run({ counts: { proven: 1 }, trigger: 'certified' }).find((s) => s.code === 'ledger_matched')
check('固化出的口径发布后补比的写明按之后发布的口径回看', certified?.sub === '封存后比对,不在封存范围内 · 按之后发布的口径回看',
certified?.sub)
const odd = run({ counts: 'x' }).find((s) => s.code === 'ledger_matched')
check('缺字段:只说比对过,不出 undefined、不瞎填数', odd?.title === '比对已发布口径' && !/undefined|NaN/.test(`${odd?.title}${odd?.sub}`),
odd?.title)
@@ -1885,6 +1888,32 @@ await section('口径账本:封存后比对已发布口径(ledger.matched)
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
})
await section('口径账本:问数据的助手选用已发布口径(use_caliber 操作)', async () => {
// next-round 02 做法 4:取数节点和口径卡由服务端按那一版生成(ledger/compose.py),不另转 add_node。过程里一行说清选用了
// 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改)
const ok = { op: 'use_caliber', id: 'c1', workflow_id: 'wf-week', version: 3, metrics: ['gmv'], inputs: { start: '2026-09-01' },
result: { status: 'ok', workflow_id: 'wf-week', workflow_name: '周销售', version: 3, level: 'governed', level_text: '受管',
card: '周销售卡', card_id: 'c1', fetches: ['c1_fetch'], metrics: [{ id: 'gmv', name: '销售额' }], nodes: 2 } }
const steps = mod.decodeCopilot([
{ op: 'plan', summary: '选用周销售口径' },
{ op: 'add_node', node: { id: 'start', type: 'input', label: '入口', config: {} } },
ok,
{ op: 'add_node', node: { id: 'write', type: 'report', label: '写答案', config: {} } },
{ op: 'done', explanation: '按口径回答' },
], { context: 'chat' })
const row = steps.find((s) => s.code === 'use_caliber')
check('一行:选用了哪一版、级别', row?.title === '选用口径「周销售」 v3(受管)' && row?.kind === 'node' && row?.status === 'done', row?.title)
check('展开:生成了几个取数节点、哪张口径卡、用到哪几个指标',
row?.detail === '按这一版生成取数节点 1 个和口径卡「周销售卡」,用到的指标:销售额', row?.detail)
check('节点数算进「新增几个步骤」(入口、报告各 1,选用的 2)',
steps.find((s) => s.id.startsWith('cd-'))?.title === '工作流已搭建完成,新增 4 个步骤', steps.find((s) => s.id.startsWith('cd-'))?.title)
const refused = mod.decodeCopilot([{ op: 'use_caliber', id: 'c2', result: { status: 'refused', reason: '请填写「end」' } }])
.find((s) => s.code === 'use_caliber_refused')
check('拒收的:提醒一行,写服务端给的原因', refused?.title === '没有选用口径' && refused?.detail === '请填写「end」'
&& refused?.level === 'warn', `${refused?.title} / ${refused?.detail}`)
check('没带结果(老服务端、形状不对):不出行、不报错', mod.decodeCopilot([{ op: 'use_caliber', id: 'c1' }, { op: 'use_caliber', result: 'x' }]).length === 0)
})
await section('术语', async () => {
// 没起名的节点退到类型名,类型名跟全站同一张表
const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }])