423 lines
20 KiB
Python
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))
|