deerflow-code/offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py
2026-09-07 18:24:55 +08:00

724 lines
26 KiB
Python

"""Gateway API for the AgentScope report-collaboration workbench.
Plan/run writes enqueue durable jobs and return immediately. AgentScope
execution is not started from this router. ``GET /runs/{id}/stream`` is a
read-only durable SSE tail: disconnect does not cancel the run.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from app.report_collaboration.agentscope_runtime.model_resolver import freeze_plan_models
from app.report_collaboration.contracts.snapshots import SessionSnapshot
from app.report_collaboration.execution.live_hub import iter_run_sse, replay_cursor
from app.report_collaboration.interventions.intent_router import build_runtime_intent_router
from app.report_collaboration.interventions.service import RuntimeCommandService
from app.report_collaboration.planning import PlanProposalService
from app.report_collaboration.reporting import ConservativeRewriteGenerator, ModelRewriteGenerator, ReportRewriteService, freeze_report_template
from app.report_collaboration.reporting.templates import requirement_snapshot_from_record
from app.report_collaboration.requirement import RequirementResolver
from app.report_collaboration.security import freeze_run_budget
from deerflow.config.app_config import get_app_config
from deerflow.persistence.report_collaboration import (
ReportCollaborationConflictError,
ReportCollaborationLimitError,
ReportCollaborationNotFoundError,
ReportCollaborationStore,
ReportCollaborationValidationError,
)
router = APIRouter(prefix="/api/report-collaboration", tags=["report-collaboration"])
class CreateSessionBody(BaseModel):
title: str | None = Field(default=None, max_length=255)
class UpdateSessionBody(BaseModel):
title: str | None = Field(default=None, max_length=255)
class SendMessageBody(BaseModel):
text: str = Field(..., min_length=1, max_length=20_000)
target_node_id: str | None = None
agent_run_id: str | None = None
class StartRunBody(BaseModel):
plan_id: str = Field(..., min_length=1, max_length=64)
class CreateCommandBody(BaseModel):
text: str = Field(..., min_length=1, max_length=20_000)
target_node_id: str | None = None
agent_run_id: str | None = None
class CreateRewriteBody(BaseModel):
instruction: str = Field(..., min_length=1, max_length=20_000)
section: str | None = None
def _store(request: Request) -> ReportCollaborationStore:
store = getattr(request.app.state, "report_collaboration_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Report collaboration store is unavailable")
return store
def _runtime_commands(request: Request) -> RuntimeCommandService:
service = getattr(request.app.state, "report_collaboration_command_service", None)
if service is None:
app_config = get_app_config()
service = RuntimeCommandService(
_store(request),
router=build_runtime_intent_router(app_config),
config=app_config.report_collaboration,
)
request.app.state.report_collaboration_command_service = service
return service
def _require_enabled() -> JSONResponse | None:
if not get_app_config().report_collaboration.enabled:
return _error(503, "REPORT_COLLABORATION_DISABLED", "报告协作工作台未启用")
return None
async def _require_user(request: Request) -> str:
user_id = await get_current_user(request)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return str(user_id)
def _error(status: int, code: str, message: str, detail: Any = None) -> JSONResponse:
body: dict[str, Any] = {"code": code, "message": message}
if detail is not None:
body["detail"] = detail
return JSONResponse(status_code=status, content=body)
def _require_idempotency(idempotency_key: str | None) -> str | JSONResponse:
key = (idempotency_key or "").strip()
if not key:
return _error(422, "IDEMPOTENCY_KEY_REQUIRED", "写操作必须携带 X-Idempotency-Key")
if len(key) > 128:
return _error(422, "IDEMPOTENCY_KEY_INVALID", "X-Idempotency-Key 过长")
return key
def _map_store_error(exc: Exception) -> JSONResponse:
if isinstance(exc, ReportCollaborationNotFoundError):
return _error(404, "NOT_FOUND", str(exc))
if isinstance(exc, ReportCollaborationConflictError):
return _error(409, exc.code, str(exc), detail={"current_revision": exc.current_revision})
if isinstance(exc, ReportCollaborationValidationError):
return _error(422, exc.code, str(exc))
if isinstance(exc, ReportCollaborationLimitError):
return _error(429, exc.code, str(exc), detail={"usage": exc.usage, "limits": exc.limits})
raise exc
def _rewrite_service(store: ReportCollaborationStore) -> ReportRewriteService:
cfg = get_app_config().report_collaboration
generator = ModelRewriteGenerator(cfg.writer_model) if cfg.writer_model else ConservativeRewriteGenerator()
return ReportRewriteService(store, generator=generator)
async def _frozen_run_template(request: Request, session: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
requirement = requirement_snapshot_from_record(session)
structure_id = plan.get("report_structure_id") or (requirement.report_structure_id if requirement else None)
structure = None
structure_store = getattr(request.app.state, "report_structure_store", None)
if structure_id and structure_store is not None:
structure = await structure_store.get_structure(str(structure_id))
return freeze_report_template(structure, requirement=requirement).model_dump(mode="json")
async def _owned_session(store: ReportCollaborationStore, session_id: str, user_id: str) -> dict[str, Any] | JSONResponse:
record = await store.get_session_record(session_id)
if record is None or record.get("owner_id") != user_id:
return _error(404, "NOT_FOUND", f"report collaboration session not found: {session_id}")
return record
async def _owned_run(store: ReportCollaborationStore, run_id: str, user_id: str) -> dict[str, Any] | JSONResponse:
run = await store.get_run(run_id)
if run is None:
return _error(404, "NOT_FOUND", f"report collaboration run not found: {run_id}")
owned = await _owned_session(store, run["session_id"], user_id)
if isinstance(owned, JSONResponse):
return owned
return run
@router.post("/sessions")
async def create_session(
request: Request,
body: CreateSessionBody | None = None,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
payload = body or CreateSessionBody()
try:
return await _store(request).create_session(owner_id=user_id, title=payload.title or "", idempotency_key=key)
except Exception as exc:
return _map_store_error(exc)
@router.get("/sessions")
async def list_sessions(request: Request):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
sessions = await _store(request).list_sessions(user_id)
return {"sessions": sessions}
@router.get("/sessions/{session_id}")
async def get_session_snapshot(request: Request, session_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
snapshot = await store.get_snapshot(session_id)
return SessionSnapshot.model_validate(snapshot).model_dump()
@router.patch("/sessions/{session_id}")
async def update_session(
request: Request,
session_id: str,
body: UpdateSessionBody,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
x_expected_revision: int | None = Header(default=None, alias="X-Expected-Revision"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
try:
updated = await store.update_session(session_id, title=body.title, expected_revision=x_expected_revision)
await store.create_command(session_id, idempotency_key=key, text=body.title or "", operation="update_session", status="completed")
return updated
except Exception as exc:
return _map_store_error(exc)
@router.delete("/sessions/{session_id}")
async def delete_session(request: Request, session_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
await store.delete_session(session_id)
return Response(status_code=204)
@router.get("/sessions/{session_id}/messages")
async def list_messages(request: Request, session_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
return {"messages": await store.list_messages(session_id)}
@router.post("/sessions/{session_id}/messages")
async def send_message(
request: Request,
session_id: str,
body: SendMessageBody,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
metadata: dict[str, Any] = {"idempotency_key": key}
if body.target_node_id:
metadata["target_node_id"] = body.target_node_id
if body.agent_run_id:
metadata["agent_run_id"] = body.agent_run_id
try:
existing = await store.get_message_by_idempotency(key)
if existing is not None:
return existing
snapshot = await store.get_snapshot(session_id)
active = snapshot.get("run") or {}
if str(active.get("status") or "") in {
"queued",
"planning",
"running",
"awaiting_input",
"reviewing",
"recovering",
}:
return _error(
409,
"RUN_ACTIVE",
"运行进行中请使用 /runs/{run_id}/commands 发送干预,不要走规划前消息接口",
detail={"run_id": active.get("id"), "status": active.get("status")},
)
created = await store.create_message(
session_id,
role="human",
content=body.text,
idempotency_key=key,
metadata=metadata,
agent_run_id=body.agent_run_id,
node_run_id=body.target_node_id,
)
await RequirementResolver(store).handle_user_message(session_id, user_message=created, idempotency_key=key)
return created
except Exception as exc:
return _map_store_error(exc)
@router.post("/sessions/{session_id}/plan-requests")
async def request_plans(
request: Request,
session_id: str,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
x_expected_revision: int | None = Header(default=None, alias="X-Expected-Revision"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
if owned.get("selected_plan_id"):
return _error(409, "PLAN_ALREADY_SELECTED", "会话已选定方案,无法重新规划")
try:
command = await store.enqueue_plan_request(session_id, idempotency_key=key, expected_revision=x_expected_revision)
if command.get("status") == "pending":
await PlanProposalService(
store,
agent_store=getattr(request.app.state, "agent_store", None),
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=key)
return Response(status_code=204)
except Exception as exc:
return _map_store_error(exc)
@router.get("/sessions/{session_id}/plans")
async def list_plans(request: Request, session_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
return {"plans": await store.list_plans(session_id)}
@router.post("/sessions/{session_id}/plans/{plan_id}/select")
async def select_plan(
request: Request,
session_id: str,
plan_id: str,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
x_expected_revision: int | None = Header(default=None, alias="X-Expected-Revision"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
try:
result = await store.select_plan(session_id, plan_id, idempotency_key=key, expected_revision=x_expected_revision)
return {"selected_plan_id": result["selected_plan_id"]}
except Exception as exc:
return _map_store_error(exc)
@router.post("/sessions/{session_id}/runs")
async def start_run(
request: Request,
session_id: str,
body: StartRunBody,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
x_expected_revision: int | None = Header(default=None, alias="X-Expected-Revision"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
try:
plans = await store.list_plans(session_id)
plan = next((item for item in plans if item.get("id") == body.plan_id), None)
if plan is None:
return _error(404, "NOT_FOUND", f"report collaboration plan not found: {body.plan_id}")
bundle = freeze_plan_models(plan, get_app_config())
cfg = get_app_config().report_collaboration
run = await store.create_run(
session_id,
plan_id=body.plan_id,
idempotency_key=key,
expected_revision=x_expected_revision,
role_snapshots=bundle.dump_by_role(),
template_snapshot=await _frozen_run_template(request, owned, plan),
budget_snapshot=freeze_run_budget(cfg),
max_active_runs_per_owner=cfg.max_concurrent_runs_per_user,
)
existing_events = await store.list_events(run["id"], after_seq=0, limit=1)
if not existing_events:
await store.append_event(
session_id=session_id,
run_id=run["id"],
event_type="heartbeat",
data={
"audit": "model_snapshot",
"roles": [item.model_dump() for item in bundle.by_role.values()],
"fallbacks": [item.model_dump() for item in bundle.fallbacks()],
},
)
return run
except Exception as exc:
return _map_store_error(exc)
@router.get("/sessions/{session_id}/reports")
async def list_reports(request: Request, session_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
return {"versions": await store.list_report_versions(session_id)}
@router.post("/sessions/{session_id}/report-rewrites")
async def create_rewrite(
request: Request,
session_id: str,
body: CreateRewriteBody,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
try:
return await _rewrite_service(store).create_candidate(
session_id,
idempotency_key=key,
instruction=body.instruction,
section=body.section,
)
except Exception as exc:
return _map_store_error(exc)
@router.post("/sessions/{session_id}/report-rewrites/{rewrite_id}/apply")
async def apply_rewrite(
request: Request,
session_id: str,
rewrite_id: str,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
try:
version, _created = await _rewrite_service(store).apply_candidate(session_id, rewrite_id, idempotency_key=key)
return version
except Exception as exc:
return _map_store_error(exc)
@router.post("/sessions/{session_id}/report-versions/{version_id}/restore")
async def restore_report(
request: Request,
session_id: str,
version_id: str,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
owned = await _owned_session(store, session_id, user_id)
if isinstance(owned, JSONResponse):
return owned
try:
version, _created = await _rewrite_service(store).restore(session_id, version_id, idempotency_key=key)
return version
except Exception as exc:
return _map_store_error(exc)
@router.get("/runs/{run_id}")
async def get_run(request: Request, run_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
run = await _owned_run(_store(request), run_id, user_id)
if isinstance(run, JSONResponse):
return run
return run
@router.get("/runs/{run_id}/events")
async def list_run_events(
request: Request,
run_id: str,
after_seq: int = Query(default=0, ge=0),
limit: int = Query(default=200, ge=1, le=1000),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
return {"events": await store.list_events(run_id, after_seq=after_seq, limit=limit)}
@router.get("/runs/{run_id}/stream")
async def stream_run(
request: Request,
run_id: str,
after_seq: int = Query(default=0, ge=0),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
hub = getattr(request.app.state, "report_collaboration_live_hub", None)
cursor = replay_cursor(last_event_id or request.headers.get("last-event-id"), after_seq)
settings = getattr(request.app.state, "report_collaboration_sse_settings", None)
async def generator():
async def _disconnected() -> bool:
return await request.is_disconnected()
async for chunk in iter_run_sse(
store=store,
hub=hub,
run_id=run_id,
cursor=cursor,
is_disconnected=_disconnected,
settings=settings,
):
yield chunk
return StreamingResponse(
generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Report-Collaboration-Run-Status": str(run.get("status") or ""),
},
)
@router.post("/runs/{run_id}/cancel")
async def cancel_run(
request: Request,
run_id: str,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
try:
return await store.request_cancel_run(run_id, idempotency_key=key)
except Exception as exc:
return _map_store_error(exc)
@router.post("/runs/{run_id}/commands")
async def create_command(
request: Request,
run_id: str,
body: CreateCommandBody,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
try:
return await _runtime_commands(request).submit(
run_id,
text=body.text,
idempotency_key=key,
target_node_id=body.target_node_id,
agent_run_id=body.agent_run_id,
)
except Exception as exc:
return _map_store_error(exc)
@router.post("/runs/{run_id}/commands/{command_id}/confirm")
async def confirm_command(
request: Request,
run_id: str,
command_id: str,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
try:
return await _runtime_commands(request).confirm(run_id, command_id, idempotency_key=key)
except Exception as exc:
return _map_store_error(exc)
@router.post("/runs/{run_id}/commands/{command_id}/cancel")
async def cancel_command(
request: Request,
run_id: str,
command_id: str,
x_idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
key = _require_idempotency(x_idempotency_key)
if isinstance(key, JSONResponse):
return key
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
try:
return await _runtime_commands(request).cancel(run_id, command_id, idempotency_key=key)
except Exception as exc:
return _map_store_error(exc)
@router.get("/runs/{run_id}/nodes/{node_id}/contract")
async def get_node_contract(request: Request, run_id: str, node_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
try:
return await store.get_node_contract(run_id, node_id)
except Exception as exc:
return _map_store_error(exc)
@router.get("/runs/{run_id}/artifacts")
async def list_artifacts(request: Request, run_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
store = _store(request)
run = await _owned_run(store, run_id, user_id)
if isinstance(run, JSONResponse):
return run
return {"artifacts": await store.list_artifacts(run_id)}
@router.get("/runs/{run_id}/sources")
async def list_sources(request: Request, run_id: str):
if disabled := _require_enabled():
return disabled
user_id = await _require_user(request)
run = await _owned_run(_store(request), run_id, user_id)
if isinstance(run, JSONResponse):
return run
return {"sources": []}