Files
mini-openclaw/backend/api/project_assistant.py
T

300 lines
13 KiB
Python

"""Unified project API. Every resource read/write derives scope from its project."""
import base64
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel, Field, ConfigDict
from api.risk_agent_access import authorize, check_project
from services.project_assistant import ProjectAssistantService
from services.assistant_store import Conflict, listing, get, put, event, digest, uid, now
from services import component_registry as registry
from services.assistant_settings import flags
def enabled():
if not flags()["assistant"]:
raise HTTPException(503, "新项目助手已暂停;旧工作台和历史入口保持可用")
router = APIRouter(prefix="/api/xingbang/risk-agent/project-assistant", dependencies=[Depends(authorize), Depends(enabled)], tags=["兴邦项目助手"])
def service():
return ProjectAssistantService()
def guard(request, project_id):
check_project(request, project_id)
if project_id in ("DEMO-A", "DEMO-B", "DEMO-C") and not flags()["demo"]:
raise HTTPException(403, "演示数据域已禁用")
def role(request, allowed):
if not request.state.identity.get("preview") and request.state.identity["role"] not in allowed:
raise HTTPException(403, "当前角色无权执行此操作")
def translate(call):
try:
return call()
except KeyError as exc:
raise HTTPException(404, str(exc.args[0])) from exc
except Conflict as exc:
raise HTTPException(409, str(exc)) from exc
except PermissionError as exc:
raise HTTPException(403, str(exc)) from exc
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
class Input(BaseModel):
model_config = ConfigDict(extra="forbid")
class RunInput(Input):
requirement: str = Field(default="", max_length=2000)
plan_id: str = "corporate.default"
revision: int | None = None
feedback_id: str | None = None
class SupplementInput(Input):
changes: dict = Field(default_factory=dict)
finding_ids: list[str] = Field(default_factory=list, max_length=100)
candidate_ids: list[str] = Field(default_factory=list, max_length=100)
revision: int
reason: str = Field(min_length=1, max_length=2000)
class ScenarioInput(Input):
facility_amount: str = Field(max_length=50)
class ReportInput(Input):
run_id: str
template_id: str = "template.due-diligence"
class ActionInput(Input):
action: str = Field(max_length=50)
revision: int
note: str = Field(default="", max_length=2000)
payload: dict = Field(default_factory=dict)
class FeedbackInput(Input):
category: str
note: str = Field(min_length=1, max_length=3000)
target_kind: str
target_id: str
class ExperienceInput(Input):
title: str = Field(min_length=1, max_length=200)
trigger: str = Field(min_length=1, max_length=2000)
action: str = Field(min_length=1, max_length=2000)
exceptions: str = Field(min_length=1, max_length=2000)
feedback_id: str
conditions: dict
required_field: str
exception_field: str
scope: str = "project"
team: str = ""
industry: str = ""
expires_at: str
@router.get("/projects")
def projects(request: Request):
allowed = request.state.identity.get("projects", [])
items = [p for p in service().list_projects() if ("*" in allowed or p["id"] in allowed) and (flags()["demo"] or p["data_domain"] != "demo")]
return {"projects": items, "identity": request.state.identity}
@router.get("/projects/{project_id}")
def project(project_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().get_project(project_id))
@router.post("/projects/{project_id}/runs", status_code=202)
def create_run(project_id: str, payload: RunInput, request: Request):
guard(request, project_id)
key = request.headers.get("idempotency-key", "")
if len(key) > 160:
raise HTTPException(422, "幂等键过长")
return translate(lambda: service().enqueue(project_id, request.state.identity, payload.requirement, payload.plan_id, key, payload.revision, payload.feedback_id))
@router.get("/projects/{project_id}/runs/{run_id}")
def run(project_id: str, run_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().public_run(service().resource("run", run_id, project_id)))
@router.post("/projects/{project_id}/runs/{run_id}/cancel")
def cancel(project_id: str, run_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().cancel(project_id, run_id, request.state.identity["id"]))
@router.get("/projects/{project_id}/snapshots/{snapshot_id}")
def snapshot(project_id: str, snapshot_id: str, request: Request):
guard(request, project_id)
return translate(lambda: service().resource("snapshot", snapshot_id, project_id))
@router.get("/projects/{project_id}/legacy/{kind}/{legacy_id}")
def legacy(project_id: str, kind: str, legacy_id: str, request: Request):
guard(request, project_id)
if kind not in ("runs", "snapshots", "findings", "scenarios", "reports", "experiences"):
raise HTTPException(404, "历史类型不存在")
return translate(lambda: service().resource("legacy_archive", kind + ":" + legacy_id, project_id))
@router.post("/projects/{project_id}/documents", status_code=201)
async def upload(project_id: str, request: Request, file: UploadFile = File(...)):
guard(request, project_id)
from services.assistant_materials import LIMIT
try:
content = await file.read(LIMIT + 1)
finally:
await file.close()
# Parsing is synchronous CPU work; do not block the ASGI event loop.
from starlette.concurrency import run_in_threadpool
return await run_in_threadpool(lambda: translate(lambda: service().upload(project_id, file.filename or "material", content, request.state.identity["id"])))
@router.get("/projects/{project_id}/documents/{document_id}")
def document(project_id: str, document_id: str, request: Request):
guard(request, project_id)
return translate(lambda: {k: v for k, v in service().resource("document", document_id, project_id).items() if k != "raw_base64"})
@router.get("/projects/{project_id}/documents/{document_id}/download")
def download(project_id: str, document_id: str, request: Request):
guard(request, project_id)
body = translate(lambda: service().resource("document", document_id, project_id))
return Response(base64.b64decode(body["raw_base64"]), media_type="application/octet-stream", headers={"Content-Disposition": "attachment; filename*=UTF-8''" + quote(body["name"]), "X-Content-Type-Options": "nosniff"})
@router.post("/projects/{project_id}/supplements", status_code=201)
def supplement(project_id: str, payload: SupplementInput, request: Request):
guard(request, project_id)
return translate(lambda: service().supplement(project_id, request.state.identity["id"], payload.changes, payload.finding_ids, payload.revision, payload.reason, payload.candidate_ids))
@router.post("/projects/{project_id}/findings/{finding_id}/actions")
def finding_action(project_id: str, finding_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
return translate(lambda: service().finding_action(project_id, finding_id, request.state.identity["id"], payload.action, payload.note, payload.revision))
@router.post("/projects/{project_id}/scenarios", status_code=201)
def scenario(project_id: str, payload: ScenarioInput, request: Request):
guard(request, project_id)
return translate(lambda: service().compare_scenario(project_id, payload.facility_amount, request.state.identity["id"]))
@router.post("/projects/{project_id}/scenarios/{scenario_id}/adopt")
def adopt(project_id: str, scenario_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
return translate(lambda: service().adopt_scenario(project_id, scenario_id, request.state.identity["id"], payload.revision, payload.note))
@router.post("/projects/{project_id}/reports", status_code=201)
def report(project_id: str, payload: ReportInput, request: Request):
guard(request, project_id)
key = request.headers.get("idempotency-key", "")
if len(key) > 160:
raise HTTPException(422, "幂等键过长")
return translate(lambda: service().create_report(project_id, payload.run_id, request.state.identity["id"], payload.template_id, key))
@router.post("/projects/{project_id}/reports/{report_id}/actions")
def report_action(project_id: str, report_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
if payload.action == "finalize":
role(request, ("reviewer",))
return translate(lambda: service().report_action(project_id, report_id, request.state.identity["id"], payload.action, payload.payload, payload.revision))
@router.get("/projects/{project_id}/reports/{report_id}/export")
def report_export(project_id: str, report_id: str, request: Request):
guard(request, project_id)
content = translate(lambda: service().export_report(project_id, report_id))
return Response(content, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f'attachment; filename="{report_id}.docx"', "X-Content-Type-Options": "nosniff"})
@router.post("/projects/{project_id}/feedback", status_code=201)
def feedback(project_id: str, payload: FeedbackInput, request: Request):
guard(request, project_id)
return translate(lambda: service().feedback(project_id, request.state.identity["id"], payload.model_dump()))
@router.post("/projects/{project_id}/experiences", status_code=201)
def experience(project_id: str, payload: ExperienceInput, request: Request):
guard(request, project_id)
if payload.scope == "team" and not request.state.identity.get("preview") and payload.team not in request.state.identity.get("teams", []):
raise HTTPException(403, "无权向该团队提交经验")
return translate(lambda: service().save_experience(project_id, request.state.identity["id"], payload.model_dump()))
@router.post("/projects/{project_id}/experiences/{experience_id}/actions")
def experience_action(project_id: str, experience_id: str, payload: ActionInput, request: Request):
guard(request, project_id)
return translate(lambda: service().experience_action(project_id, experience_id, request.state.identity, payload.action, payload.revision, payload.note))
@router.get("/components")
def components(request: Request):
role(request, ("admin", "reviewer"))
with service().connect() as db:
return {"published": [registry.resolve(db, r["id"]) for r in listing(db, "release")], "drafts": listing(db, "component_draft"), "versions": listing(db, "component_version")}
@router.post("/components")
def component_draft(payload: dict, request: Request):
role(request, ("admin",))
def save():
with service().connect() as db:
db.execute("BEGIN IMMEDIATE")
return registry.draft(db, payload, request.state.identity["id"])
return translate(save)
@router.post("/components/{draft_id}/actions")
def component_action(draft_id: str, payload: ActionInput, request: Request):
role(request, ("admin", "reviewer"))
def action():
if not flags()["evaluation"]:
raise HTTPException(503, "候选评测与发布已暂停")
from services.component_evaluation import evaluate
with service().connect() as db:
db.execute("BEGIN IMMEDIATE")
if payload.action in ("evaluate", "publish") and get(db, "component_draft", draft_id)["revision"] != payload.revision:
raise Conflict("组件草稿已更新,请刷新")
if payload.action == "evaluate":
return evaluate(db, draft_id, request.state.identity["id"])
role(request, ("reviewer",))
if payload.action == "publish":
return registry.release(db, draft_id, request.state.identity["id"], request.state.identity.get("preview"))
if payload.action == "rollback":
old = get(db, "component_version", draft_id)
result = put(db, "release", {"id": old["component_id"], "version_id": old["id"], "scope": old["scope"]})
event(db, "", "component.rollback", request.state.identity["id"], {"version_id": draft_id, "note": payload.note})
return result
raise ValueError("不支持的组件动作")
return translate(action)
class ResetInput(Input):
confirmation: str
@router.post("/demo/reset")
def reset_demo(payload: ResetInput, request: Request):
role(request, ("admin",))
for project_id in ("DEMO-A", "DEMO-B", "DEMO-C"):
guard(request, project_id)
return translate(lambda: service().reset_demo(request.state.identity["id"], payload.confirmation))