mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:21:58 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
145 lines
8.0 KiB
Python
145 lines
8.0 KiB
Python
"""从工作流版本确定性派生应用(A8):不靠模型,助手失败了也有可用的应用(P3)。
|
|
|
|
入口字段 → 入口表单;人工节点 → 待办;图里引用到的岗位(派给谁、超时升级给谁、动作确认派给谁,tree.slot_refs)→
|
|
岗位,留空等人绑定;口径卡 → 指标块;报告 → 报告块;取数节点 → 表格;写入的调用工具节点 → 动作确认待办 + 动作状态。
|
|
|
|
看板(use=view)的首页看「最近封存运行」,视图优先绑定已有的定时任务(同一工作流、正式运行、启用着的):数从定时任务的
|
|
封存运行来,打开页面不发起运行。流程(use=process)的首页只放发起和我发起的:每件事的结果在它自己的实例页看,首页没有
|
|
「最近一次」可看——派生出来只会挂一块永远空着的印章。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.apps.forms import input_schema
|
|
from app.apps.spec import APP_FORMAT, parse_spec
|
|
from app.apps.tree import input_fields, node_map, slot_refs
|
|
|
|
PIN = "main"
|
|
VIEW = "latest"
|
|
INSTANCE_VIEW = "inst"
|
|
ENTRY = "start"
|
|
RECONCILER = "reconciler"
|
|
|
|
|
|
class DeriveRefused(Exception):
|
|
def __init__(self, status_code: int, message: str) -> None:
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.message = message
|
|
|
|
|
|
def _safe(text: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9_-]", "_", text)
|
|
|
|
|
|
async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, name: str | None = None) -> dict[str, Any]:
|
|
"""返回一份 agentlab.app/1 spec(未发布的草稿)。"""
|
|
from app.apps.pins import PinState, write_nodes
|
|
from app.apps.versions import version_row
|
|
from app.db.models import Schedule, Workflow
|
|
|
|
workflow = await session.get(Workflow, workflow_id)
|
|
if workflow is None:
|
|
raise DeriveRefused(404, "工作流不存在,可能已被删除")
|
|
row = await version_row(session, workflow_id, version)
|
|
if row is None:
|
|
raise DeriveRefused(404, f"「{workflow.name}」不存在 v{version} 版本")
|
|
state = PinState(alias=PIN, pin={"workflow_id": workflow_id, "version": version}, workflow=workflow, row=row)
|
|
graph = row.graph or {}
|
|
nodes = node_map(graph)
|
|
fields = input_fields(graph)
|
|
humans = {nid: n for nid, n in nodes.items() if n["type"] == "human"}
|
|
# 写外部系统的调用工具节点:和运行时同一个认法(pins.write_nodes),只有它们开发送确认卡、结果未知时要人核实
|
|
caps = await write_nodes(session, graph)
|
|
writes = {nid: nodes[nid] for nid in caps}
|
|
lookups = all(getattr(cap, "lookup", None) for cap in caps.values())
|
|
process = bool(humans or writes)
|
|
spec: dict[str, Any] = {
|
|
"format": APP_FORMAT, "name": name or workflow.name, "description": workflow.description or "",
|
|
"icon": "app",
|
|
"pins": [{"alias": PIN, "workflow_id": workflow_id, "version": version, "graph_hash": state.hash,
|
|
"level": state.level, "use": "process" if process else "view"}],
|
|
"slots": {}, "entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [],
|
|
}
|
|
for ref in slot_refs(graph, writes):
|
|
spec["slots"].setdefault(ref["slot"], {"label": ref["slot"]})
|
|
for nid, node in humans.items():
|
|
spec["task_ui"][nid] = {"title": node["title"]}
|
|
if writes and not lookups:
|
|
spec["slots"][RECONCILER] = {"label": "动作核实"}
|
|
if fields:
|
|
spec["forms"][ENTRY] = input_schema(fields)
|
|
names = list(spec["forms"][ENTRY]["properties"])
|
|
first = next((n for n in names if n in spec["forms"][ENTRY]["required"]), names[0] if names else None)
|
|
spec["entries"].append({
|
|
"id": ENTRY, "label": f"发起{workflow.name}" if process else "按条件运行", "pin": PIN,
|
|
"form": {"$ref": f"#/forms/{ENTRY}"}, "map": {n: f"form.{n}" for n in names},
|
|
**({"instance_title": {"pattern": f"{workflow.name}·{{{first}}}", "fields": [first]}}
|
|
if first and re.fullmatch(r"[A-Za-z0-9_㐀-鿿]+", first) else {}),
|
|
})
|
|
if not process:
|
|
schedule = (await session.execute(select(Schedule).where(
|
|
Schedule.workflow_id == workflow_id, Schedule.run_class == "formal", Schedule.enabled.is_(True))
|
|
.order_by(Schedule.created_at))).scalars().first()
|
|
spec["views"][VIEW] = {"source": {"kind": "latest_sealed", "pin": PIN,
|
|
"from": f"schedule:{schedule.id}" if schedule else "formal"}}
|
|
if spec["entries"]:
|
|
spec["views"][INSTANCE_VIEW] = {"source": {"kind": "instance"}}
|
|
spec["pages"].append({"id": "home", "title": workflow.name,
|
|
"body": ([] if process else _results(nodes, VIEW, "home")) + _entries(spec)})
|
|
if spec["entries"]:
|
|
spec["pages"].append({"id": "instance", "title": "实例", "body": [
|
|
{"id": "inst_header", "type": "instance_header"},
|
|
{"id": "inst_timeline", "type": "instance_timeline"},
|
|
*[c for nid in humans for c in (
|
|
{"id": f"task_{_safe(nid)}", "type": "task_form", "bind": {"task": nid}},
|
|
{"id": f"signoffs_{_safe(nid)}", "type": "signoffs", "bind": {"task": nid}})],
|
|
*[{"id": f"action_{_safe(nid)}", "type": "action_status", "props": {"label": node["title"][:200]},
|
|
"bind": {"action": nid}} for nid, node in writes.items()],
|
|
*_results(nodes, INSTANCE_VIEW, "inst"),
|
|
]})
|
|
# 形状对不上是派生规则的错,不是用户的:这里直接让它报出来
|
|
return parse_spec(spec).model_dump(mode="json", by_alias=True)
|
|
|
|
|
|
def _results(nodes: dict[str, dict[str, Any]], view: str, prefix: str) -> list[dict[str, Any]]:
|
|
"""看结果的那几块:印章、口径卡的指标(分栏)、报告、取数节点的表格。"""
|
|
body: list[dict[str, Any]] = [{"id": f"{prefix}_stamp", "type": "stamp", "bind": {"view": view}}]
|
|
for nid, node in nodes.items():
|
|
if node["type"] != "metrics":
|
|
continue
|
|
metrics = [m for m in node["config"].get("metrics") or [] if isinstance(m, dict) and m.get("id")]
|
|
if not metrics:
|
|
continue
|
|
body.append({"id": f"{prefix}_kpis_{_safe(nid)}", "type": "section", "props": {"title": node["title"][:200]},
|
|
"children": [{"id": f"{prefix}_cols_{_safe(nid)}", "type": "columns",
|
|
"props": {"count": min(len(metrics), 4)}, "children": [
|
|
{"id": f"{prefix}_m_{_safe(nid)}_{_safe(str(m['id']))}", "type": "metric",
|
|
"props": {"label": str(m.get("name") or m["id"])[:200],
|
|
**({"unit": str(m["unit"])[:200]} if m.get("unit") else {})},
|
|
"bind": {"view": view, "metric": f"{nid}.{m['id']}"}}
|
|
for m in metrics]}]})
|
|
for nid, node in nodes.items():
|
|
if node["type"] == "report":
|
|
body.append({"id": f"{prefix}_report_{_safe(nid)}", "type": "report", "props": {"title": node["title"][:200]},
|
|
"bind": {"view": view, "report": nid}})
|
|
for nid, node in nodes.items():
|
|
if node["type"] == "tool" and str(node["config"].get("tool") or "").startswith("db_query__"):
|
|
body.append({"id": f"{prefix}_table_{_safe(nid)}", "type": "table", "props": {"title": node["title"][:200]},
|
|
"bind": {"view": view, "query": {"node": nid}}})
|
|
return body
|
|
|
|
|
|
def _entries(spec: dict[str, Any]) -> list[dict[str, Any]]:
|
|
out: list[dict[str, Any]] = []
|
|
for entry in spec["entries"]:
|
|
out.append({"id": f"btn_{_safe(entry['id'])}", "type": "entry_button", "props": {"entry": entry["id"]}})
|
|
out.append({"id": f"list_{_safe(entry['id'])}", "type": "instance_list",
|
|
"props": {"entry": entry["id"], "who": "mine"}})
|
|
return out
|