Files

191 lines
6.7 KiB
Python

"""Task-detail API for externally initiated single-agent deep links.
The detail snapshot is deliberately owned by DeerFlow. The embedding system
writes it once, then the browser only reads it after login; no frontend call is
made back to the embedding system for task content.
"""
from __future__ import annotations
import json
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Path, Query, Request
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
from app.gateway.deps import get_task_deeplink_detail_store
router = APIRouter(prefix="/api/task-deeplink-details", tags=["task-deeplink-details"])
_B5_DETAIL_TYPE = "b5-1"
_MAX_EXTRA_BYTES = 64 * 1024
_MAX_SUB_TYPE = 64
class TaskDeeplinkDetailUpsertRequest(BaseModel):
"""Complete task snapshot supplied by the embedding system.
``extra`` is intentionally an object, not a bag of top-level fields, so
the stable contract stays small while integrators can attach their own
structured fields. ``type`` is the business type (the deep-link
``?type=``): the same task id may hold one snapshot per type; omitting it
addresses the legacy no-type partition.
"""
model_config = ConfigDict(populate_by_name=True)
task_name: str = Field(
min_length=1,
max_length=512,
validation_alias=AliasChoices("taskName", "task_name"),
)
task_content: str = Field(
min_length=1,
max_length=20_000,
validation_alias=AliasChoices("taskContent", "task_content"),
)
overview: str = Field(default="", max_length=4_000)
detail_sub_type: str = Field(
default="",
max_length=_MAX_SUB_TYPE,
validation_alias=AliasChoices("type", "taskType", "sub_type"),
)
extra: dict[str, Any] = Field(default_factory=dict)
@field_validator("task_name", "task_content", "overview", "detail_sub_type")
@classmethod
def strip_text(cls, value: str) -> str:
return value.strip()
@field_validator("task_name", "task_content")
@classmethod
def require_nonblank_text(cls, value: str) -> str:
if not value:
raise ValueError("text must not be blank")
return value
@field_validator("extra")
@classmethod
def require_bounded_json_object(cls, value: dict[str, Any]) -> dict[str, Any]:
try:
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
raise ValueError("extra must be JSON serializable") from exc
if len(encoded.encode("utf-8")) > _MAX_EXTRA_BYTES:
raise ValueError(f"extra must not exceed {_MAX_EXTRA_BYTES} bytes")
return value
class TaskDeeplinkDetailResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
detail_type: Literal["b5-1"] = Field(serialization_alias="type")
detail_id: str = Field(serialization_alias="id")
detail_sub_type: str = Field(default="", serialization_alias="taskType")
task_name: str = Field(serialization_alias="taskName")
task_content: str = Field(serialization_alias="taskContent")
overview: str = ""
extra: dict[str, Any] = Field(default_factory=dict)
version: int
updated_at: str | None = Field(default=None, serialization_alias="updatedAt")
class TaskDeeplinkDetailEnvelope(BaseModel):
data: TaskDeeplinkDetailResponse
class TaskDeeplinkDetailUpsertEnvelope(TaskDeeplinkDetailEnvelope):
created: bool
changed: bool
def _detail_response(row: dict[str, Any]) -> TaskDeeplinkDetailResponse:
return TaskDeeplinkDetailResponse(
detail_type=row["detail_type"],
detail_id=row["detail_id"],
detail_sub_type=str(row.get("sub_type") or ""),
task_name=row["task_name"],
task_content=row["task_content"],
overview=row.get("overview") or "",
extra=row.get("extra") or {},
version=int(row.get("version") or 1),
updated_at=str(row["updated_at"]) if row.get("updated_at") else None,
)
def _normalized_detail_id(detail_id: str) -> str:
normalized = detail_id.strip()
if not normalized:
raise HTTPException(status_code=422, detail="detail id must not be blank")
return normalized
def _writer_id(request: Request) -> str:
"""Audit identity for a snapshot write.
The PUT endpoint is credential-free (integration systems write before the
user's browser is involved), so an unauthenticated caller is recorded as
the external integrator; a logged-in browser write keeps the user id.
"""
user = getattr(request.state, "user", None)
if user is not None:
return str(getattr(user, "id", "system"))
return "external:task-deeplink"
@router.get("/{detail_type}/{detail_id}", response_model=TaskDeeplinkDetailEnvelope)
async def get_task_deeplink_detail(
request: Request,
detail_type: Literal["b5-1"],
detail_id: str = Path(min_length=1, max_length=128),
type: str = Query(default="", max_length=_MAX_SUB_TYPE),
) -> TaskDeeplinkDetailEnvelope:
"""Read one persisted snapshot for a post-login deep-link workspace.
``?type=`` selects the business-type partition written via the PUT body's
``type`` field; omitted means the legacy no-type partition.
"""
store = get_task_deeplink_detail_store(request)
row = await store.get_detail(
detail_type,
_normalized_detail_id(detail_id),
sub_type=type.strip(),
)
if row is None:
raise HTTPException(status_code=404, detail="Task deep-link detail not found")
return TaskDeeplinkDetailEnvelope(data=_detail_response(row))
@router.put("/{detail_type}/{detail_id}", response_model=TaskDeeplinkDetailUpsertEnvelope)
async def upsert_task_deeplink_detail(
request: Request,
body: TaskDeeplinkDetailUpsertRequest,
detail_type: Literal["b5-1"],
detail_id: str = Path(min_length=1, max_length=128),
) -> TaskDeeplinkDetailUpsertEnvelope:
"""Idempotently create or update a snapshot before ``goPath=b5-1``.
Open write endpoint: no login session or integration key is required
(AuthMiddleware/CSRF let the PUT through); the writer is only recorded
for audit. Reads still require a normal authenticated session.
"""
store = get_task_deeplink_detail_store(request)
row, created, changed = await store.upsert_detail(
detail_type,
_normalized_detail_id(detail_id),
sub_type=body.detail_sub_type,
task_name=body.task_name,
task_content=body.task_content,
overview=body.overview,
extra=body.extra,
updated_by=_writer_id(request),
)
return TaskDeeplinkDetailUpsertEnvelope(
data=_detail_response(row),
created=created,
changed=changed,
)