Files
AgentLab/backend/app/api/workflows.py
T
ThearchyHelios fcdcc30d4e 可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复
一、可点击证据第二期

取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
  表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
  再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号

证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
  遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
  和子工作流同一套规则;子工作流节点也补上了升版处置的配置项

Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
  模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口

二、发布前自动修复

- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
  Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽

验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

502 lines
20 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.
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import graph_error
from app.api.runs import actor_of
from app.core.artifact_store import graph_hash
from app.db.base import get_session
from app.db.models import Run, Workflow, WorkflowVersion
from app.engine.governance import publish_issues
from app.engine.schema import GraphSpec, NodeType, validate_graph
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
_GONE = "这个工作流不存在,可能已经被删了"
_LEVELS = ("published", "governed")
_BAD_LEVEL = "发布档位只能选「已发布」或「受管」"
class WorkflowIn(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = ""
graph: dict[str, Any] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
class WorkflowPatch(BaseModel):
name: str | None = None
description: str | None = None
graph: dict[str, Any] | None = None
tags: list[str] | None = None
note: str = ""
class WorkflowOut(BaseModel):
id: str
name: str
description: str
graph: dict[str, Any]
tags: list[str]
version: int
is_template: bool
status: str = "draft"
published_version: int | None = None
published_by: str | None = None
created_at: Any = None
updated_at: Any = None
run_count: int = 0
model_config = {"from_attributes": True}
@router.get("", response_model=list[WorkflowOut])
async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[WorkflowOut]:
rows = list(
(await session.execute(select(Workflow).order_by(Workflow.updated_at.desc()))).scalars()
)
counts = dict(
(await session.execute(select(Run.workflow_id, func.count(Run.id)).group_by(Run.workflow_id))).all()
)
out: list[WorkflowOut] = []
for row in rows:
item = WorkflowOut.model_validate(row)
item.run_count = counts.get(row.id, 0)
out.append(item)
return out
@router.post("", response_model=WorkflowOut, status_code=201)
async def create_workflow(
payload: WorkflowIn, session: AsyncSession = Depends(get_session)
) -> Workflow:
workflow = Workflow(
name=payload.name,
description=payload.description,
graph=payload.graph,
tags=payload.tags,
)
session.add(workflow)
await session.flush()
session.add(
WorkflowVersion(workflow_id=workflow.id, version=1, graph=payload.graph,
graph_hash=graph_hash(payload.graph), note="初始版本")
)
await session.commit()
await session.refresh(workflow)
return workflow
@router.get("/{workflow_id}", response_model=WorkflowOut)
async def get_workflow(
workflow_id: str, session: AsyncSession = Depends(get_session)
) -> Workflow:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
return workflow
@router.patch("/{workflow_id}", response_model=WorkflowOut)
async def update_workflow(
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
) -> Workflow:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
if payload.name is not None:
workflow.name = payload.name
if payload.description is not None:
workflow.description = payload.description
if payload.tags is not None:
workflow.tags = payload.tags
if payload.graph is not None and payload.graph != workflow.graph:
# 图有实际变化才留快照,避免改个名字就堆一堆版本
workflow.graph = payload.graph
workflow.version += 1
session.add(
WorkflowVersion(
workflow_id=workflow.id,
version=workflow.version,
graph=payload.graph,
graph_hash=graph_hash(payload.graph),
note=payload.note,
)
)
# status 说的是**当前画布**的状态,不是"这个工作流曾经发布过"。
# 改完图还挂着 governed,界面上就会显示一张从未过闸的图是受管模板——
# 已发布的那一版仍由 published_version 指着,formal 运行不受影响。
if workflow.status in ("published", "governed"):
workflow.status = "draft"
await session.commit()
await session.refresh(workflow)
return workflow
@router.delete("/{workflow_id}", status_code=204)
async def delete_workflow(
workflow_id: str, session: AsyncSession = Depends(get_session)
) -> None:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
# 运行记录是 ON DELETE SET NULL:删掉工作流,已结束的运行留着、照样能核对封存;
# 还在跑的那次会失去归属,再也回不到它的画布,所以和删除运行记录一样先停再删
live = await session.scalar(
select(func.count()).select_from(Run).where(
Run.workflow_id == workflow_id, Run.status.in_(("queued", "running"))
)
)
if live:
raise HTTPException(409, f"这个工作流还有 {live} 次运行在进行,现在删除它们会失去归属。先停止运行,再删除")
await session.delete(workflow)
await session.commit()
@router.post("/{workflow_id}/duplicate", response_model=WorkflowOut, status_code=201)
async def duplicate_workflow(
workflow_id: str, session: AsyncSession = Depends(get_session)
) -> Workflow:
source = await session.get(Workflow, workflow_id)
if not source:
raise HTTPException(404, _GONE)
copy = Workflow(
name=f"{source.name} 副本",
description=source.description,
graph=source.graph,
tags=list(source.tags or []),
)
session.add(copy)
await session.commit()
await session.refresh(copy)
return copy
class VersionOut(BaseModel):
id: str
version: int
note: str
created_at: Any = None
#: 这一版按哪一档发布的(published / governed)。没发布过、或者是记级别之前发布的为 null
level: str | None = None
model_config = {"from_attributes": True}
@router.get("/{workflow_id}/versions", response_model=list[VersionOut])
async def list_versions(
workflow_id: str, session: AsyncSession = Depends(get_session)
) -> list[WorkflowVersion]:
rows = await session.execute(
select(WorkflowVersion)
.where(WorkflowVersion.workflow_id == workflow_id)
.order_by(WorkflowVersion.version.desc())
)
return list(rows.scalars())
class VersionDetailOut(VersionOut):
workflow_id: str
graph: dict[str, Any]
graph_hash: str | None = None
#: 这一版入口节点声明的字段。正式运行跑的是已发布的那一版,表单得照它来填——
#: 画布上改过入口字段时,拿当前画布的字段去跑已发布版本会传错参数
input_fields: list[dict[str, Any]] = Field(default_factory=list)
published: bool = False
@router.get("/{workflow_id}/versions/{version}", response_model=VersionDetailOut)
async def get_version(
workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
) -> VersionDetailOut:
"""取某一版的完整图。"""
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
snapshot = (
await session.execute(
select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow_id,
WorkflowVersion.version == version,
)
)
).scalar_one_or_none()
if not snapshot:
raise HTTPException(404, f"「{workflow.name}」没有 v{version} 这个版本")
graph = snapshot.graph or {}
fields: list[dict[str, Any]] = []
for node in graph.get("nodes") or []:
if node.get("type") == "input":
fields.extend(
f for f in ((node.get("data") or {}).get("config") or {}).get("fields") or []
if isinstance(f, dict)
)
return VersionDetailOut(
id=snapshot.id, version=snapshot.version, note=snapshot.note,
created_at=snapshot.created_at, workflow_id=workflow_id, graph=graph,
graph_hash=snapshot.graph_hash or graph_hash(graph), input_fields=fields,
published=workflow.published_version == snapshot.version, level=snapshot.level,
)
@router.post("/{workflow_id}/versions/{version}/restore", response_model=WorkflowOut)
async def restore_version(
workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
) -> Workflow:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
snapshot = (
await session.execute(
select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow_id,
WorkflowVersion.version == version,
)
)
).scalar_one_or_none()
if not snapshot:
raise HTTPException(404, f"没有 v{version} 这个版本")
workflow.graph = snapshot.graph
workflow.version += 1
session.add(
WorkflowVersion(
workflow_id=workflow.id,
version=workflow.version,
graph=snapshot.graph,
graph_hash=graph_hash(snapshot.graph),
note=f"回滚到 v{version}",
)
)
# 恢复旧版也是改图,和 PATCH 同一条规矩:旧图没过这次的闸,不能挂着受管 / 已发布
if workflow.status in ("published", "governed"):
workflow.status = "draft"
await session.commit()
await session.refresh(workflow)
return workflow
class PublishIn(BaseModel):
level: str = "published" # published | governed
version: int | None = None # 默认发布当前最新版本
@router.post("/{workflow_id}/publish")
async def publish_workflow(
workflow_id: str,
payload: PublishIn,
session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None),
) -> dict[str, Any]:
"""把某个版本立为正式版本。
formal 运行只能从这里发布过的版本发起。governed 级别额外过治理 lint:
错误会挡住发布——这就是"必经检查点"落地的地方。
返回 200 + ok/issues 而不是抛错,让前端能把问题列表渲染出来。
"""
if payload.level not in _LEVELS:
raise HTTPException(400, _BAD_LEVEL)
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
version = payload.version or workflow.version
snapshot = (
await session.execute(
select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow_id,
WorkflowVersion.version == version,
)
)
).scalar_one_or_none()
if not snapshot:
raise HTTPException(404, f"没有 v{version} 这个版本:画布上的改动还没保存,先保存一次再发布")
try:
spec = GraphSpec.model_validate(snapshot.graph)
except Exception as e: # noqa: BLE001
return {"ok": False, "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]}
issues = [i.model_dump() for i in publish_issues(spec, level=payload.level)]
if any(i["level"] == "error" for i in issues):
return {"ok": False, "level": payload.level, "version": version, "issues": issues}
if not snapshot.graph_hash:
snapshot.graph_hash = graph_hash(snapshot.graph)
# 级别记在这一版上:之后改画布(status 退回 draft),从这一版发起的正式运行照样按发布时的级别出具
snapshot.level = payload.level
workflow.status = payload.level
workflow.published_version = version
workflow.published_by = actor_of(x_actor)
await session.commit()
return {
"ok": True,
"level": payload.level,
"version": version,
"graph_hash": snapshot.graph_hash,
"published_by": workflow.published_by,
"issues": issues, # 剩下的都是警告
}
# --------------------------------------------------------------------------
# 发布前检查与自动修复:两个接口都只读,不改库、不发布
# --------------------------------------------------------------------------
class PublishCheckIn(BaseModel):
level: str = "published"
#: 画布上还没保存的图。不带就用库里当前的草稿
graph: dict[str, Any] | None = None
class AutofixIn(PublishCheckIn):
#: 要应用的修复 id(publish-check 给的 fixes[].id)
apply: list[str] = Field(default_factory=list)
#: choice 类修复的选择:{fix_id: 值},可以多选的给列表
choices: dict[str, Any] = Field(default_factory=dict)
#: 剩下的 error 交给 Copilot 试着修(结果同样只是预览)
assist: bool = False
provider: str | None = None
model: str | None = None
async def _draft(session: AsyncSession, workflow_id: str, payload: PublishCheckIn) -> dict[str, Any]:
if payload.level not in _LEVELS:
raise HTTPException(400, _BAD_LEVEL)
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
return payload.graph if payload.graph is not None else (workflow.graph or {})
async def _gate_context(session: AsyncSession, spec: GraphSpec) -> tuple[dict[str, Any], dict[str, Any]]:
"""修复要用、但门禁本身不查库的东西:子工作流当前的发布版本,钉在别处的口径卡有哪些指标。"""
versions: dict[str, Any] = {}
cards: dict[str, Any] = {}
for node in spec.nodes:
cfg = node.config
if node.type == NodeType.SUBGRAPH and cfg.get("workflow_id") and str(cfg["workflow_id"]) not in versions:
upstream = await session.get(Workflow, str(cfg["workflow_id"]))
versions[str(cfg["workflow_id"])] = upstream.published_version if upstream else None
elif node.type == NodeType.METRICS and isinstance(cfg.get("caliber_from"), dict):
ref = cfg["caliber_from"]
version = ref.get("workflow_version")
if not ref.get("workflow_id") or not str(version or "").isdigit():
continue
snapshot = (await session.execute(select(WorkflowVersion).where(
WorkflowVersion.workflow_id == str(ref["workflow_id"]),
WorkflowVersion.version == int(version)))).scalar_one_or_none()
card = next((n for n in ((snapshot.graph if snapshot else None) or {}).get("nodes") or []
if isinstance(n, dict) and n.get("id") == ref.get("node_id")), None)
if card is not None:
cards[node.id] = ((card.get("data") or {}).get("config") or {}).get("metrics") or []
return versions, cards
def _unreadable(level: str, e: Exception) -> dict[str, Any]:
return {"level": level, "ok": False, "fixes": [], "issues": [
{"level": "error", "node_id": None, "edge_id": None, "field": None, "code": None, "fix": None,
"message": f"工作流的结构读不懂:{graph_error(e)}"}]}
@router.post("/{workflow_id}/publish-check")
async def publish_check(
workflow_id: str, payload: PublishCheckIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""发布前检查:和真正发布同一套口径(validate + 门禁)列出问题,以及能怎么修。
只读:不改库、不发布。发布弹窗一打开就调它,不用等点了发布才知道被拦。
"""
from app.engine.autofix import check
graph = await _draft(session, workflow_id, payload)
try:
spec = GraphSpec.model_validate(graph)
except Exception as e: # noqa: BLE001
return _unreadable(payload.level, e)
versions, cards = await _gate_context(session, spec)
return check(spec, level=payload.level, versions=versions, cards=cards)
@router.post("/{workflow_id}/autofix")
async def autofix(
workflow_id: str, payload: AutofixIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""应用选中的修复,返回修复后的整张图——只是预览。
不自动保存、不自动发布:人看过逐项变更再确认,前端走现有的保存接口存草稿,再检查、再发布。
每条修复都复核过(降低要求的、错误没变少或者冒出新错误的丢弃并写明原因)。assist 为真时,
剩下的 error 交给 Copilot,同一道复核通过才采纳。
"""
from app.engine.autofix import apply_fixes, diff_changes
graph = await _draft(session, workflow_id, payload)
try:
spec = GraphSpec.model_validate(graph)
except Exception as e: # noqa: BLE001
bad = _unreadable(payload.level, e)
return {"graph": graph, "changes": [], "ops": [], "applied": [], "rejected": [
{"fix_id": fid, "reason": "工作流的结构读不懂,没法应用修复"} for fid in payload.apply],
"remaining": bad["issues"], "fixes": [], "assist": None, "ok": False}
versions, cards = await _gate_context(session, spec)
out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards)
out["assist"] = None
if payload.assist:
if out["ok"]:
out["assist"] = {"ok": True, "summary": "修复之后已经没有挡住发布的错误,没有再交给 Copilot",
"questions": []}
return out
from app.api.copilot import assist_publish_fix
helped = await assist_publish_fix(session, out["graph"], level=payload.level,
provider=payload.provider, model=payload.model)
out["assist"] = {"ok": helped["accepted"], "summary": helped["summary"], "questions": helped["questions"]}
if helped["accepted"]:
fixed = helped["graph"]
out["changes"] += diff_changes(out["graph"], fixed, fix_id="assist", label="Copilot 的修改")
out["ops"] += helped["ops"]
out["applied"].append("assist")
after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards)
out.update(graph=fixed, remaining=after["remaining"], fixes=after["fixes"], ok=after["ok"])
elif helped["reason"]:
out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]})
return out
class ValidateIn(BaseModel):
graph: dict[str, Any]
@router.post("/validate")
async def validate(payload: ValidateIn) -> dict[str, Any]:
"""画布上的实时校验。前端每次改动都会调它来标红。"""
try:
spec = GraphSpec.model_validate(payload.graph)
except Exception as e: # noqa: BLE001
return {"ok": False, "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]}
result = validate_graph(spec)
return result.model_dump()
@router.post("/variables")
async def variables(payload: ValidateIn) -> dict[str, Any]:
"""这张图里有哪些变量、谁产出、谁引用。
纯静态分析,不用跑图也不用有运行记录——用户在编排到一半时最需要它,
而那时候还没有任何一次运行可看。运行期的实际取值由前端从事件里补上。
"""
from app.engine.variables import analyze
try:
spec = GraphSpec.model_validate(payload.graph)
except Exception as e: # noqa: BLE001
return {"variables": [], "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]}
return analyze(spec).model_dump()