From e905426dfe13196e8e16289057902bd1a5f1c85e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 14 Sep 2026 10:27:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E9=97=A8=E7=A6=81=E3=80=81?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E8=80=85=E5=BD=92=E5=B1=9E=E3=80=81=E6=B2=BB?= =?UTF-8?q?=E7=90=86=E9=81=A5=E6=B5=8B=E4=B8=8E=E5=89=8D=E7=AB=AF=E6=94=AF?= =?UTF-8?q?=E6=92=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审批记录批准人(X-Actor 先落库再恢复执行)。设置页可配操作者署名, 随请求头发送——这是归属记录不是身份认证,界面上写明了区别。 治理遥测三件套,全部从既有事件流取材: - 工具白名单衰减报表:授了权但从未用过的工具摆上台面,放宽可被回收 - 探索问题聚类:同一问题反复出现(≥3 次)标记为晋升候选——模板生长 的触发器 - 轨迹→模板提取器:把探索性运行实际走过的路径确定性提取成草稿工作流 (剪掉未走的分支、输入参数化),人审发布后才成为模板——探索层的 价值在路径沉淀,不在单次答案 前端:口径卡节点与指标编辑器、出具契约配置、发布对话框(两级门禁、 问题列表)、试运行/正式运行双按钮、出具档位横幅(含缺数据声明与 未回指数字点名)、运行列表正式/探索徽标、运行详情版本与清单哈希、 提取模板按钮。 真模型验证闭环:周报模板 governed 发布 → formal 运行,Opus 5 正文 数字全部回指,但结尾'(约 100 字)'被校验器抓住降档——修 prompt 禁元话语而非加白名单,重发 v2 后正式档。8/8 模板回归通过。 --- README.md | 34 ++++++- backend/app/api/copilot.py | 96 ++++++++++++++++++ backend/app/api/governance.py | 151 ++++++++++++++++++++++++++++ backend/app/api/runs.py | 9 +- backend/app/main.py | 3 +- backend/app/seed.py | 6 +- frontend/src/api/client.ts | 25 +++++ frontend/src/canvas/FlowCanvas.tsx | 2 +- frontend/src/canvas/Inspector.tsx | 36 +++++++ frontend/src/canvas/NodeCard.tsx | 8 +- frontend/src/canvas/nodeDefs.ts | 36 ++++++- frontend/src/index.css | 1 + frontend/src/pages/RunsPage.tsx | 36 ++++++- frontend/src/pages/SettingsPage.tsx | 16 +++ frontend/src/pages/StudioPage.tsx | 87 +++++++++++++++- frontend/src/run/RunPanel.tsx | 108 +++++++++++++++++--- frontend/src/store/studio.ts | 22 ++++ frontend/src/types.ts | 9 +- 18 files changed, 655 insertions(+), 30 deletions(-) create mode 100644 backend/app/api/governance.py diff --git a/README.md b/README.md index ba16498f..d0761d74 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,38 @@ | **自然语言编排** | Copilot:一句话生成或改写整张图,自动排版 + 校验 | | **结构化成果** | JSON Schema 校验,不合格把错误喂回模型自动返工 | | **追踪与成本** | 每步事件全量落库,可回放;逐节点耗时、token、按目录价折算的美元成本 | +| **三档出具** | 口径卡(受控指标集)+ 出具契约:叙述里每个数字回指指标集,formal / degraded / withheld 三档判定,缺数据声明与口径版本随成果印出 | +| **正式 / 探索分级** | 正式运行只从已发布的不可变版本发起(按图哈希钉死),画布试跑自动标探索性;受管(governed)发布过治理 lint | +| **模板生长** | 探索性运行可一键提取为草稿模板(只保留实际走过的路径);探索问题聚类提示"该晋升为固定节了";工具白名单衰减报表 | + +## 受限动态编排:模板定骨架、agent 填关节 + +工业场景的大多数任务步骤是已知的(接单报价、周报、体检),让模型每次现场重新规划 +是在重复发明已知答案,而且不可复现就不可验收。AgentLab 的引擎是编译式的——运行时 +模型改不了图,它的自由被结构性地关在 agent 节点内部和 branch 决策里。在此之上: + +- **模板层**:正式运行只认已发布的不可变版本(`WorkflowVersion` + 图哈希)。 + 发布后改画布不影响它。governed 级发布要过治理 lint:禁 supervisor(全动态属于 + 探索层)、方法卡必须钉版本、agent 不得关闭全部审批、output 必须有出具契约。 +- **关节层**:agent 的工具白名单、分支的语义分类、缺数据时降档——有边界的自由。 + 白名单衰减报表(`/api/governance/tool-usage`)把"授了权没用过"的工具摆上台面。 +- **探索层**:画布试跑、临时图、追问,结果自动标探索性、不进正式归档。跑通的路径 + 用「提取模板」沉淀为草稿(只保留实际执行的节点,剪掉没走的分支),人审后发布。 + 探索问题聚类提示哪些追问该长成模板里的固定一节。 + +**出具链路**(模板⑧就是完整示范):取数(固定,快照落工件库)→ 口径卡(metrics +节点,所有算术在这里确定性发生,带口径名和版本)→ 叙述(LLM,只许引用指标清单) +→ 出具契约(从叙述抽出所有数字逐个回指指标集,支持千分位/百分数↔比率/万亿后缀/ +按书写精度的舍入容差;日期、枚举序号、引用标记不参与)。三档判定印在成果上。 +实测:Opus 5 写的正文数字全部回指,但结尾"(约 100 字)"里的 100 被抓住降档—— +修的是 prompt 而不是白名单,这正是校验器该有的行为。 + +钉住的方法卡出现新版本时,正式运行会被拒绝启动,直到模板声明升版处置: +`recompute`(回算历史)/ `dual`(双印)/ `incomparable`(标注不可比),没有默认值 +——口径变更最容易静默翻车的地方是"数看起来都对,只是和上周不可比"。 + +每次运行的完整证据在内容寻址的工件库里(`data/artifacts/`,sha256 即地址,取回 +复验哈希);运行终态的事件清单哈希存在 Run 上,事后改流水会对不上。 ## 节点类型 @@ -61,7 +93,7 @@ | 执行 | `tool` `code` | 直接调工具 / 沙箱里跑代码 | | 控制 | `branch` `loop` `subgraph` | 条件或语义分支 / 遍历与条件循环 / 嵌套工作流 | | 上下文 | `memory` `retrieve` `transform` | 长期记忆读写 / 知识检索 / 数据整形 | -| 把关 | `human` `validate` | 人工介入 / Schema 校验与自动返工 | +| 把关 | `human` `validate` `metrics` | 人工介入 / Schema 校验与自动返工 / 口径卡(受控指标集) | 节点之间用模板语法传数据: diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index ee661592..aa36cdc2 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -223,6 +223,102 @@ async def generate( ) +class FromRunIn(BaseModel): + run_id: str + name: str = "" + + +@router.post("/from-run") +async def extract_template( + payload: FromRunIn, session: AsyncSession = Depends(get_session) +) -> dict[str, Any]: + """轨迹 → 模板:把一次探索性运行实际走过的路径提取成草稿工作流。 + + 探索层的价值不在单次答案,而在"跑通的路径能沉淀为资产"。 + 提取是确定性的:只保留真正执行过的节点和它们之间的边,分支上 + 没走的岔路剪掉;输入值参数化成 input 字段默认值。产物落成 draft, + 人审、改名、发布之后才成为正式模板——提取器是模板的作者,不是发布者。 + """ + from sqlalchemy import select as _sel + + from app.api.copilot import auto_layout # 自引用仅为显式 + from app.db.models import Run, RunEvent, Workflow, WorkflowVersion + from app.core.artifact_store import graph_hash + + run = await session.get(Run, payload.run_id) + if not run: + raise HTTPException(404, "运行记录不存在") + if not run.graph.get("nodes"): + raise HTTPException(400, "这次运行没有保存图快照,无法提取") + + events = list( + ( + await session.execute( + _sel(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + ) + ).scalars() + ) + executed = {e.node_id for e in events if e.type == "node.started" and e.node_id} + taken = { + (e.node_id, str((e.data or {}).get("branch"))) + for e in events + if e.type == "edge.taken" and e.node_id + } + if not executed: + raise HTTPException(400, "事件流里没有节点执行记录") + + spec = GraphSpec.model_validate(run.graph) + kept_nodes = [n for n in spec.nodes if n.id in executed] + kept_ids = {n.id for n in kept_nodes} + kept_edges = [] + for edge in spec.edges: + if edge.source not in kept_ids or edge.target not in kept_ids: + continue + # 分支/循环节点:只保留实际走过的出口,没走的岔路不进模板 + source_node = spec.node_map().get(edge.source) + if source_node and source_node.type in (NodeType.BRANCH,) and edge.sourceHandle: + if (edge.source, edge.sourceHandle) not in taken: + continue + kept_edges.append(edge) + + # 输入参数化:实际输入值变成字段默认值 + for node in kept_nodes: + if node.type.value == "input": + node.data.config["fields"] = [ + {"name": key, "default": value if isinstance(value, (str, int, float)) else json.dumps(value, ensure_ascii=False)} + for key, value in (run.input or {}).items() + ] or node.data.config.get("fields", []) + + draft_spec = auto_layout( + GraphSpec(nodes=kept_nodes, edges=kept_edges, defaults=spec.defaults) + ) + graph = draft_spec.model_dump(mode="json") + workflow = Workflow( + name=payload.name or f"{run.workflow_name or '探索'}·提取模板", + description=f"从运行 {run.id[:8]} 的实际执行路径提取({len(kept_nodes)} 节点),待人审后发布", + graph=graph, + tags=["extracted"], + status="draft", + ) + 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(kept_nodes), + "edges": len(kept_edges), + "dropped_nodes": len(spec.nodes) - len(kept_nodes), + "source_run": run.id, + } + + class ExplainIn(BaseModel): graph: dict[str, Any] provider: str | None = None diff --git a/backend/app/api/governance.py b/backend/app/api/governance.py new file mode 100644 index 00000000..eef26da9 --- /dev/null +++ b/backend/app/api/governance.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.base import get_session +from app.db.models import Run, RunEvent, Workflow +from app.engine.schema import GraphSpec, NodeType + +router = APIRouter(prefix="/api/governance", tags=["governance"]) + + +@router.get("/tool-usage") +async def tool_usage( + workflow_id: str, + limit_runs: int = Query(default=100, le=500), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """工具白名单衰减报表。 + + 关节层最可能的退化方式不是模板太死,而是白名单为了长尾一个个放宽, + 半年后等于没有白名单。这份报表把"授了权但从来没用过"的工具摆到台面上, + 放宽才能被定期回收。 + """ + workflow = await session.get(Workflow, workflow_id) + if not workflow: + raise HTTPException(404, "工作流不存在") + spec = GraphSpec.model_validate(workflow.graph) + + # 各节点声明的白名单 + granted: dict[str, list[str]] = {} + for node in spec.nodes: + if node.type == NodeType.AGENT: + granted[node.id] = list(node.config.get("tools") or []) + elif node.type == NodeType.TOOL and node.config.get("tool"): + granted[node.id] = [node.config["tool"]] + elif node.type == NodeType.SUPERVISOR: + merged: list[str] = [] + for agent in node.config.get("agents") or []: + merged.extend(agent.get("tools") or []) + granted[node.id] = sorted(set(merged)) + + run_ids = [ + rid + for (rid,) in await session.execute( + select(Run.id) + .where(Run.workflow_id == workflow_id) + .order_by(Run.created_at.desc()) + .limit(limit_runs) + ) + ] + usage: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + if run_ids: + rows = await session.execute( + select(RunEvent.node_id, RunEvent.data).where( + RunEvent.run_id.in_(run_ids), RunEvent.type == "tool.start" + ) + ) + for node_id, data in rows: + tool = (data or {}).get("tool") + if node_id and tool: + # supervisor 内部子 agent 的 node_id 形如 "team:researcher" + usage[node_id.split(":")[0]][tool] += 1 + + report = [] + for node_id, tools in granted.items(): + used = usage.get(node_id, {}) + report.append( + { + "node_id": node_id, + "granted": tools, + "used": dict(used), + "unused": [t for t in tools if not used.get(t)], + "ungrated_but_used": [t for t in used if t not in tools], + } + ) + return { + "workflow_id": workflow_id, + "runs_considered": len(run_ids), + "nodes": report, + "hint": "unused 里躺了很多轮的工具是白名单衰减的信号,考虑摘除", + } + + +@router.get("/exploratory-clusters") +async def exploratory_clusters( + limit: int = Query(default=200, le=500), + threshold: float = Query(default=0.62, ge=0.3, le=0.95), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """探索性问题聚类:同一个问题被反复问,就该长成模板里的固定一节。 + + 这就是模板生长的触发器。用本地 embedding 做贪心聚类, + 出现 ≥3 次的簇标记为晋升候选。 + """ + from app.memory.embeddings import cosine, embed_texts + + rows = list( + await session.execute( + select(Run.id, Run.input, Run.workflow_name, Run.created_at) + .where(Run.run_class != "formal") + .order_by(Run.created_at.desc()) + .limit(limit) + ) + ) + items = [] + for rid, input_payload, wf_name, created in rows: + text = json.dumps(input_payload or {}, ensure_ascii=False, sort_keys=True) + if len(text) <= 4: # 空输入没有聚类意义 + continue + items.append({"run_id": rid, "text": text, "workflow": wf_name, + "at": created.isoformat() if created else None}) + if not items: + return {"clusters": [], "considered": 0} + + vectors = await embed_texts([i["text"] for i in items]) + clusters: list[dict[str, Any]] = [] + for i, item in enumerate(items): + placed = False + for cluster in clusters: + if cosine(vectors[i], vectors[cluster["seed"]]) >= threshold: + cluster["members"].append(item) + placed = True + break + if not placed: + clusters.append({"seed": i, "members": [item]}) + + out = [ + { + "count": len(c["members"]), + "representative": c["members"][0]["text"][:200], + "workflow": c["members"][0]["workflow"], + "run_ids": [m["run_id"] for m in c["members"]][:20], + "first_at": c["members"][-1]["at"], + "last_at": c["members"][0]["at"], + "promote_candidate": len(c["members"]) >= 3, + } + for c in clusters + if len(c["members"]) >= 2 + ] + out.sort(key=lambda c: c["count"], reverse=True) + return { + "clusters": out, + "considered": len(items), + "hint": "promote_candidate=true 的问题反复出现,考虑用「从运行提取模板」晋升为固定节", + } diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 2ab39856..b5354149 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -409,7 +409,10 @@ class DecideIn(BaseModel): @approvals_router.post("/{approval_id}/decide", response_model=RunOut) async def decide( - approval_id: str, payload: DecideIn, session: AsyncSession = Depends(get_session) + approval_id: str, + payload: DecideIn, + session: AsyncSession = Depends(get_session), + x_actor: str | None = Header(default=None), ) -> Run: approval = await session.get(Approval, approval_id) if not approval: @@ -417,6 +420,10 @@ async def decide( if approval.status != "pending": raise HTTPException(409, "该请求已经处理过了") + # 责任归属先落库再恢复执行——恢复失败也要留下"谁试图批的" + approval.resolved_by = (x_actor or "").strip() or None + await session.commit() + response: dict[str, Any] = {"approved": payload.approved, "note": payload.note} if payload.value is not None: response["value"] = payload.value diff --git a/backend/app/main.py b/backend/app/main.py index 251f5c19..66e3a428 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from app.api import artifacts, copilot, knowledge, runs, sandbox, settings as settings_api, tools, workflows +from app.api import artifacts, copilot, governance, knowledge, runs, sandbox, settings as settings_api, tools, workflows from app.core.config import settings from app.db.base import init_db from app.engine.runner import run_manager @@ -68,6 +68,7 @@ for router in ( sandbox.router, copilot.router, artifacts.router, + governance.router, ): app.include_router(router) diff --git a/backend/app/seed.py b/backend/app/seed.py index 0d567837..6bf17ebe 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -204,8 +204,10 @@ TEMPLATES: list[dict[str, Any]] = [ ]), _n("narrate", "llm", "叙述(关节层)", system="你是周报撰写人。只允许引用下面指标清单里出现的数值," - "一个清单外的数字都不要写;重点是挑哪些变化值得说、怎么归因。", - prompt="{{ nodes.caliber.text }}\n\n请为 {{ input.week }} 写一段 120 字以内的周报正文。", + "一个清单外的数字都不要写——包括字数统计、序号补充说明等元内容。" + "只输出周报正文本身,不要任何撰写说明。" + "你的自由度在于:挑哪些变化值得说、怎么归因。", + prompt="{{ nodes.caliber.text }}\n\n请为 {{ input.week }} 写一段简短的周报正文。", assign_to="report"), _n("done", "output", "出具", fields=[ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 345c030e..17bd9e78 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -11,11 +11,21 @@ export class ApiError extends Error { } } +function actorHeader(): Record { + try { + const actor = localStorage.getItem('agentlab_actor') + return actor ? { 'X-Actor': actor } : {} + } catch { + return {} + } +} + async function request(path: string, init?: RequestInit): Promise { const res = await fetch(BASE + path, { ...init, headers: { ...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...actorHeader(), ...init?.headers, }, }) @@ -59,6 +69,9 @@ export const api = { duplicate: (id: string) => post(`/workflows/${id}/duplicate`), versions: (id: string) => get(`/workflows/${id}/versions`), restore: (id: string, v: number) => post(`/workflows/${id}/versions/${v}/restore`), + publish: (id: string, level: 'published' | 'governed', version?: number) => + post<{ ok: boolean; level?: string; version?: number; issues: any[] }>( + `/workflows/${id}/publish`, { level, version }), validate: (graph: GraphSpec) => post<{ ok: boolean; issues: ValidationIssue[] }>('/workflows/validate', { graph }), }, @@ -75,7 +88,9 @@ export const api = { start: (body: { workflow_id?: string; graph?: GraphSpec; input?: Record memory_scope?: string; collection?: string + run_class?: 'formal' | 'exploratory'; version?: number }) => post('/runs', body), + artifacts: (id: string) => get(`/runs/${id}/artifacts`), cancel: (id: string) => post<{ ok: boolean }>(`/runs/${id}/cancel`), resume: (id: string, response: any) => post(`/runs/${id}/resume`, { response }), events: (id: string, after = 0) => get(`/runs/${id}/events?after=${after}`), @@ -167,6 +182,14 @@ export const api = { remove: (id: string) => del(`/skills/${id}`), }, + // ---- 治理 ---- + governance: { + toolUsage: (workflowId: string) => + get(`/governance/tool-usage?workflow_id=${workflowId}`), + clusters: () => get('/governance/exploratory-clusters'), + }, + artifact: (id: string) => get<{ id: string; content: any }>(`/artifacts/${id}`), + // ---- Copilot ---- copilot: { generate: (body: { instruction: string; base_graph?: GraphSpec | null }) => @@ -174,6 +197,8 @@ export const api = { '/copilot/generate', body), explain: (graph: GraphSpec) => post<{ explanation: string }>('/copilot/explain', { graph }), layout: (graph: GraphSpec) => post('/copilot/layout', { graph }), + fromRun: (runId: string, name?: string) => + post('/copilot/from-run', { run_id: runId, name }), }, } diff --git a/frontend/src/canvas/FlowCanvas.tsx b/frontend/src/canvas/FlowCanvas.tsx index 0ec362df..96519ec9 100644 --- a/frontend/src/canvas/FlowCanvas.tsx +++ b/frontend/src/canvas/FlowCanvas.tsx @@ -13,7 +13,7 @@ const NODE_COLOR: Record = { input: '#3fb950', output: '#a371f7', llm: '#4f8cff', agent: '#6366f1', supervisor: '#db61a2', tool: '#f0883e', code: '#2dd4bf', branch: '#d29922', loop: '#ec4899', human: '#f85149', validate: '#84cc16', memory: '#14b8a6', - retrieve: '#0ea5e9', transform: '#8b949e', subgraph: '#bc8cff', + retrieve: '#0ea5e9', transform: '#8b949e', subgraph: '#bc8cff', metrics: '#34d399', } function CanvasInner() { diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 66d1ab18..c0df3c72 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -263,6 +263,9 @@ function FieldInput({ field, value, onChange }: { case 'cases': return + case 'metricsList': + return + case 'agents': return @@ -418,6 +421,39 @@ function CaseList({ value, onChange }: { value: any[]; onChange: (v: any[]) => v ) } +function MetricList({ value, onChange }: { value: any[]; onChange: (v: any[]) => void }) { + const update = (i: number, patch: any) => + onChange(value.map((m, idx) => (idx === i ? { ...m, ...patch } : m))) + return ( +
+ {value.map((m, i) => ( + onChange(value.filter((_, idx) => idx !== i))}> +
+ update(i, { id: e.target.value })} /> + update(i, { name: e.target.value })} /> + update(i, { unit: e.target.value })} /> +
+ update(i, { expression: e.target.value })} /> +
+ ))} + +
+ 所有算术都发生在这里(确定性、可复算)。叙述节点只许引用这份清单里的数, + 出具时会逐个数字回指校验。 +
+
+ ) +} + function AgentList({ value, onChange }: { value: any[]; onChange: (v: any[]) => void }) { const catalog = useCatalog() const update = (i: number, patch: any) => diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 3124c02a..b3d5ba96 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -12,8 +12,10 @@ function summarize(type: NodeType, config: Record): string { switch (type) { case 'input': return (config.fields ?? []).map((f: any) => f.name).filter(Boolean).join(' · ') || '未定义输入' - case 'output': - return (config.fields ?? []).map((f: any) => f.name).filter(Boolean).join(' · ') || '未定义成果' + case 'output': { + const names = (config.fields ?? []).map((f: any) => f.name).filter(Boolean).join(' · ') + return (config.contract ? '⚖ 出具契约 · ' : '') + (names || '未定义成果') + } case 'llm': return first(config.prompt, config.system) || '未填提示' case 'agent': { @@ -40,6 +42,8 @@ function summarize(type: NodeType, config: Record): string { return first(config.title) || '等待人工' case 'validate': return 'JSON Schema 校验' + (config.repair_with_llm ? ' · 失败自动返工' : '') + case 'metrics': + return `${config.caliber || '口径'}@${config.caliber_version || 'v1'} · ${(config.metrics ?? []).length} 个指标` case 'transform': return first(config.expression, config.template) || '未配置' case 'subgraph': diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index c21b4d3c..dcdff1aa 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -1,13 +1,13 @@ import { Bot, Braces, Brain, CheckCircle2, Code2, Database, FileInput, FileOutput, - GitBranch, Hand, Repeat, Search, Shuffle, Users, Wrench, + Gauge, GitBranch, Hand, Repeat, Search, Shuffle, Users, Wrench, } from 'lucide-react' import type { NodeType } from '../types' export type FieldType = | 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch' | 'json' | 'model' | 'tools' | 'skills' | 'collection' - | 'ioFields' | 'cases' | 'agents' + | 'ioFields' | 'cases' | 'agents' | 'metricsList' export interface FieldDef { key: string @@ -92,10 +92,18 @@ export const NODE_DEFS: Record = { defaults: { fields: [{ name: 'question', required: true }] }, }, output: { - type: 'output', label: '成果', category: '起止', icon: FileOutput, - description: '收集最终结构化成果', + type: 'output', label: '成果 / 出具', category: '起止', icon: FileOutput, + description: '收集结构化成果;配出具契约后升级为三档出具', hasTarget: true, sources: [], - fields: [{ key: 'fields', label: '成果字段', type: 'ioFields' }], + fields: [ + { key: 'fields', label: '成果字段', type: 'ioFields' }, + { + key: 'contract', label: '出具契约', type: 'json', advanced: true, + help: '{"metrics_from":["口径卡节点id"],"narrative":"{{ vars.report }}",' + + '"required":[…],"expected":[…],"allow_numbers":[…],"strict":false}。' + + '配了之后叙述里的每个数字必须能回指指标集,否则降档或不予出具', + }, + ], defaults: { fields: [{ name: '结果', value: '{{ last_message }}' }] }, }, llm: { @@ -364,6 +372,24 @@ export const NODE_DEFS: Record = { schema: { type: 'object', properties: {}, required: [] }, }, }, + metrics: { + type: 'metrics', label: '口径卡', category: '把关', icon: Gauge, + description: '受控指标集:所有算术在这里发生,叙述层只能引用', + hasTarget: true, sources: [{ id: 'out', label: '' }], + fields: [ + { key: 'caliber', label: '口径名称', type: 'text', placeholder: '周报口径' }, + { key: 'caliber_version', label: '口径版本', type: 'text', placeholder: 'v1' }, + { key: 'metrics', label: '指标定义', type: 'metricsList' }, + { + key: 'assign_to', label: '结果存为变量', type: 'text', + help: '叙述节点用 {{ nodes.节点id.text }} 引用指标清单', + }, + ], + defaults: { + caliber: '', caliber_version: 'v1', + metrics: [{ id: 'total', name: '', unit: '', expression: '' }], + }, + }, } export const NODE_CATEGORIES = ['起止', '模型', '执行', '控制', '上下文', '把关'] diff --git a/frontend/src/index.css b/frontend/src/index.css index 2d555b04..350deb73 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -117,6 +117,7 @@ pre, .nt-retrieve { --nt: #0ea5e9; } .nt-transform { --nt: #8b949e; } .nt-subgraph { --nt: #bc8cff; } +.nt-metrics { --nt: #34d399; } /* -------------------------------------------------------------------------- React Flow 覆盖 diff --git a/frontend/src/pages/RunsPage.tsx b/frontend/src/pages/RunsPage.tsx index b77002de..98994875 100644 --- a/frontend/src/pages/RunsPage.tsx +++ b/frontend/src/pages/RunsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { History, RefreshCw, Trash2 } from 'lucide-react' +import { GitFork, History, RefreshCw, Trash2 } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { formatMs } from '../canvas/NodeCard' @@ -53,6 +53,12 @@ export function RunsPage() { >
{run.workflow_name || '临时图'} + {run.run_class === 'formal' && ( + 正式 + )} + {(run.output as any)?._issuance?.tier === 'withheld' && ( + 不予出具 + )}
@@ -72,10 +78,34 @@ export function RunsPage() {
-
{selected.workflow_name || '临时图'}
-
{selected.id}
+
+ {selected.workflow_name || '临时图'} + {selected.run_class === 'formal' + ? 正式 v{selected.version} + : 探索性} +
+
+ {selected.id} + {selected.version_hash && ` · 版本 ${selected.version_hash.slice(0, 10)}`} + {selected.manifest_hash && ` · 清单 ${selected.manifest_hash.slice(0, 10)}`} + {selected.started_by && ` · by ${selected.started_by}`} +
+ {selected.run_class !== 'formal' && ( + + )}
+
+
操作者署名
+ + { try { return localStorage.getItem('agentlab_actor') ?? '' } catch { return '' } })()} + placeholder="例如 yilun" + onChange={(e) => { + try { localStorage.setItem('agentlab_actor', e.target.value.trim()) } catch { /* noop */ } + }} + /> +
+ 本地署名,随请求头 X-Actor 发送。这是归属记录不是身份认证——多人环境需要真正的登录体系。 +
+
+
运行默认值
diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx index 49c9da33..0a1895c0 100644 --- a/frontend/src/pages/StudioPage.tsx +++ b/frontend/src/pages/StudioPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { - AlertTriangle, Check, ChevronDown, Copy, LayoutGrid, Plus, Save, Sparkles, Trash2, Wand2, + AlertTriangle, Check, ChevronDown, Copy, LayoutGrid, Plus, Save, ShieldCheck, Sparkles, Trash2, Wand2, } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' @@ -27,6 +27,7 @@ export function StudioPage() { const [picker, setPicker] = useState(false) const [copilot, setCopilot] = useState(false) const [saving, setSaving] = useState(false) + const [publishing, setPublishing] = useState(false) // 首次进来自动打开第一张图 useEffect(() => { @@ -99,6 +100,15 @@ export function StudioPage() { + {workflow?.status === 'governed' && ( + + 受管 v{workflow.published_version} + + )} + {workflow?.status === 'published' && ( + 已发布 v{workflow.published_version} + )} + {(!workflow?.status || workflow.status === 'draft') && 草稿} {dirty && 未保存} {errorCount > 0 && ( @@ -118,6 +128,10 @@ export function StudioPage() { Copilot + @@ -145,6 +159,10 @@ export function StudioPage() { setPicker(false)} /> setCopilot(false)} /> + {publishing && workflow && ( + setPublishing(false)} + onDone={() => { setPublishing(false); void refresh() }} /> + )}
) } @@ -259,6 +277,73 @@ function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () => void // ------------------------------------------------------------------------- +function PublishModal({ workflow, onClose, onDone }: { + workflow: Workflow; onClose: () => void; onDone: () => void +}) { + const toast = useToast() + const load = useStudio((s) => s.load) + const [level, setLevel] = useState<'published' | 'governed'>('published') + const [busy, setBusy] = useState(false) + const [issues, setIssues] = useState(null) + + const publish = async () => { + setBusy(true) + setIssues(null) + try { + const res = await api.workflows.publish(workflow.id, level) + setIssues(res.issues ?? []) + if (res.ok) { + toast(`已发布 v${res.version}(${level === 'governed' ? '受管' : '正式'})`, 'ok') + const fresh = await api.workflows.get(workflow.id) + load(fresh) + onDone() + } else { + toast('发布被门禁拦下,看问题列表', 'error') + } + } catch (e: any) { + toast(e.message ?? '发布失败', 'error') + } finally { + setBusy(false) + } + } + + return ( + + + + }> +
+ + +
+
+ 发布把当前版本立为不可变的正式版本。之后画布上继续改不影响它——正式运行永远执行发布时的快照。 +
+ {issues && !!issues.length && ( +
+ {issues.map((issue, i) => ( +
+ {issue.level === 'error' ? '✕' : '!'} {issue.message} +
+ ))} +
+ )} +
+ ) +} + function CopilotModal({ open, onClose }: { open: boolean; onClose: () => void }) { const toast = useToast() const nodes = useStudio((s) => s.nodes) diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx index 95e82a71..5ccbf5d9 100644 --- a/frontend/src/run/RunPanel.tsx +++ b/frontend/src/run/RunPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { - AlertTriangle, Ban, Check, ChevronRight, CircleDot, Hand, Play, Square, Wrench, + AlertTriangle, Ban, Check, ChevronRight, CircleDot, Hand, Play, ShieldCheck, Square, Wrench, } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' @@ -41,6 +41,9 @@ export function RunPanel() { {run && (
#{run.id.slice(0, 8)} + {run.run_class === 'formal' + ? 正式 v{run.version} + : 探索} {streaming && ● 实时} {!!run.usage?.total_tokens && {run.usage.total_tokens} tok} @@ -60,7 +63,11 @@ function RunLauncher() { const run = useStudio((s) => s.run) const streaming = useStudio((s) => s.streaming) const issues = useStudio((s) => s.issues) - const { startRun, stopRun } = useStudio() + const workflow = useStudio((s) => s.workflow) + const dirty = useStudio((s) => s.dirty) + const { startRun, startFormalRun, stopRun } = useStudio() + const canFormal = !!workflow && (workflow.status === 'published' || workflow.status === 'governed') + && !!workflow.published_version const toast = useToast() const [values, setValues] = useState>({}) const [busy, setBusy] = useState(false) @@ -73,7 +80,7 @@ function RunLauncher() { const errors = issues.filter((i) => i.level === 'error') - const launch = async () => { + const launch = async (formal = false) => { setBusy(true) try { const payload: Record = {} @@ -87,7 +94,8 @@ function RunLauncher() { payload[field.name] = raw } } - await startRun(payload) + if (formal) await startFormalRun(payload) + else await startRun(payload) } catch (e: any) { toast(e.message ?? '启动失败', 'error') } finally { @@ -132,15 +140,32 @@ function RunLauncher() { 停止 ) : ( - + <> + + {canFormal && ( + + )} + )}
+ {canFormal && dirty && ( +
画布有未保存改动;正式运行永远执行已发布的不可变版本
+ )}
) } @@ -345,6 +370,8 @@ const EVENT_META: Record = { 'human.requested': { text: '请求人工', tone: 'warn' }, 'human.resolved': { text: '人工已回复', tone: 'ok' }, 'log': { text: '日志', tone: 'dim' }, + 'issuance': { text: '出具判定', tone: 'warn' }, + 'caliber.upgrade': { text: '口径升版', tone: 'warn' }, } function describe(event: RunEvent): string { @@ -372,6 +399,15 @@ function describe(event: RunEvent): string { return String(d.message ?? '') case 'human.requested': return String(d.title ?? d.tool ?? '') + case 'issuance': { + const tier = { formal: '正式出具', degraded: '降档出具', withheld: '不予出具' }[d.tier as string] ?? d.tier + const parts = [tier] + if (d.missing_required?.length) parts.push(`缺必需指标 ${d.missing_required.join(',')}`) + if (d.unmatched) parts.push(`${d.unmatched} 个数字无法回指`) + return parts.join(' · ') + } + case 'caliber.upgrade': + return `方法卡 v${d.pinned} → v${d.latest} 已有新版,处置:${d.policy_label ?? d.policy}` case 'node.finished': { if (!d.preview) return '' const text = typeof d.preview === 'string' ? d.preview : JSON.stringify(d.preview) @@ -410,9 +446,11 @@ function OutputView() { return } + const issuance = (output as any)._issuance return (
- {Object.entries(output).map(([key, value]) => ( + {issuance && } + {Object.entries(output).filter(([k]) => k !== '_issuance').map(([key, value]) => (
{key}
@@ -424,6 +462,52 @@ function OutputView() { ) } +const TIER_META: Record = { + formal: { label: '正式出具', color: 'var(--ok)', hint: '指标齐全,叙述中所有数字均可回指口径卡' }, + degraded: { label: '降档出具', color: 'var(--warn)', hint: '存在缺口,结论请对照下方声明使用' }, + withheld: { label: '不予出具', color: 'var(--err)', hint: '必需指标缺失或数字无法溯源,本期结论不作数' }, +} + +function IssuanceBanner({ issuance, runClass }: { issuance: any; runClass?: string }) { + const meta = TIER_META[issuance.tier] ?? TIER_META.degraded + return ( +
+
+ + ⚖ {meta.label} + + {runClass === 'exploratory' && ( + 探索性运行 · 不进正式归档 + )} + + 回指 {issuance.matched_numbers ?? 0} 个数字 / 核对 {issuance.metrics_checked ?? 0} 个指标 + +
+
{meta.hint}
+ {(issuance.calibers ?? []).map((c: any) => ( +
+ 口径:{c.caliber} @ {c.version} +
+ ))} + {!!issuance.missing_required?.length && ( +
+ 缺必需指标:{issuance.missing_required.join('、')} +
+ )} + {!!issuance.missing_expected?.length && ( +
+ 缺数据声明:{issuance.missing_expected.join('、')} 本期缺失 +
+ )} + {!!issuance.unmatched_numbers?.length && ( +
+ 无法回指的数字:{issuance.unmatched_numbers.map((u: any) => u.token).join('、')} +
+ )} +
+ ) +} + function RawEvents() { const events = useStudio((s) => s.events) if (!events.length) return diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index ff0332de..fbc14451 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -93,6 +93,7 @@ interface StudioState { validate: () => Promise startRun: (input: Record) => Promise + startFormalRun: (input: Record) => Promise attachRun: (runId: string) => Promise stopRun: () => Promise clearRun: () => void @@ -251,6 +252,27 @@ export const useStudio = create((set, get) => ({ } }, + startFormalRun: async (input) => { + const { workflow } = get() + if (!workflow) return null + get().unsubscribe?.() + set({ events: [], runtime: {}, activeEdges: [], run: null }) + try { + // 正式运行不传 graph:后端只认已发布的不可变版本 + const run = await api.runs.start({ + workflow_id: workflow.id, + run_class: 'formal', + input, + }) + set({ run, streaming: true }) + await get().attachRun(run.id) + return run + } catch (e) { + set({ streaming: false }) + throw e + } + }, + attachRun: async (runId) => { get().unsubscribe?.() const stop = streamRun( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c15c1efa..008ece72 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,7 +1,7 @@ export type NodeType = | 'input' | 'output' | 'llm' | 'agent' | 'supervisor' | 'tool' | 'code' | 'branch' | 'loop' | 'subgraph' | 'memory' | 'retrieve' | 'transform' - | 'human' | 'validate' + | 'human' | 'validate' | 'metrics' export interface GraphNode { id: string @@ -34,6 +34,8 @@ export interface Workflow { tags: string[] version: number is_template: boolean + status?: 'draft' | 'published' | 'governed' + published_version?: number | null run_count?: number created_at?: string updated_at?: string @@ -51,6 +53,11 @@ export interface Run { output: Record error: string | null usage: Record + run_class?: 'formal' | 'exploratory' + version?: number | null + version_hash?: string | null + manifest_hash?: string | null + started_by?: string | null created_at?: string started_at?: string finished_at?: string