按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单, 从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。 - 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等 4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/ ori_file)、Office 临时文件 - 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案 - 未部署、未启动服务、未调用模型 Co-Authored-By: Claude Code <noreply@anthropic.com>
500 lines
27 KiB
Python
500 lines
27 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
|
||
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))
|