mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
合入 W3-b 口径账本闭环:caliber_from.nodes 改名映射;问数据的助手 use_caliber 选用已发布口径(服务端确定性内联实例化);按口径正式出具(自动只给问数据的答案)与口径对账;认证队列、固化成口径草稿、发布后补比;账本统计补上对账与认证转化;使用者的原始运行记录只给问数据的运行
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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),原样交给界面
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)。用户在卡片上选的、助手提的都过这一道。"""
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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: '正在选用口径…',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } }])
|
||||
|
||||
Reference in New Issue
Block a user