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

423 lines
20 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
class CommandInput(Input):
project_id: str
message: str = Field(min_length=1, max_length=2000)
class ReviewDecisionInput(Input):
finding_id: str | None = None
node_id: str | None = Field(default=None, max_length=50)
decision: str = Field(max_length=30)
opinion: str = Field(default="", max_length=2000)
@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")]
if request.state.identity["role"] == "reviewer":
reviewer_id = request.state.identity["id"]
items = [item for item in items if item.get("submitted_at") and item.get("workflow_state") not in ("review_approved", "returned")
and item.get("reviewer_id") in (None, reviewer_id)]
for item in items:
latest = item.get("latest_run") or {}
if latest.get("run_kind") == "independent_review" and latest.get("status") in ("queued", "running"):
item["next_action"] = "查看独立评审进度"
elif item.get("reviewer_id") == reviewer_id:
item["next_action"] = "继续办理"
else:
item["next_action"] = "领取并开始评审"
items.sort(key=lambda item: item.get("review_wait_hours") or 0, reverse=True)
return {"projects": items, "identity": request.state.identity}
@router.get("/review-pool")
def review_pool(request: Request):
role(request, ("manager", "reviewer"))
if request.state.identity["role"] not in ("manager", "reviewer"):
return {"projects": [], "identity": request.state.identity}
allowed = request.state.identity.get("projects", [])
items = [item for item in service().review_pool(request.state.identity["id"], request.state.identity["role"])
if "*" in allowed or item["project_id"] in allowed]
return {"projects": items, "identity": request.state.identity}
@router.get("/projects/{project_id}")
def project(project_id: str, request: Request):
guard(request, project_id)
result = translate(lambda: service().get_project(project_id))
current_role = request.state.identity["role"]
result["plans"] = [p for p in result["plans"] if current_role == "admin" or not p.get("audience") or current_role in p["audience"]]
return result
@router.post("/projects/{project_id}/review-claim", status_code=201)
def review_claim(project_id: str, request: Request):
guard(request, project_id)
role(request, ("reviewer",))
return translate(lambda: service().claim_review(project_id, request.state.identity["id"]))
@router.post("/projects/{project_id}/review-withdraw")
def review_withdraw(project_id: str, request: Request):
guard(request, project_id)
role(request, ("manager",))
return translate(lambda: service().withdraw_review(project_id, request.state.identity["id"]))
@router.post("/projects/{project_id}/review-resubmit", status_code=201)
def review_resubmit(project_id: str, request: Request):
guard(request, project_id)
role(request, ("manager",))
return translate(lambda: service().resubmit_review(project_id, request.state.identity["id"]))
@router.get("/time-dashboard")
def time_dashboard(request: Request):
allowed = request.state.identity.get("projects", [])
project_ids = [p["id"] for p in service().list_projects() if "*" in allowed or p["id"] in allowed]
result = service().time_dashboard(project_ids)
result["identity"] = request.state.identity
return result
@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}/review-decisions")
def review_decision(project_id: str, payload: ReviewDecisionInput, request: Request):
guard(request, project_id)
role(request, ("reviewer",))
return translate(lambda: service().review_decision(project_id, request.state.identity["id"], payload.decision, payload.opinion, payload.finding_id, payload.node_id))
@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)
current_role = request.state.identity["role"]
allowed_templates = {
"manager": {"template.ai-pre-review", "template.due-diligence"},
"reviewer": {"template.independent-review", "template.committee-brief", "template.review", "template.committee"},
"admin": {"template.ai-pre-review", "template.independent-review", "template.committee-brief", "template.due-diligence", "template.review", "template.committee"},
}
if payload.template_id not in allowed_templates[current_role] and not request.state.identity.get("preview"):
raise HTTPException(403, "当前身份不能使用该报告模板")
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",))
if payload.action == "submit":
role(request, ("manager",))
return translate(lambda: service().report_action(project_id, report_id, request.state.identity["id"], payload.action, payload.payload, payload.revision))
@router.post("/commands")
def business_command(payload: CommandInput, request: Request):
"""Small, auditable command surface; every mutation uses the same project service as buttons."""
guard(request, payload.project_id)
svc = service()
project_body = translate(lambda: svc.get_project(payload.project_id))
message = payload.message.strip()
current_role = request.state.identity["role"]
key = request.headers.get("idempotency-key", "")
if any(word in message for word in ("预审", "评审", "检查", "复核")) and not any(word in message for word in ("查看", "还缺", "多少", "状态", "生成", "报告", "提交")):
plan_id = "corporate.independent-review" if current_role == "reviewer" else "corporate.pre-review"
run = translate(lambda: svc.enqueue(payload.project_id, request.state.identity, message, plan_id, key, project_body["revision"]))
return {"intent": "start_run", "message": ("独立评审" if current_role == "reviewer" else "AI预审") + "已进入队列。", "object_id": run["id"], "url": f"/xingbang-workbench?project={payload.project_id}&run={run['id']}"}
if "生成" in message and "报告" in message:
run_kind = "independent_review" if current_role == "reviewer" else "pre_review"
source = next((r for r in project_body["runs"] if r.get("run_kind", "pre_review") == run_kind and r["status"] in ("completed", "partial")), None)
if not source:
raise HTTPException(409, "请先完成对应的mini-openclaw运行")
template_id = "template.independent-review" if current_role == "reviewer" else "template.ai-pre-review"
report_body = translate(lambda: svc.create_report(payload.project_id, source["id"], request.state.identity["id"], template_id, key))
return {"intent": "create_report", "message": "报告草稿已生成,来源版本已冻结。", "object_id": report_body["id"], "url": f"/xingbang-workbench?project={payload.project_id}&report={report_body['id']}"}
if "提交" in message and "评审" in message:
role(request, ("manager",))
report_body = next((r for r in project_body["reports"] if r["template"].get("id") in ("template.ai-pre-review", "template.due-diligence") and r["status"] != "stale"), None)
if not report_body:
raise HTTPException(409, "请先生成当前数据版本的AI预审报告")
submitted = translate(lambda: svc.report_action(payload.project_id, report_body["id"], request.state.identity["id"], "submit", {}, report_body["revision"]))
return {"intent": "submit_review", "message": "AI预审报告已提交评审,历史版本保持可查看。", "object_id": submitted["id"], "url": f"/review-pool?submitted={payload.project_id}"}
unresolved = [f for f in project_body["findings"] if f["status"] not in ("已解决", "不适用")]
latest = project_body["runs"][0] if project_body["runs"] else None
message_text = f"{project_body['customer']}当前有{len(unresolved)}项待处理风险、{len(project_body['pending_candidates'])}项材料候选待确认。"
if latest:
message_text += f"最近一次{('独立评审' if latest.get('run_kind') == 'independent_review' else 'AI预审')}状态为{latest['status']}。"
message_text += "下一步:" + (f"处理“{unresolved[0]['title']}”" if unresolved else "生成报告或提交下一环节")
return {"intent": "project_status", "message": message_text, "items": [{"id": f["id"], "title": f["title"], "status": f["status"]} for f in unresolved[:5]], "url": f"/xingbang-workbench?project={payload.project_id}"}
@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))