Files
AgentLab/backend/app/ledger/compose.py
T
ThearchyHelios ced7234180 按口径出具也按 SQL 里参数的位置校验绑定值;去掉 certify 没用到的导入
为什么:出具用的参数来自比对时的绑定,运行时要渲染进口径的 SQL 模板。写在引号里的值带着引号、裸写的值不是数,渲染出来就不是那一版口径的 SQL 了。组合时校验助手给的参数用的是同一个判断(ledger/compose.slot_contexts / value_problem),这里共用;那张图里别的节点引用运行时变量的不归出具管,跳过不报。宁可少出具,照实说为什么不代填。
2026-10-16 17:17:48 +08:00

255 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""组合式问数:口径做骨架,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