feat(server): add workbench API, DSH planner worker and RAG pipeline
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"""APIs for DSH Planner workflow templates and immutable plan versions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from . import execution_config
|
||||
from . import planner
|
||||
from . import task_queue
|
||||
from .access import actor_fields, can_access, request_user
|
||||
from .db import engine as db_engine, get_session
|
||||
from .models import (
|
||||
AuditLog,
|
||||
Campaign,
|
||||
CampaignPlanVersion,
|
||||
TaskQueueItem,
|
||||
WorkflowTemplate,
|
||||
now,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _client_template_config(body: dict) -> dict:
|
||||
config = body.get("config") or {}
|
||||
if not isinstance(config, dict):
|
||||
raise HTTPException(422, "工作流模板配置必须是对象")
|
||||
merged = dict(planner.default_template_config())
|
||||
merged.update(config)
|
||||
max_segments = int(merged.get("max_segments") or 80)
|
||||
if max_segments < 1 or max_segments > 200:
|
||||
raise HTTPException(422, "max_segments 必须在 1~200 之间")
|
||||
merged["max_segments"] = max_segments
|
||||
return merged
|
||||
|
||||
|
||||
def _template_payload(tpl: WorkflowTemplate) -> dict[str, Any]:
|
||||
return {
|
||||
"id": tpl.id,
|
||||
"key": tpl.key,
|
||||
"name": tpl.name,
|
||||
"description": tpl.description,
|
||||
"config": tpl.config or {},
|
||||
"enabled": tpl.enabled,
|
||||
"builtin": tpl.builtin,
|
||||
"organization_id": tpl.organization_id,
|
||||
"owner_user_id": tpl.owner_user_id,
|
||||
"visibility": tpl.visibility,
|
||||
"created_at": tpl.created_at,
|
||||
"updated_at": tpl.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _plan_payload(v: CampaignPlanVersion, *, include_plan: bool = True) -> dict[str, Any]:
|
||||
data = {
|
||||
"id": v.id,
|
||||
"campaign_id": v.campaign_id,
|
||||
"version": v.version,
|
||||
"source": v.source,
|
||||
"template_id": v.template_id,
|
||||
"template_key": v.template_key,
|
||||
"status": v.status,
|
||||
"summary": v.summary,
|
||||
"risks": v.risks or [],
|
||||
"advice": v.advice,
|
||||
"validation": v.validation or {},
|
||||
"created_by": v.created_by,
|
||||
"reviewed_by": v.reviewed_by,
|
||||
"review_note": v.review_note,
|
||||
"created_at": v.created_at,
|
||||
"reviewed_at": v.reviewed_at,
|
||||
}
|
||||
if include_plan:
|
||||
data["plan"] = v.plan or {}
|
||||
return data
|
||||
|
||||
|
||||
def _require_campaign(s: Session, cid: int, request: Request, operation: str = "manage") -> Campaign:
|
||||
campaign = s.get(Campaign, cid)
|
||||
perm = {"review": "tasks:review", "read": "tasks:read"}.get(operation, "agents:write")
|
||||
if not campaign or not can_access(campaign, request_user(request), operation, perm, s):
|
||||
raise HTTPException(404, "任务不存在")
|
||||
return campaign
|
||||
|
||||
|
||||
def _audit(s: Session, request: Request, action: str, resource: str, result: str = "success",
|
||||
detail: str = "") -> None:
|
||||
user = request_user(request)
|
||||
s.add(AuditLog(
|
||||
actor_user_id=int(getattr(user, "id", 0) or 0),
|
||||
actor_display_name=getattr(user, "display_name", "") or getattr(user, "username", "") or "",
|
||||
action=action, resource=resource, result=result,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
@router.get("/api/campaigns/workflow-templates")
|
||||
def list_workflow_templates(s: Session = Depends(get_session), request: Request = None):
|
||||
planner.ensure_default_workflow_template(s)
|
||||
user = request_user(request)
|
||||
rows = s.exec(select(WorkflowTemplate).order_by(WorkflowTemplate.builtin.desc(),
|
||||
WorkflowTemplate.id)).all()
|
||||
out = []
|
||||
for tpl in rows:
|
||||
if user is None or user.role == "admin" or tpl.owner_user_id == int(user.id or 0) or tpl.visibility == "global":
|
||||
out.append(_template_payload(tpl))
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/campaigns/workflow-templates")
|
||||
def create_workflow_template(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||||
user = request_user(request)
|
||||
if user and user.role != "admin":
|
||||
raise HTTPException(403, "只有管理员可以创建工作流模板")
|
||||
key = str(body.get("key") or "").strip()
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not key or not name:
|
||||
raise HTTPException(422, "模板 key 和名称不能为空")
|
||||
if s.exec(select(WorkflowTemplate).where(WorkflowTemplate.key == key)).first():
|
||||
raise HTTPException(409, "模板 key 已存在")
|
||||
organization_id, owner_user_id = actor_fields(request)
|
||||
tpl = WorkflowTemplate(
|
||||
key=key[:80], name=name[:100], description=str(body.get("description") or "")[:1000],
|
||||
config=_client_template_config(body), enabled=bool(body.get("enabled", True)),
|
||||
builtin=False, organization_id=organization_id, owner_user_id=owner_user_id,
|
||||
visibility=str(body.get("visibility") or "private"),
|
||||
)
|
||||
s.add(tpl)
|
||||
s.commit()
|
||||
s.refresh(tpl)
|
||||
_audit(s, request, "workflow_template.create", f"/api/campaigns/workflow-templates/{tpl.id}")
|
||||
return _template_payload(tpl)
|
||||
|
||||
|
||||
@router.patch("/api/campaigns/workflow-templates/{tid}")
|
||||
def update_workflow_template(tid: int, body: dict, s: Session = Depends(get_session),
|
||||
request: Request = None):
|
||||
user = request_user(request)
|
||||
tpl = s.get(WorkflowTemplate, tid)
|
||||
if not tpl:
|
||||
raise HTTPException(404, "工作流模板不存在")
|
||||
if user and user.role != "admin" and tpl.owner_user_id != int(user.id or 0):
|
||||
raise HTTPException(404, "工作流模板不存在")
|
||||
if "name" in body:
|
||||
tpl.name = str(body.get("name") or tpl.name)[:100]
|
||||
if "description" in body:
|
||||
tpl.description = str(body.get("description") or "")[:1000]
|
||||
if "enabled" in body:
|
||||
tpl.enabled = bool(body.get("enabled"))
|
||||
if "visibility" in body and str(body.get("visibility")) in {"private", "shared", "global"}:
|
||||
tpl.visibility = str(body.get("visibility"))
|
||||
if body.get("config") is not None:
|
||||
if tpl.builtin and user and user.role != "admin":
|
||||
raise HTTPException(403, "内置模板只能由管理员调整")
|
||||
tpl.config = _client_template_config(body)
|
||||
tpl.updated_at = now()
|
||||
s.add(tpl)
|
||||
s.commit()
|
||||
s.refresh(tpl)
|
||||
_audit(s, request, "workflow_template.update", f"/api/campaigns/workflow-templates/{tpl.id}")
|
||||
return _template_payload(tpl)
|
||||
|
||||
|
||||
@router.post("/api/campaigns/{cid}/plans")
|
||||
def create_campaign_plan(cid: int, body: dict = None, s: Session = Depends(get_session),
|
||||
request: Request = None):
|
||||
campaign = _require_campaign(s, cid, request, "manage")
|
||||
body = body or {}
|
||||
if campaign.status == "done":
|
||||
raise HTTPException(409, "任务已交付,不能重新规划")
|
||||
if campaign.status not in {"draft", "planning", "plan_review", "plan_failed", "parsed"}:
|
||||
raise HTTPException(409, f"任务当前状态 {campaign.status} 不能重新规划;请先停止运行或使用新版本任务")
|
||||
if not (campaign.mindmap_text or "").strip() and not advice:
|
||||
raise HTTPException(422, "导图内容是空的——请先补充导图/框架文本,再提交 DSH Planner 规划")
|
||||
active = s.exec(select(TaskQueueItem).where(
|
||||
TaskQueueItem.task_key == f"campaign-plan:{cid}",
|
||||
TaskQueueItem.status.in_(["pending", "running"]))).first()
|
||||
if active:
|
||||
return {"status": "queued", "queued": True, "planning": True, "backend": "dsh",
|
||||
"message": "DSH Planner 已在规划队列中"}
|
||||
try:
|
||||
template_id = int(body.get("template_id") or 0)
|
||||
if template_id:
|
||||
planner.get_template(s, template_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "工作流模板无效")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
advice = str(body.get("advice") or "")[:1000]
|
||||
_, user_id = actor_fields(request)
|
||||
campaign.status = "planning"
|
||||
s.add(campaign)
|
||||
s.commit()
|
||||
payload = {"campaign_id": cid, "template_id": template_id, "advice": advice,
|
||||
"created_by": int(user_id or campaign.owner_user_id or 0)}
|
||||
if execution_config.is_dsh_backend():
|
||||
task_queue.enqueue(s, TaskQueueItem, task_key=f"campaign-plan:{cid}",
|
||||
payload=payload, update_existing=True)
|
||||
return {"status": "queued", "queued": True, "planning": True, "backend": "dsh"}
|
||||
|
||||
def _run_inline():
|
||||
with Session(db_engine) as worker_session:
|
||||
planner.run_plan_job(worker_session, cid, template_id=template_id, advice=advice,
|
||||
created_by=payload["created_by"])
|
||||
|
||||
threading.Thread(target=_run_inline, daemon=True).start()
|
||||
return {"status": "accepted", "planning": True, "backend": "inline"}
|
||||
|
||||
|
||||
@router.get("/api/campaigns/{cid}/plans")
|
||||
def list_campaign_plans(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||||
campaign = _require_campaign(s, cid, request, "read")
|
||||
rows = s.exec(select(CampaignPlanVersion).where(
|
||||
CampaignPlanVersion.campaign_id == cid
|
||||
).order_by(CampaignPlanVersion.version.desc())).all()
|
||||
return [_plan_payload(v, include_plan=False) for v in rows]
|
||||
|
||||
|
||||
@router.get("/api/campaigns/{cid}/plans/{vid}")
|
||||
def get_campaign_plan(cid: int, vid: int, s: Session = Depends(get_session), request: Request = None):
|
||||
_require_campaign(s, cid, request, "read")
|
||||
v = s.get(CampaignPlanVersion, vid)
|
||||
if not v or v.campaign_id != cid:
|
||||
raise HTTPException(404, "计划版本不存在")
|
||||
return _plan_payload(v)
|
||||
|
||||
|
||||
@router.post("/api/campaigns/{cid}/plans/{vid}/approve")
|
||||
def approve_campaign_plan(cid: int, vid: int, body: dict = None,
|
||||
s: Session = Depends(get_session), request: Request = None):
|
||||
campaign = _require_campaign(s, cid, request, "review")
|
||||
v = s.get(CampaignPlanVersion, vid)
|
||||
if not v or v.campaign_id != cid:
|
||||
raise HTTPException(404, "计划版本不存在")
|
||||
note = str((body or {}).get("review_note") or "")[:1000]
|
||||
acknowledge_risks = bool((body or {}).get("acknowledge_risks"))
|
||||
if any(str(risk.get("severity") or "") == "high" for risk in (v.risks or [])) and not acknowledge_risks:
|
||||
raise HTTPException(409, "该计划存在高风险项,请在工作台逐项核对并显式确认后再批准")
|
||||
user = request_user(request)
|
||||
try:
|
||||
v = planner.approve_plan(s, vid, reviewer_user_id=int(getattr(user, "id", 0) or 0),
|
||||
review_note=note)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
_audit(s, request, "campaign_plan.approve", f"/api/campaigns/{cid}/plans/{vid}")
|
||||
return {"status": "approved", "plan": _plan_payload(v, include_plan=False)}
|
||||
|
||||
|
||||
@router.post("/api/campaigns/{cid}/plans/{vid}/reject")
|
||||
def reject_campaign_plan(cid: int, vid: int, body: dict = None,
|
||||
s: Session = Depends(get_session), request: Request = None):
|
||||
campaign = _require_campaign(s, cid, request, "review")
|
||||
v = s.get(CampaignPlanVersion, vid)
|
||||
if not v or v.campaign_id != cid:
|
||||
raise HTTPException(404, "计划版本不存在")
|
||||
note = str((body or {}).get("review_note") or "")[:1000]
|
||||
user = request_user(request)
|
||||
try:
|
||||
v = planner.reject_plan(s, vid, reviewer_user_id=int(getattr(user, "id", 0) or 0),
|
||||
review_note=note)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
_audit(s, request, "campaign_plan.reject", f"/api/campaigns/{cid}/plans/{vid}")
|
||||
return {"status": "rejected", "plan": _plan_payload(v, include_plan=False)}
|
||||
Reference in New Issue
Block a user