Files

268 lines
11 KiB
Python

"""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)}