Files
RAG/backend/app/api/v1/documents.py
YoVinchen ecdb10c37a
Some checks failed
verify / verify (push) Has been cancelled
Make the governed RAG evidence path executable end to end
Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
2026-07-13 05:58:11 +08:00

907 lines
28 KiB
Python

"""Governed asynchronous document-upload and review HTTP API."""
from __future__ import annotations
import asyncio
import re
import uuid
from datetime import datetime
from pathlib import PurePosixPath
from typing import Annotated, Literal, cast
from fastapi import APIRouter, Depends, Header, Query, Request, status
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationInfo,
field_validator,
model_validator,
)
from starlette.requests import ClientDisconnect
from app.adapters.local_storage import (
LocalStorageError,
LocalUploadStorage,
StorageErrorCode,
)
from app.core.config import Settings, get_settings
from app.core.demo_identity import (
ACCESS_SCOPE_ID,
BAILIAN_ACCESS_SCOPE_ID,
BAILIAN_KNOWLEDGE_BASE_ID,
KNOWLEDGE_BASE_ID,
)
from app.core.problems import ApiProblem
from app.persistence.document_review import (
DocumentReviewConflictError,
DocumentReviewError,
DocumentReviewNotFoundError,
DocumentReviewResult,
DocumentReviewStateError,
PostgresDocumentReviewRepository,
)
from app.persistence.documents import (
CompletedUpload,
DocumentActor,
DocumentDetail,
DocumentListPage,
DocumentPersistenceError,
DocumentsRepository,
DocumentSummary,
DocumentUpload,
IdempotencyConflictError,
PostgresDocumentsRepository,
ReviewBlock,
ReviewBundle,
ReviewChunk,
ReviewPage,
ReviewVersion,
SafeJob,
UploadStateConflictError,
idempotency_key_hash,
upload_request_fingerprint,
)
_DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
_SECRET = re.compile(r"(?i)(?:sk-[A-Za-z0-9_-]{16,}|Bearer\s+[A-Za-z0-9._~+/-]{16,})")
_SYNTHETIC_ACTOR = DocumentActor(
subject="synthetic-demo-maintainer",
knowledge_base_id=KNOWLEDGE_BASE_ID,
access_scope_id=ACCESS_SCOPE_ID,
)
_BAILIAN_SYNTHETIC_ACTOR = DocumentActor(
subject="synthetic-bailian-maintainer",
knowledge_base_id=BAILIAN_KNOWLEDGE_BASE_ID,
access_scope_id=BAILIAN_ACCESS_SCOPE_ID,
)
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class CreateDocumentUploadRequest(StrictModel):
filename: str = Field(min_length=1, max_length=240)
declared_mime_type: Literal[
"text/plain",
"text/markdown",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]
expected_size: int = Field(ge=1, le=2_147_483_648)
expected_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
@field_validator("filename")
@classmethod
def validate_filename(cls, value: str) -> str:
if (
value != value.strip()
or "\x00" in value
or "/" in value
or "\\" in value
or _SECRET.search(value)
):
raise ValueError("filename is not safe")
return value
@field_validator("declared_mime_type")
@classmethod
def validate_extension_matches_mime(cls, mime: str, info: ValidationInfo) -> str:
filename = cast_filename(info.data.get("filename"))
suffix = PurePosixPath(filename).suffix.lower()
accepted = {
".txt": {"text/plain"},
".md": {"text/plain", "text/markdown"},
".markdown": {"text/plain", "text/markdown"},
".pdf": {"application/pdf"},
".docx": {_DOCX_MIME},
}
if mime not in accepted.get(suffix, set()):
raise ValueError("filename extension does not match media type")
return mime
class UploadResponse(StrictModel):
id: uuid.UUID
filename: str
declared_mime_type: str
expected_size: int
expected_sha256: str
actual_size: int | None
actual_sha256: str | None
status: Literal["CREATED", "STORED", "COMPLETED"]
document_id: uuid.UUID | None
parse_job_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
completed_at: datetime | None
replayed: bool = False
class SafeJobResponse(StrictModel):
id: uuid.UUID
job_type: str
stage: str
status: Literal["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"]
progress: int = Field(ge=0, le=100)
attempt: int = Field(ge=0)
max_attempts: int = Field(ge=1)
last_error_code: str | None
created_at: datetime
updated_at: datetime
finished_at: datetime | None
class DocumentSummaryResponse(StrictModel):
id: uuid.UUID
filename: str
mime_type: str
raw_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
status: str
active_version_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
class DocumentDetailResponse(StrictModel):
document: DocumentSummaryResponse
version_count: int = Field(ge=0)
page_count: int = Field(ge=0)
block_count: int = Field(ge=0)
chunk_count: int = Field(ge=0)
class DocumentListResponse(StrictModel):
items: list[DocumentSummaryResponse]
next_cursor: uuid.UUID | None
class CompleteUploadResponse(StrictModel):
upload: UploadResponse
document: DocumentSummaryResponse
job: SafeJobResponse
class ReviewVersionResponse(StrictModel):
id: uuid.UUID
review_state: str
review_revision: int = Field(ge=0)
status: str
parser_profile_hash: str
chunk_profile_hash: str
cloud_policy_id: str
outbound_manifest_sha256: str | None
expected_chunk_count: int | None
error_code: str | None
created_at: datetime
completed_at: datetime | None
class ReviewPageResponse(StrictModel):
id: uuid.UUID
ordinal: int
page_number: int | None
text: str
text_sha256: str
line_start: int
line_end: int
class ReviewBlockResponse(StrictModel):
id: uuid.UUID
ordinal: int
kind: str
text: str
text_sha256: str
section_path: list[str]
anchor_id: str
char_start: int
char_end: int
line_start: int
line_end: int
page_start: int | None
page_end: int | None
class ReviewChunkResponse(StrictModel):
ordinal: int
display_text: str
cloud_text: str
cloud_text_sha256: str
embedding_text_sha256: str
token_count: int
page_start: int | None
page_end: int | None
section_path: list[str]
approval_status: str
index_status: str
class ReviewBundleResponse(StrictModel):
document: DocumentSummaryResponse
version: ReviewVersionResponse | None
pages: list[ReviewPageResponse]
blocks: list[ReviewBlockResponse]
chunks: list[ReviewChunkResponse]
next_ordinal: int | None
class DocumentReviewDecisionRequest(StrictModel):
decision: Literal["APPROVE", "REJECT"]
reason_code: Literal[
"SYNTHETIC_REVIEW_APPROVED",
"RIGHTS_NOT_VERIFIED",
"CONTENT_QUALITY_REJECTED",
"CLOUD_PROCESSING_REJECTED",
]
expected_revision: int = Field(ge=0)
outbound_manifest_sha256: str | None = Field(
default=None,
pattern=r"^[0-9a-f]{64}$",
)
@model_validator(mode="after")
def validate_decision_contract(self) -> DocumentReviewDecisionRequest:
if self.decision == "APPROVE":
if (
self.reason_code != "SYNTHETIC_REVIEW_APPROVED"
or self.outbound_manifest_sha256 is None
):
raise ValueError("approval requires the reviewed manifest")
elif (
self.reason_code == "SYNTHETIC_REVIEW_APPROVED"
or self.outbound_manifest_sha256 is not None
):
raise ValueError("rejection requires a rejection reason and no manifest")
return self
class DocumentReviewDecisionResponse(StrictModel):
document_id: uuid.UUID
document_version_id: uuid.UUID
decision: Literal["APPROVE", "REJECT"]
review_state: Literal["CLOUD_APPROVED", "REJECTED"]
review_revision: int = Field(ge=1)
outbound_manifest_sha256: str | None
embedding_profile_hash: str | None
job: SafeJobResponse | None
def cast_filename(value: object) -> str:
return value if isinstance(value, str) else ""
def get_document_actor(
settings: Annotated[Settings, Depends(get_settings)],
) -> DocumentActor:
"""Return a server-owned synthetic grant; requests cannot select a scope."""
if settings.document_namespace_mode == "bailian":
return _BAILIAN_SYNTHETIC_ACTOR
return _SYNTHETIC_ACTOR
def get_documents_repository(
settings: Annotated[Settings, Depends(get_settings)],
) -> DocumentsRepository:
return PostgresDocumentsRepository(settings)
def get_upload_storage(
settings: Annotated[Settings, Depends(get_settings)],
) -> LocalUploadStorage:
return LocalUploadStorage(
settings.upload_root,
max_bytes=settings.max_upload_mb * 1024 * 1024,
)
def get_document_review_repository(
settings: Annotated[Settings, Depends(get_settings)],
) -> PostgresDocumentReviewRepository:
return PostgresDocumentReviewRepository(settings)
def parse_idempotency_key(
value: Annotated[str, Header(alias="Idempotency-Key")],
) -> uuid.UUID:
try:
return uuid.UUID(value)
except (ValueError, AttributeError):
raise ApiProblem(
status=422,
code="IDEMPOTENCY_KEY_INVALID",
title="Idempotency key is invalid",
detail="Idempotency-Key must be a UUID.",
) from None
router = APIRouter(
prefix="/api/v1",
tags=["documents"],
)
@router.post(
"/document-uploads",
response_model=UploadResponse,
status_code=status.HTTP_201_CREATED,
operation_id="createDocumentUpload",
)
def create_document_upload(
body: CreateDocumentUploadRequest,
request: Request,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
settings: Annotated[Settings, Depends(get_settings)],
key: Annotated[uuid.UUID, Depends(parse_idempotency_key)],
) -> UploadResponse:
if body.expected_size > settings.max_upload_mb * 1024 * 1024:
raise ApiProblem(
status=413,
code="UPLOAD_TOO_LARGE",
title="Upload is too large",
detail="The declared upload size exceeds the configured limit.",
)
fingerprint = upload_request_fingerprint(
filename=body.filename,
declared_mime_type=body.declared_mime_type,
expected_size=body.expected_size,
expected_sha256=body.expected_sha256,
)
try:
upload, created = repository.create_upload(
actor=actor,
idempotency_key_hash=idempotency_key_hash(actor, key),
request_fingerprint=fingerprint,
filename=body.filename,
declared_mime_type=body.declared_mime_type,
expected_size=body.expected_size,
expected_sha256=body.expected_sha256,
storage_key=uuid.uuid4(),
trace_id=_trace_id(request),
)
except IdempotencyConflictError:
raise ApiProblem(
status=409,
code="IDEMPOTENCY_CONFLICT",
title="Idempotency conflict",
detail="The key was already used for a different upload declaration.",
) from None
except DocumentPersistenceError:
raise _persistence_problem() from None
return _upload_response(upload, replayed=not created)
@router.put(
"/document-uploads/{upload_id}/content",
response_model=UploadResponse,
operation_id="storeDocumentUploadContent",
openapi_extra={
"requestBody": {
"required": True,
"content": {
"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}
},
}
},
)
async def store_document_upload_content(
upload_id: uuid.UUID,
request: Request,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
storage: Annotated[LocalUploadStorage, Depends(get_upload_storage)],
) -> UploadResponse:
if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != (
"application/octet-stream"
):
raise ApiProblem(
status=415,
code="UPLOAD_CONTENT_TYPE_INVALID",
title="Upload content type is invalid",
detail="Upload bytes require application/octet-stream.",
)
try:
upload = await asyncio.to_thread(repository.get_upload, actor, upload_id)
except DocumentPersistenceError:
raise _persistence_problem() from None
if upload is None:
raise _not_found_problem()
if upload.status == "COMPLETED":
return _upload_response(upload)
declared_length = request.headers.get("content-length")
if declared_length is not None:
try:
length = int(declared_length)
except ValueError:
raise ApiProblem(
status=400,
code="CONTENT_LENGTH_INVALID",
title="Content length is invalid",
detail="Content-Length must be a decimal byte count.",
) from None
if length != upload.expected_size:
raise ApiProblem(
status=422,
code="UPLOAD_SIZE_MISMATCH",
title="Upload size mismatch",
detail="The streamed byte count must match the upload declaration.",
)
try:
stored = await storage.store(
storage_key=upload.storage_key,
chunks=request.stream(),
expected_size=upload.expected_size,
expected_sha256=upload.expected_sha256,
)
updated = await asyncio.to_thread(
repository.mark_upload_stored,
actor=actor,
upload_id=upload_id,
actual_size=stored.byte_size,
actual_sha256=stored.sha256,
trace_id=_trace_id(request),
)
except ClientDisconnect:
raise ApiProblem(
status=400,
code="UPLOAD_INTERRUPTED",
title="Upload was interrupted",
detail="The upload stream ended before it was complete.",
) from None
except LocalStorageError as exc:
raise _storage_problem(exc.code) from None
except UploadStateConflictError:
raise _state_problem() from None
except DocumentPersistenceError:
raise _persistence_problem() from None
return _upload_response(updated)
@router.post(
"/document-uploads/{upload_id}/complete",
response_model=CompleteUploadResponse,
status_code=status.HTTP_202_ACCEPTED,
operation_id="completeDocumentUpload",
)
def complete_document_upload(
upload_id: uuid.UUID,
request: Request,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
) -> CompleteUploadResponse:
try:
completed = repository.complete_upload(
actor=actor,
upload_id=upload_id,
trace_id=_trace_id(request),
)
except UploadStateConflictError:
raise _state_problem() from None
except DocumentPersistenceError:
raise _persistence_problem() from None
return _completed_response(completed)
@router.get(
"/document-uploads/{upload_id}",
response_model=UploadResponse,
operation_id="getDocumentUpload",
)
def get_document_upload(
upload_id: uuid.UUID,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
) -> UploadResponse:
try:
upload = repository.get_upload(actor, upload_id)
except DocumentPersistenceError:
raise _persistence_problem() from None
if upload is None:
raise _not_found_problem()
return _upload_response(upload)
@router.get(
"/document-jobs/{job_id}",
response_model=SafeJobResponse,
operation_id="getDocumentJob",
)
def get_document_job(
job_id: uuid.UUID,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
) -> SafeJobResponse:
try:
job = repository.get_job(actor, job_id)
except DocumentPersistenceError:
raise _persistence_problem() from None
if job is None:
raise _not_found_problem()
return _job_response(job)
@router.get(
"/documents",
response_model=DocumentListResponse,
operation_id="listDocuments",
)
def list_documents(
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
cursor: Annotated[uuid.UUID | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> DocumentListResponse:
try:
page = repository.list_documents(actor, cursor=cursor, limit=limit)
except DocumentPersistenceError:
raise _persistence_problem() from None
return _document_list_response(page)
@router.get(
"/documents/{document_id}",
response_model=DocumentDetailResponse,
operation_id="getDocument",
)
def get_document(
document_id: uuid.UUID,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
) -> DocumentDetailResponse:
try:
detail = repository.get_document(actor, document_id)
except DocumentPersistenceError:
raise _persistence_problem() from None
if detail is None:
raise _not_found_problem()
return _document_detail_response(detail)
@router.get(
"/documents/{document_id}/review-bundle",
response_model=ReviewBundleResponse,
operation_id="getDocumentReviewBundle",
)
def get_document_review_bundle(
document_id: uuid.UUID,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[DocumentsRepository, Depends(get_documents_repository)],
after_ordinal: Annotated[int, Query(ge=-1)] = -1,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> ReviewBundleResponse:
try:
bundle = repository.get_review_bundle(
actor,
document_id,
after_ordinal=after_ordinal,
limit=limit,
)
except DocumentPersistenceError:
raise _persistence_problem() from None
if bundle is None:
raise _not_found_problem()
return _review_bundle_response(bundle)
@router.post(
"/documents/{document_id}/review-decisions",
response_model=DocumentReviewDecisionResponse,
status_code=status.HTTP_202_ACCEPTED,
operation_id="createDocumentReviewDecision",
)
def create_document_review_decision(
document_id: uuid.UUID,
body: DocumentReviewDecisionRequest,
request: Request,
actor: Annotated[DocumentActor, Depends(get_document_actor)],
repository: Annotated[
PostgresDocumentReviewRepository,
Depends(get_document_review_repository),
],
) -> DocumentReviewDecisionResponse:
try:
result = repository.apply_decision(
actor=actor,
document_id=document_id,
decision=body.decision,
reason_code=body.reason_code,
expected_revision=body.expected_revision,
outbound_manifest_sha256=body.outbound_manifest_sha256,
trace_id=_trace_id(request),
)
except DocumentReviewNotFoundError:
raise _not_found_problem() from None
except DocumentReviewConflictError:
raise ApiProblem(
status=412,
code="REVIEW_REVISION_CONFLICT",
title="Review revision conflict",
detail="The review bundle changed; reload it before deciding.",
) from None
except DocumentReviewStateError:
raise ApiProblem(
status=409,
code="REVIEW_STATE_CONFLICT",
title="Document is not reviewable",
detail="The latest document version is not eligible for this decision.",
) from None
except DocumentReviewError:
raise _persistence_problem() from None
return _review_decision_response(result)
def _trace_id(request: Request) -> uuid.UUID:
try:
return uuid.UUID(str(request.state.trace_id))
except (ValueError, AttributeError):
return uuid.uuid4()
def _upload_response(upload: DocumentUpload, *, replayed: bool = False) -> UploadResponse:
return UploadResponse(
id=upload.id,
filename=upload.filename,
declared_mime_type=upload.declared_mime_type,
expected_size=upload.expected_size,
expected_sha256=upload.expected_sha256,
actual_size=upload.actual_size,
actual_sha256=upload.actual_sha256,
status=cast_upload_status(upload.status),
document_id=upload.document_id,
parse_job_id=upload.parse_job_id,
created_at=upload.created_at,
updated_at=upload.updated_at,
completed_at=upload.completed_at,
replayed=replayed,
)
def cast_upload_status(value: str) -> Literal["CREATED", "STORED", "COMPLETED"]:
if value not in {"CREATED", "STORED", "COMPLETED"}:
raise DocumentPersistenceError
return cast(Literal["CREATED", "STORED", "COMPLETED"], value)
def _job_response(job: SafeJob) -> SafeJobResponse:
return SafeJobResponse(
id=job.id,
job_type=job.job_type,
stage=job.stage,
status=cast_job_status(job.status),
progress=job.progress,
attempt=job.attempt,
max_attempts=job.max_attempts,
last_error_code=job.last_error_code,
created_at=job.created_at,
updated_at=job.updated_at,
finished_at=job.finished_at,
)
def cast_job_status(
value: str,
) -> Literal["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"]:
if value not in {"QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"}:
raise DocumentPersistenceError
return cast(Literal["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"], value)
def _document_response(document: DocumentSummary) -> DocumentSummaryResponse:
return DocumentSummaryResponse(
id=document.id,
filename=document.filename,
mime_type=document.mime_type,
raw_sha256=document.raw_sha256,
status=document.status,
active_version_id=document.active_version_id,
created_at=document.created_at,
updated_at=document.updated_at,
)
def _document_detail_response(detail: DocumentDetail) -> DocumentDetailResponse:
return DocumentDetailResponse(
document=_document_response(detail.document),
version_count=detail.version_count,
page_count=detail.page_count,
block_count=detail.block_count,
chunk_count=detail.chunk_count,
)
def _document_list_response(page: DocumentListPage) -> DocumentListResponse:
return DocumentListResponse(
items=[_document_response(item) for item in page.items],
next_cursor=page.next_cursor,
)
def _completed_response(value: CompletedUpload) -> CompleteUploadResponse:
return CompleteUploadResponse(
upload=_upload_response(value.upload),
document=_document_response(value.document),
job=_job_response(value.job),
)
def _review_version_response(value: ReviewVersion) -> ReviewVersionResponse:
return ReviewVersionResponse(
id=value.id,
review_state=value.review_state,
review_revision=value.review_revision,
status=value.status,
parser_profile_hash=value.parser_profile_hash,
chunk_profile_hash=value.chunk_profile_hash,
cloud_policy_id=value.cloud_policy_id,
outbound_manifest_sha256=value.outbound_manifest_sha256,
expected_chunk_count=value.expected_chunk_count,
error_code=value.error_code,
created_at=value.created_at,
completed_at=value.completed_at,
)
def _review_page_response(value: ReviewPage) -> ReviewPageResponse:
return ReviewPageResponse(
id=value.id,
ordinal=value.ordinal,
page_number=value.page_number,
text=value.text,
text_sha256=value.text_sha256,
line_start=value.line_start,
line_end=value.line_end,
)
def _review_block_response(value: ReviewBlock) -> ReviewBlockResponse:
return ReviewBlockResponse(
id=value.id,
ordinal=value.ordinal,
kind=value.kind,
text=value.text,
text_sha256=value.text_sha256,
section_path=list(value.section_path),
anchor_id=value.anchor_id,
char_start=value.char_start,
char_end=value.char_end,
line_start=value.line_start,
line_end=value.line_end,
page_start=value.page_start,
page_end=value.page_end,
)
def _review_chunk_response(value: ReviewChunk) -> ReviewChunkResponse:
return ReviewChunkResponse(
ordinal=value.ordinal,
display_text=value.display_text,
cloud_text=value.cloud_text,
cloud_text_sha256=value.cloud_text_sha256,
embedding_text_sha256=value.embedding_text_sha256,
token_count=value.token_count,
page_start=value.page_start,
page_end=value.page_end,
section_path=list(value.section_path),
approval_status=value.approval_status,
index_status=value.index_status,
)
def _review_bundle_response(value: ReviewBundle) -> ReviewBundleResponse:
return ReviewBundleResponse(
document=_document_response(value.document),
version=(_review_version_response(value.version) if value.version is not None else None),
pages=[_review_page_response(item) for item in value.pages],
blocks=[_review_block_response(item) for item in value.blocks],
chunks=[_review_chunk_response(item) for item in value.chunks],
next_ordinal=value.next_ordinal,
)
def _review_decision_response(
value: DocumentReviewResult,
) -> DocumentReviewDecisionResponse:
return DocumentReviewDecisionResponse(
document_id=value.document_id,
document_version_id=value.document_version_id,
decision=value.decision,
review_state=value.review_state,
review_revision=value.review_revision,
outbound_manifest_sha256=value.outbound_manifest_sha256,
embedding_profile_hash=value.embedding_profile_hash,
job=_job_response(value.job) if value.job is not None else None,
)
def _not_found_problem() -> ApiProblem:
return ApiProblem(
status=404,
code="DOCUMENT_RESOURCE_NOT_FOUND",
title="Document resource not found",
detail="The requested resource is unavailable to the current identity.",
)
def _state_problem() -> ApiProblem:
return ApiProblem(
status=409,
code="UPLOAD_STATE_CONFLICT",
title="Upload state conflict",
detail="The upload is not ready for this operation.",
)
def _persistence_problem() -> ApiProblem:
return ApiProblem(
status=503,
code="DOCUMENT_PERSISTENCE_UNAVAILABLE",
title="Document service unavailable",
detail="Document metadata is temporarily unavailable.",
)
def _storage_problem(code: StorageErrorCode) -> ApiProblem:
mapping = {
StorageErrorCode.TOO_LARGE: (413, "UPLOAD_TOO_LARGE", "Upload is too large"),
StorageErrorCode.SIZE_MISMATCH: (
422,
"UPLOAD_SIZE_MISMATCH",
"Upload size mismatch",
),
StorageErrorCode.HASH_MISMATCH: (
422,
"UPLOAD_HASH_MISMATCH",
"Upload digest mismatch",
),
StorageErrorCode.OBJECT_CONFLICT: (
409,
"UPLOAD_OBJECT_CONFLICT",
"Upload object conflict",
),
StorageErrorCode.INVALID_CONTRACT: (
422,
"UPLOAD_CONTRACT_INVALID",
"Upload contract is invalid",
),
StorageErrorCode.ROOT_UNSAFE: (
503,
"UPLOAD_STORAGE_UNSAFE",
"Upload storage unavailable",
),
StorageErrorCode.IO_UNAVAILABLE: (
503,
"UPLOAD_STORAGE_UNAVAILABLE",
"Upload storage unavailable",
),
}
status_code, public_code, title = mapping[code]
return ApiProblem(
status=status_code,
code=public_code,
title=title,
detail="The upload content could not be stored under the declared contract.",
)