"""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 pending = project_body["pending_candidates"] project_url = f"/xingbang-workbench?project={quote(payload.project_id)}" run_labels = {"queued": "等待执行", "running": "执行中", "completed": "已完成", "partial": "部分完成", "failed": "执行失败", "cancelled": "已取消"} workflow_labels = {"report_ready": "报告准备", "supplementing": "补充材料", "submitted": "待评审领取", "under_review": "评审办理", "returned": "退回补件", "review_approved": "评审通过", "withdrawn": "已撤回"} field_labels = {"facility.amount": "融资金额", "lease.total_value": "租赁物价值", "lease.duplicate_count": "设备清单重复情况", "financial.operating_cashflow": "经营现金流资料", "financial.total_liabilities": "财报负债", "due_diligence.liabilities": "尽调负债", "collection.evidence": "回款依据", "credit.overdue_count": "征信逾期记录", "credit.report_date": "征信报告日期"} def finding_title(item): title = item.get("title", "待核查问题") if "." in title and title.endswith("完整性"): field = next(iter(item.get("fields", [])), title[:-3]) return field_labels.get(field, title) + "是否完整" return title if any(word in message for word in ("下一步", "接下来", "要做什么", "该做什么", "怎么推进", "如何推进", "怎么办")): workflow = project_body.get("workflow_state", "") independent = next((r for r in project_body["runs"] if r.get("run_kind") == "independent_review"), None) if current_role == "reviewer" and workflow == "submitted" and not project_body.get("reviewer_id"): next_text = "下一步请先到项目评审池领取该项目;领取后再发起独立评审。" next_url = "/review-pool" elif current_role == "reviewer" and not independent: next_text = "下一步请发起独立评审,系统会基于评审员专用 Skills 与规则重新核查全链路。" next_url = project_url + "&tab=review" elif workflow == "returned": returned = next((d for d in project_body["review_decisions"] if d.get("decision") == "退回" and not d.get("finding_id") and not d.get("node_id")), None) reason = (returned or {}).get("opinion") or "请查看评审员的退回要求" next_text = f"下一步请先补充退回材料:{reason}。确认材料来源后,重新发起AI预审。" next_url = project_url + "&tab=findings" elif pending: next_text = f"下一步请先确认{len(pending)}项材料候选,确认期间、口径和来源后再更新项目事实。" next_url = project_url + "&tab=findings" elif latest and latest["status"] in ("queued", "running"): next_text = f"本次{('独立评审' if latest.get('run_kind') == 'independent_review' else 'AI预审')}正在{run_labels[latest['status']]}。下一步先查看运行进度,完成后再处理问题。" next_url = project_url elif unresolved: first = unresolved[0] action = first.get("suggested_action") or "补充对应材料并说明核实口径" next_text = f"下一步先处理“{finding_title(first)}”:{action}。处理后重新AI预审,确认问题是否闭环。" next_url = project_url + "&tab=findings#project-findings" elif not latest: next_text = "下一步请发起AI预审,系统会检查当前材料并列出需要补充或人工确认的事项。" next_url = project_url elif not project_body["reports"]: next_text = "当前问题已闭环。下一步请生成AI预审报告,并核对报告内容和来源版本。" next_url = project_url + "&tab=report" elif workflow != "submitted": next_text = "当前报告已经具备送审条件。下一步请核对报告后提交正式评审。" next_url = project_url + "&tab=report" else: next_text = "项目已经提交。下一步请到项目评审池查看领取状态和等待时间。" next_url = "/review-pool" return {"intent": "next_step", "message": next_text, "url": next_url} if any(word in message for word in ("什么问题", "哪些问题", "问题是什么", "风险", "异常")): if unresolved: details = ";".join(f"{index + 1}.{finding_title(item)}({item['status']})" for index, item in enumerate(unresolved[:5])) message_text = f"当前共有{len(unresolved)}项未闭环问题:{details}。打开问题页可以逐项查看数据来源、AI判断和解决建议。" else: message_text = "当前没有未闭环问题。你可以继续生成报告,或查看历史检查记录和来源依据。" return {"intent": "project_issues", "message": message_text, "items": [{"id": f["id"], "title": f["title"], "status": f["status"]} for f in unresolved[:5]], "url": project_url + "&tab=findings#project-findings"} if any(word in message for word in ("还缺", "缺什么", "缺少", "材料", "补件")): if pending: fields = "、".join(str(item.get("field") or "待确认字段") for item in pending[:5]) message_text = f"当前有{len(pending)}项材料解析候选尚未确认:{fields}。请打开材料档案,核对期间、单位和来源后确认。" elif unresolved: message_text = f"当前没有待确认的材料解析候选,但仍有{len(unresolved)}项业务问题。请先查看“{finding_title(unresolved[0])}”的数据依据和补件建议。" else: message_text = f"当前没有待确认的材料候选,已归档{len(project_body['documents'])}份材料,现有问题已闭环。" return {"intent": "materials", "message": message_text, "url": project_url + "&tab=findings"} if any(word in message for word in ("到哪", "进度", "状态", "阶段", "办到")): stage = workflow_labels.get(project_body.get("workflow_state", ""), project_body.get("stage") or "处理中") message_text = f"{project_body['customer']}当前处于“{stage}”阶段:有{len(unresolved)}项风险待处理,{len(pending)}项材料候选待确认。" if latest: run_name = "独立评审" if latest.get("run_kind") == "independent_review" else "AI预审" message_text += f"最近一次{run_name}{run_labels.get(latest['status'], latest['status'])}。" return {"intent": "project_status", "message": message_text, "items": [{"id": f["id"], "title": f["title"], "status": f["status"]} for f in unresolved[:5]], "url": project_url} if any(word in message for word in ("能做什么", "可以做什么", "怎么用", "如何使用", "帮助")) or message in ("你好", "您好", "嗨"): return {"intent": "help", "message": "我可以帮你查项目进度、说明下一步、列出问题与缺失材料,也可以发起AI预审、生成报告和提交正式评审。你可以直接问:这个项目到哪一步了?或:我接下来要做什么?", "url": project_url} return {"intent": "clarify", "message": "我还不能确定你要查哪一项。你可以直接说“查看项目进度”“下一步做什么”“有哪些问题”“还缺什么材料”,也可以使用左侧快捷操作。复杂的自由分析请切换到 mini-openclaw。", "url": project_url} @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))