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