Skip to content
Merged
11 changes: 11 additions & 0 deletions backend/app/api/docs/documents/initiate_v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Open a v2 upload session: get a URL and form fields to send a document straight to Kaapi's object storage.

Step 1 of the three-step flow:

1. `POST /api/v2/documents/uploads` with the filename — returns a `document_id`, an `upload_url`, and `upload_fields`.
2. Upload the file with a single `multipart/form-data` POST to `upload_url`: send every entry in `upload_fields` as a form field, then the file **last** in a field named `file`. No auth header on this call.
3. `PUT /api/v2/documents/{document_id}` to create the document — no body needed.

The filename is validated here (an unsupported type fails before any upload) and travels with the file, so registration in step 3 uses it automatically. Nothing is persisted at this step — hence the `200` rather than a `201` — the `document_id` only becomes a document once you register it.

Maximum file size is 25 MB, enforced by storage as the file uploads: a larger file is rejected outright with `400 EntityTooLarge` and nothing is stored. `upload_url` is valid for `expires_in` seconds (the effective value after server-side capping, which may be shorter than requested); open a new upload session if it lapses.
7 changes: 7 additions & 0 deletions backend/app/api/docs/documents/register_v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Register the document at the `document_id` issued by `POST /api/v2/documents/uploads`, from the file uploaded to its pre-signed URL.

Final step of the v2 upload flow, and it takes no request body. The uploaded object is moved to its permanent location, the document row is created with the filename captured at step 1, and the response carries a fresh signed URL for reading the file back.

Errors: `400` if nothing was uploaded for that `document_id` (the upload never happened or the URL lapsed); `409` if the `document_id` was already registered — open a new upload session in that case.

The 25 MB cap is enforced by storage while the file uploads, so an oversized file never reaches this step. Document transformation is not available on v2. Use `POST /api/v1/documents` if you need a `target_format` conversion.
5 changes: 4 additions & 1 deletion backend/app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
cron,
doc_transformation_job,
documents,
documents_v2,
evaluations,
features,
fine_tuning,
Expand Down Expand Up @@ -89,8 +90,10 @@


# v2 API surface (mounted at settings.API_V2_STR). Only the endpoints that differ
# from v1 live here — currently the judged run trigger. Everything else stays v1.
# from v1 live here — currently the judged run trigger and the pre-signed document
# upload flow. Everything else stays v1.
api_v2_router = APIRouter()
api_v2_router.include_router(documents_v2.router)
api_v2_router.include_router(evaluations_v2_router)
api_v2_router.include_router(evaluations_dataset_v2_router)
api_v2_router.include_router(evaluations_prompt_improvement_v2_router)
Expand Down
97 changes: 97 additions & 0 deletions backend/app/api/routes/documents_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""v2 document upload: pre-signed POST to a pending key, then registration."""

from pathlib import Path
from uuid import UUID, uuid4

from fastapi import APIRouter, Depends
from fastapi import Path as FastPath

from app.api.deps import AuthContextDep, SessionDep
from app.api.permissions import Permission, require_permission
from app.core.cloud import get_cloud_storage
from app.models import (
DocumentPublic,
DocumentUploadInitiateResponse,
DocumentUploadRequest,
)
from app.services.collections.helpers import MAX_DOC_SIZE_MB
from app.services.documents.registration import (
register_uploaded_document,
validate_filename_format,
)
from app.utils import APIResponse, load_description

router = APIRouter(prefix="/documents", tags=["Documents v2"])

UPLOAD_URL_EXPIRY_SECONDS = 3600
MAX_UPLOAD_BYTES = MAX_DOC_SIZE_MB * 1024 * 1024


@router.post(
"/uploads",
description=load_description("documents/initiate_v2.md"),
response_model=APIResponse[DocumentUploadInitiateResponse],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def create_upload_url(
session: SessionDep,
current_user: AuthContextDep,
request: DocumentUploadRequest,
) -> APIResponse[DocumentUploadInitiateResponse]:
validate_filename_format(request.filename)

storage = get_cloud_storage(session=session, project_id=current_user.project_.id)
document_id = uuid4()
ticket = storage.create_upload_ticket(
Path(str(document_id)),
filename=request.filename,
max_bytes=MAX_UPLOAD_BYTES,
expires_in=UPLOAD_URL_EXPIRY_SECONDS,
)

return APIResponse[DocumentUploadInitiateResponse].success_response(
DocumentUploadInitiateResponse(
document_id=document_id,
upload_url=ticket.url,
upload_fields=ticket.fields,
expires_in=ticket.expires_in,
),
metadata={
"next_step": (
f"Upload the file to the pre-signed S3 URL with the provided fields "
f"(file part last), then PUT /api/v2/documents/{document_id} to register the document."
)
},
)


@router.put(
"/{document_id}",
description=load_description("documents/register_v2.md"),
status_code=201,
response_model=APIResponse[DocumentPublic],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def register_document(
session: SessionDep,
current_user: AuthContextDep,
document_id: UUID = FastPath(
description="Document id issued by the upload session"
),
) -> APIResponse[DocumentPublic]:
document = register_uploaded_document(
session=session,
project_id=current_user.project_.id,
document_id=document_id,
)

storage = get_cloud_storage(session=session, project_id=current_user.project_.id)
document_schema = DocumentPublic.model_validate(document, from_attributes=True)
document_schema.signed_url = storage.get_signed_url(document.object_store_url)

return APIResponse[DocumentPublic].success_response(
document_schema,
metadata={
"note": "Document registered. The upload URL is spent and cannot be reused."
},
)
3 changes: 3 additions & 0 deletions backend/app/core/cloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
AmazonCloudStorageClient,
CloudStorage,
CloudStorageError,
ObjectNotFoundError,
StoredObject,
UploadTicket,
get_cloud_storage,
upload_audio_to_gcs,
)
Loading
Loading