Files
AgentLab/backend/tests/publication_samples.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

175 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""K07 发布依据与用途判定测试共用的帮手:建一份「已接管」的口径资产、放下验证记录与审阅、按入口发布。
资产的形状就是文档起草建出来的那种(tags=["doc_draft"],app/apps/versions.taken_over):一张源头口径卡 `card`、
一个取数节点 `fetch`(查虚构店铺库)、一张口径「周销售」。验证记录和审阅走 K05 的真实落库入口
(app/ledger/validation.append、app/ledger/review.decide),发布走真实的发布入口(HTTP 发布接口 / commit_publish),
不手写行、不伪造历史。
"""
from __future__ import annotations
import asyncio
import uuid
from typing import Any
from httpx import AsyncClient
from sqlalchemy import select
from app.core.artifact_store import graph_hash
from app.db.base import SessionLocal
from app.db.models import CaliberDraft, Run, Workflow, WorkflowPublication, WorkflowVersion
from app.ledger import review as ledger_review
from app.ledger import validation
from tests import ledger_samples as S
CARD = "card"
#: 人工确认过的独立期望(K05 的 expectation:来源 + 逐条给全卡指标)
EXPECTATION = {"source": "human_confirmed", "metrics": {"gmv": 2}, "note": "独立核算:十月两单"}
async def make_workflow(*, graph: dict | None = None, tags: list[str] | None = None, version: int = 1) -> dict:
"""建一份口径资产(工作流 + v1 快照 + 一份草稿行),还没发布。tags 缺省是文档起草的产物(已接管)。"""
graph = graph or S.caliber_graph()
async with SessionLocal() as session:
workflow = Workflow(name=f"周销售-{uuid.uuid4().hex[:6]}", graph=graph, status="draft",
tags=tags if tags is not None else ["doc_draft"], version=version)
session.add(workflow)
await session.flush()
session.add(WorkflowVersion(workflow_id=workflow.id, version=version, graph=graph,
graph_hash=graph_hash(graph), note="从业务文档分组生成"))
draft = CaliberDraft(title="周销售口径草稿", workflow_id=workflow.id, status="ready")
session.add(draft)
await session.commit()
return {"workflow_id": workflow.id, "draft_id": draft.id, "graph": graph, "version": version}
async def add_version(asset: dict, graph: dict, *, version: int, note: str = "改过") -> dict:
"""加一版(图上改了东西之后的保存结果),并把工作流当前版本推上去。"""
async with SessionLocal() as session:
workflow = await session.get(Workflow, asset["workflow_id"])
workflow.graph = graph
workflow.version = version
session.add(WorkflowVersion(workflow_id=workflow.id, version=version, graph=graph,
graph_hash=graph_hash(graph), note=note))
await session.commit()
return {**asset, "graph": graph, "version": version}
async def add_record(asset: dict, *, graph: dict | None = None, card_id: str = CARD, status: str = "matched",
metric_ids: tuple[str, ...] = ("gmv",), version: int | None = None) -> str:
"""按 K05 的落库入口追加一条验证记录。返回记录 id。"""
graph = graph or asset["graph"]
materials = validation.materials(graph, card_id, workflow_id=asset["workflow_id"])
record_id = await validation.append(
draft_id=asset["draft_id"], workflow_id=asset["workflow_id"], version=version or asset["version"],
card_id=card_id, metric_ids=list(metric_ids), basis=materials, inputs={"start": "2026-10-01"},
inputs_hash="inputs-1", trial_id=uuid.uuid4().hex, run_id="", actor="试跑人", status=status, problems=[],
expectation=EXPECTATION if status == "matched" else None,
actual={mid: {"value": 2, "status": "ok"} for mid in metric_ids})
assert record_id, "验证记录没落下"
return record_id
async def confirm(asset: dict, record_id: str, *, scope: list[str] | None = None, verified: bool = True,
decision: str = "confirmed") -> str:
"""按 K05 的审阅入口追加一条决定(主体、范围、所审依据)。返回决定 id。"""
async with SessionLocal() as session:
record = await validation.get_record(session, asset["draft_id"], record_id)
row = await ledger_review.decide(session, draft_id=asset["draft_id"], record=record, decision=decision,
actor="审核人", verified=verified, scope=scope, note="看过了")
await session.commit()
return row.id
async def publish(client: AsyncClient, asset: dict, *, version: int | None = None, level: str = "published") -> dict:
"""走 HTTP 发布接口(唯一的发布入口最终都到这里)。"""
body = {"level": level, "version": version or asset["version"]}
res = await client.post(f"/api/workflows/{asset['workflow_id']}/publish", json=body,
headers={"X-Actor": "%E5%B0%8F%E7%8E%8B"})
assert res.status_code == 200, res.text
return res.json()
async def events_of(workflow_id: str, version: int | None = None) -> list[WorkflowPublication]:
from app.apps import publications
async with SessionLocal() as session:
return await publications.events(session, workflow_id, version)
async def latest_event(workflow_id: str, version: int) -> WorkflowPublication:
from app.apps import publications
async with SessionLocal() as session:
row = await publications.latest(session, workflow_id, version)
assert row is not None, f"「{workflow_id}」v{version} 没有发布事件"
return row
async def persisted(workflow_id: str) -> Workflow:
async with SessionLocal() as session:
return await session.get(Workflow, workflow_id)
async def version_row(workflow_id: str, version: int) -> WorkflowVersion:
async with SessionLocal() as session:
return (await session.execute(select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.version == version))).scalar_one()
def report_model(monkeypatch) -> None:
"""假模型:图里那张报告节点(指令「写周销售」)引用口径卡的指标,别的照旧。正式运行才跑得完。"""
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 "这周的两单")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
async def launch_formal(asset: dict, *, version: int | None = None, actor: str = "发布人",
input_payload: dict[str, Any] | None = None, origin_kind: str | None = "launcher") -> Run:
"""从已发布的版本发起一次正式运行(engine/launch.launch_run:正式身份的唯一入口)。"""
from app.engine.launch import launch_run
async with SessionLocal() as session:
return await launch_run(session, workflow_id=asset["workflow_id"], run_class="formal",
version=version or asset["version"], actor=actor,
input_payload=input_payload or {"start": "2026-09-01", "end": "2026-09-08"},
origin_kind=origin_kind)
async def wait_run(run_id: str, timeout: float = 20.0) -> Run:
"""等一次运行跑到终态(测试收尾不留挂着的任务)。"""
for _ in range(int(timeout / 0.05)):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有结束:{row.status}")
def publication_of(run: Run) -> dict[str, Any]:
"""运行来源里记下的发布依据(K07:正式运行固定了哪一版、哪份清单、什么身份)。"""
origin = run.origin if isinstance(run.origin, dict) else {}
return origin.get("publication") or {}
async def issue_plan(session: Any, asset: dict, *, version: int | None = None) -> Any:
"""按口径出具这一次的打算(走 K07 那条预检 ledger/issue.check_target,builder 身份)。"""
from app.api.deps import Principal
from app.ledger import issue as ledger_issue
from app.ledger.labels import caliber_key
number = version or asset["version"]
workflow = await session.get(Workflow, asset["workflow_id"])
plan = ledger_issue.Plan(run_id="source-run", workflow_id=asset["workflow_id"], version=number,
caliber=caliber_key(asset["workflow_id"], number))
await ledger_issue.check_target(session, plan, workflow,
principal=Principal(username="boss", role="builder", mode="off", via="off"),
source_run="source-run")
return plan