diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 1ec9ebe4..b10f8944 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -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: diff --git a/backend/app/api/copilot_context.py b/backend/app/api/copilot_context.py index 92621802..71f90fab 100644 --- a/backend/app/api/copilot_context.py +++ b/backend/app/api/copilot_context.py @@ -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", ] diff --git a/backend/app/api/copilot_gen.py b/backend/app/api/copilot_gen.py index e88c1b41..2acbeab4 100644 --- a/backend/app/api/copilot_gen.py +++ b/backend/app/api/copilot_gen.py @@ -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" diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 89023f31..21b4fbda 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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) diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index e647c40c..2b6a4a2e 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -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),原样交给界面 diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 1af77f15..5c8baa41 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -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} diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c45a685a..0ed08090 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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(): diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 2dd765c0..62dee247 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -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, diff --git a/backend/app/apps/backfill.py b/backend/app/apps/backfill.py index 6777986c..47349ed9 100644 --- a/backend/app/apps/backfill.py +++ b/backend/app/apps/backfill.py @@ -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 diff --git a/backend/app/engine/compare.py b/backend/app/engine/compare.py index d4a0a4c5..49839bf1 100644 --- a/backend/app/engine/compare.py +++ b/backend/app/engine/compare.py @@ -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: diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 75a33045..4f78c8f2 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -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 diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 06649df3..c289fdc9 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -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 同一种写法 +_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)) diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 7284f28b..8f6fca69 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -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") diff --git a/backend/app/ledger/certify.py b/backend/app/ledger/certify.py new file mode 100644 index 00000000..cf57526b --- /dev/null +++ b/backend/app/ledger/certify.py @@ -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"] diff --git a/backend/app/ledger/compose.py b/backend/app/ledger/compose.py new file mode 100644 index 00000000..160557f5 --- /dev/null +++ b/backend/app/ledger/compose.py @@ -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 diff --git a/backend/app/ledger/derive.py b/backend/app/ledger/derive.py index 835fabfa..4d22ff4c 100644 --- a/backend/app/ledger/derive.py +++ b/backend/app/ledger/derive.py @@ -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"] diff --git a/backend/app/ledger/issue.py b/backend/app/ledger/issue.py new file mode 100644 index 00000000..a05c81d8 --- /dev/null +++ b/backend/app/ledger/issue.py @@ -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"] diff --git a/backend/app/ledger/labels.py b/backend/app/ledger/labels.py index 5511ca6d..08a23cac 100644 --- a/backend/app/ledger/labels.py +++ b/backend/app/ledger/labels.py @@ -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"] diff --git a/backend/app/ledger/match.py b/backend/app/ledger/match.py index 109d37d8..85804248 100644 --- a/backend/app/ledger/match.py +++ b/backend/app/ledger/match.py @@ -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"] diff --git a/backend/app/ledger/reconcile.py b/backend/app/ledger/reconcile.py new file mode 100644 index 00000000..89cf56de --- /dev/null +++ b/backend/app/ledger/reconcile.py @@ -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., 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"] diff --git a/backend/app/ledger/stats.py b/backend/app/ledger/stats.py index 38aa108e..b0f4bb8e 100644 --- a/backend/app/ledger/stats.py +++ b/backend/app/ledger/stats.py @@ -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 diff --git a/backend/app/ledger/store.py b/backend/app/ledger/store.py index 3fd3aff2..6ccb7f39 100644 --- a/backend/app/ledger/store.py +++ b/backend/app/ledger/store.py @@ -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) diff --git a/backend/app/ledger/view.py b/backend/app/ledger/view.py index 5e827e04..d1c8f356 100644 --- a/backend/app/ledger/view.py +++ b/backend/app/ledger/view.py @@ -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, } diff --git a/backend/app/look/calibers.py b/backend/app/look/calibers.py index fce2daf4..3a5ea8f0 100644 --- a/backend/app/look/calibers.py +++ b/backend/app/look/calibers.py @@ -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)。用户在卡片上选的、助手提的都过这一道。""" diff --git a/backend/tests/test_caliber_from_nodes.py b/backend/tests/test_caliber_from_nodes.py new file mode 100644 index 00000000..be4a6800 --- /dev/null +++ b/backend/tests/test_caliber_from_nodes.py @@ -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 diff --git a/backend/tests/test_ledger_api.py b/backend/tests/test_ledger_api.py index 885c2579..24cc969a 100644 --- a/backend/tests/test_ledger_api.py +++ b/backend/tests/test_ledger_api.py @@ -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() diff --git a/backend/tests/test_ledger_certify.py b/backend/tests/test_ledger_certify.py new file mode 100644 index 00000000..e91dee59 --- /dev/null +++ b/backend/tests/test_ledger_certify.py @@ -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 diff --git a/backend/tests/test_ledger_compose.py b/backend/tests/test_ledger_compose.py new file mode 100644 index 00000000..0c3405b0 --- /dev/null +++ b/backend/tests/test_ledger_compose.py @@ -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") diff --git a/backend/tests/test_ledger_issue.py b/backend/tests/test_ledger_issue.py new file mode 100644 index 00000000..d624fcf2 --- /dev/null +++ b/backend/tests/test_ledger_issue.py @@ -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"] diff --git a/backend/tests/test_ledger_reconcile.py b/backend/tests/test_ledger_reconcile.py new file mode 100644 index 00000000..a14b5175 --- /dev/null +++ b/backend/tests/test_ledger_reconcile.py @@ -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() diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index f1e0d5cb..c161d675 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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: '正在选用口径…', + }, } /** diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index ddcb9fb1..6e2eeeb7 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -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) }) diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index d098ea01..c405fbb8 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -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 diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index e76b8b59..77af3970 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -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' } }])