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

247 lines
11 KiB
Python

"""Task-scoped submission status for roundtable artifacts.
This API records only an artifact reference (thread + sandbox path) and its
submission state. It intentionally does not fetch task detail, copy Markdown,
or deliver a file to an external task system.
Routes (authenticated by the Gateway middleware):
GET /api/roundtable/tasks/{task_id}/artifacts?submitted=true
PUT /api/roundtable/tasks/{task_id}/artifacts/submission
"""
from __future__ import annotations
from pathlib import PurePosixPath
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator
from app.gateway.deps import get_current_user
from deerflow.persistence.roundtable_artifact_submissions import RoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict
router = APIRouter(prefix="/api/roundtable/tasks", tags=["roundtable-artifact-submissions"])
class ArtifactReferenceInput(BaseModel):
"""Reference to a generated file; the file content remains in its thread sandbox."""
model_config = ConfigDict(populate_by_name=True)
session_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("sessionId", "session_id"))
thread_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("threadId", "thread_id"))
node_key: StrictStr | None = Field(default=None, max_length=256, validation_alias=AliasChoices("nodeKey", "node_key"))
source_key: StrictStr = Field(..., min_length=1, max_length=512, validation_alias=AliasChoices("sourceKey", "source_key"))
path: StrictStr = Field(..., min_length=1, max_length=2048)
name: StrictStr | None = Field(default=None, max_length=512)
mime_type: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("mimeType", "mime_type"))
revision: StrictInt = Field(..., ge=0)
@field_validator("session_id", "thread_id", "node_key", "source_key", "path", "name", "mime_type", mode="before")
@classmethod
def _trim_text(cls, value: Any) -> Any:
if isinstance(value, str):
value = value.strip()
return value or None
return value
class ArtifactSubmissionRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
task_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("taskId", "task_id"))
submitted: bool
register_only: bool = Field(default=False, validation_alias=AliasChoices("registerOnly", "register_only"))
submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id"))
task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id"))
expected_version: int | None = Field(default=None, ge=0, validation_alias=AliasChoices("expectedVersion", "expected_version"))
artifact: ArtifactReferenceInput
@field_validator("task_id", "submission_id", "task_delivery_id", mode="before")
@classmethod
def _trim_optional_text(cls, value: Any) -> Any:
if isinstance(value, str):
value = value.strip()
return value or None
return value
class ArtifactReferenceResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
session_id: str | None = Field(default=None, serialization_alias="sessionId")
thread_id: str | None = Field(default=None, serialization_alias="threadId")
node_key: str | None = Field(default=None, serialization_alias="nodeKey")
source_key: str = Field(serialization_alias="sourceKey")
path: str
name: str | None = None
mime_type: str | None = Field(default=None, serialization_alias="mimeType")
revision: int
class ArtifactSubmissionResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
task_id: str = Field(serialization_alias="taskId")
version: int = Field(default=0)
submitted: bool
submitted_at: str | None = Field(default=None, serialization_alias="submittedAt")
cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt")
task_delivery_id: str | None = Field(default=None, serialization_alias="taskDeliveryId")
artifact: ArtifactReferenceResponse
class ArtifactSubmissionListResponse(BaseModel):
items: list[ArtifactSubmissionResponse]
def _get_store(request: Request) -> RoundtableArtifactSubmissionStore:
store = getattr(request.app.state, "roundtable_artifact_submission_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable artifact submission store not available")
return store
def _task_id_or_422(value: str) -> str:
task_id = value.strip()
if not task_id:
raise HTTPException(status_code=422, detail="task id is required")
if len(task_id) > 128:
raise HTTPException(status_code=422, detail="task id must be at most 128 characters")
return task_id
def _artifact_payload(artifact: ArtifactReferenceInput) -> dict[str, Any]:
source_key = artifact.source_key
path = artifact.path
if not source_key or not path:
raise HTTPException(status_code=422, detail="artifact.sourceKey and artifact.path are required")
name = artifact.name or PurePosixPath(path).name or path
return {
"session_id": artifact.session_id,
"thread_id": artifact.thread_id,
"node_key": artifact.node_key,
"source_key": source_key,
"path": path,
"name": name,
"mime_type": artifact.mime_type,
"revision": artifact.revision,
}
def _response(row: dict[str, Any]) -> ArtifactSubmissionResponse:
return ArtifactSubmissionResponse(
id=str(row["id"]),
task_id=str(row["task_id"]),
version=int(row.get("version", 0)),
submitted=bool(row["submitted"]),
submitted_at=row.get("submitted_at"),
cancelled_at=row.get("cancelled_at"),
task_delivery_id=row.get("task_delivery_id"),
artifact=ArtifactReferenceResponse(
session_id=row.get("session_id"),
thread_id=row.get("thread_id"),
node_key=row.get("node_key"),
source_key=str(row["source_key"]),
path=str(row["path"]),
name=row.get("name"),
mime_type=row.get("mime_type"),
revision=int(row["revision"]),
),
)
@router.get("/{task_id}/artifacts", response_model=ArtifactSubmissionListResponse)
async def list_artifact_submissions(
request: Request,
task_id: str,
submitted: bool | None = Query(default=True),
) -> ArtifactSubmissionListResponse:
"""List task artifacts by submission state (defaults to submitted only)."""
rows = await _get_store(request).list_by_task(_task_id_or_422(task_id), submitted=submitted)
return ArtifactSubmissionListResponse(items=[_response(row) for row in rows])
@router.put("/{task_id}/artifacts/submission", response_model=ArtifactSubmissionResponse)
async def set_artifact_submission(
request: Request,
task_id: str,
body: ArtifactSubmissionRequest,
) -> ArtifactSubmissionResponse:
"""Create/update a submission or retain an audit row while cancelling it."""
path_task_id = _task_id_or_422(task_id)
if body.task_id is not None and body.task_id != path_task_id:
raise HTTPException(status_code=422, detail="taskId must match the path task id")
artifact = _artifact_payload(body.artifact)
actor_id = await get_current_user(request)
store = _get_store(request)
if body.submitted:
# The frontend previews submitted artifacts by threadId + path. Without
# a thread reference a saved state could never be previewed correctly.
if not artifact["thread_id"]:
raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true")
try:
row = await store.submit(
path_task_id,
artifact,
task_delivery_id=body.task_delivery_id,
actor_id=actor_id,
expected_version=body.expected_version,
)
except SubmissionVersionConflict as conflict:
raise HTTPException(
status_code=409,
detail={
"code": "version_conflict",
"message": "提交产物版本冲突:另一写入已先提交,请刷新后重试。",
"current_version": conflict.row.get("version", 0),
"submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False),
},
) from conflict
elif body.register_only:
if not artifact["thread_id"]:
raise HTTPException(status_code=422, detail="artifact.threadId is required when registering an artifact")
try:
row = await store.register(
path_task_id,
artifact,
task_delivery_id=body.task_delivery_id,
actor_id=actor_id,
expected_version=body.expected_version,
)
except SubmissionVersionConflict as conflict:
raise HTTPException(
status_code=409,
detail={
"code": "version_conflict",
"message": "登记产物版本冲突:另一写入已更新,请刷新后重试。",
"current_version": conflict.row.get("version", 0),
"submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False),
},
) from conflict
else:
try:
row = await store.cancel(
path_task_id,
artifact,
submission_id=body.submission_id,
actor_id=actor_id,
expected_version=body.expected_version,
)
except SubmissionVersionConflict as conflict:
raise HTTPException(
status_code=409,
detail={
"code": "version_conflict",
"message": "取消产物版本冲突:另一写入已先提交,旧取消不能撤销新提交。",
"current_version": conflict.row.get("version", 0),
"submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False),
},
) from conflict
if row is None:
raise HTTPException(status_code=404, detail="Artifact submission not found")
return _response(row)