| from typing import Annotated |
|
|
| from fastapi import APIRouter, Depends, File, Form, Path, Query, Response, UploadFile |
| from fastapi.responses import JSONResponse |
|
|
| from src.api.dependencies import DbSessionDep, GuestSessionHeaderDep |
| from src.api.errors import error_response |
| from src.config import Settings, get_settings |
| from src.models.auth import AuthorityLevel, SourceDocumentStatus, SourceScope |
| from src.models.schemas import ( |
| DocumentListResponse, |
| DocumentType, |
| DocumentUploadResult, |
| EffectiveStatus, |
| IngestionStatus, |
| Purpose, |
| RetentionPolicy, |
| ScopedDocumentItem, |
| ScopedDocumentListResponse, |
| ScopedDocumentUploadResult, |
| ) |
| from src.services.documents import ( |
| DocumentLibraryService, |
| DocumentListFilters, |
| DocumentServiceError, |
| DocumentUploadInput, |
| InMemoryDocumentIngestionService, |
| ScopedUploadContext, |
| build_document_metadata, |
| document_ingestion_service, |
| parse_upload_metadata, |
| ) |
|
|
| router = APIRouter(prefix="/documents", tags=["documents"]) |
|
|
| SettingsDep = Annotated[Settings, Depends(get_settings)] |
|
|
|
|
| def get_document_service() -> InMemoryDocumentIngestionService: |
| return document_ingestion_service |
|
|
|
|
| @router.post("", response_model=DocumentUploadResult) |
| async def upload_document( |
| file: Annotated[UploadFile, File()], |
| settings: SettingsDep, |
| service: Annotated[InMemoryDocumentIngestionService, Depends(get_document_service)], |
| document_type: Annotated[DocumentType, Form()], |
| purpose: Annotated[Purpose, Form()], |
| retention_policy: Annotated[RetentionPolicy, Form()], |
| title: Annotated[str | None, Form(max_length=500)] = None, |
| metadata: Annotated[str | None, Form()] = None, |
| ) -> DocumentUploadResult | JSONResponse: |
| """Upload a document and queue it for ingestion.""" |
| try: |
| raw_metadata = parse_upload_metadata(metadata) |
| document_metadata = build_document_metadata(raw_metadata, title) |
| record = service.upload_document( |
| DocumentUploadInput( |
| filename=file.filename or "", |
| content=await file.read(), |
| document_type=document_type, |
| purpose=purpose, |
| retention_policy=retention_policy, |
| title=title, |
| metadata=document_metadata, |
| ), |
| max_upload_bytes=settings.max_upload_bytes, |
| ) |
| except DocumentServiceError as exc: |
| return error_response( |
| status_code=exc.status_code, |
| code=exc.code, |
| message=exc.message, |
| details=exc.details, |
| ) |
|
|
| return DocumentUploadResult( |
| document_id=record.document_id, |
| filename=record.filename, |
| document_type=record.document_type, |
| purpose=record.purpose, |
| retention_policy=record.retention_policy, |
| ingestion_status=record.ingestion_status, |
| metadata=record.metadata, |
| created_at=record.created_at, |
| ) |
|
|
|
|
| @router.get("", response_model=DocumentListResponse) |
| def list_documents( |
| service: Annotated[InMemoryDocumentIngestionService, Depends(get_document_service)], |
| page: Annotated[int, Query(ge=1)] = 1, |
| page_size: Annotated[int, Query(ge=1, le=100)] = 20, |
| document_type: Annotated[DocumentType | None, Query()] = None, |
| purpose: Annotated[Purpose | None, Query()] = None, |
| ingestion_status: Annotated[IngestionStatus | None, Query()] = None, |
| effective_status: Annotated[EffectiveStatus | None, Query()] = None, |
| q: Annotated[str | None, Query(max_length=200)] = None, |
| ) -> DocumentListResponse | JSONResponse: |
| """List uploaded or indexed documents.""" |
| try: |
| items, total = service.list_documents( |
| DocumentListFilters( |
| page=page, |
| page_size=page_size, |
| document_type=document_type, |
| purpose=purpose, |
| ingestion_status=ingestion_status, |
| effective_status=effective_status, |
| q=q, |
| ) |
| ) |
| except DocumentServiceError as exc: |
| return error_response( |
| status_code=exc.status_code, |
| code=exc.code, |
| message=exc.message, |
| details=exc.details, |
| ) |
|
|
| return DocumentListResponse( |
| items=items, |
| page=page, |
| page_size=page_size, |
| total=total, |
| ) |
|
|
|
|
| @router.post("/guest/upload", response_model=ScopedDocumentUploadResult) |
| async def upload_guest_document( |
| file: Annotated[UploadFile, File()], |
| response: Response, |
| settings: SettingsDep, |
| db: DbSessionDep, |
| guest_session_id: GuestSessionHeaderDep = None, |
| document_type: Annotated[DocumentType, Form()] = DocumentType.OTHER, |
| purpose: Annotated[Purpose, Form()] = Purpose.DRAFT_CONTEXT, |
| title: Annotated[str | None, Form(max_length=500)] = None, |
| metadata: Annotated[str | None, Form()] = None, |
| ) -> ScopedDocumentUploadResult | JSONResponse: |
| service = DocumentLibraryService(db, settings) |
| session_id = service.ensure_guest_session_id(guest_session_id) |
| response.headers["X-Guest-Session-Id"] = session_id |
| response.set_cookie( |
| settings.guest_session_cookie_name, |
| session_id, |
| max_age=settings.guest_upload_ttl_hours * 3600, |
| httponly=True, |
| secure=settings.auth_cookie_secure, |
| samesite="lax", |
| ) |
| return await _upload_scoped( |
| file=file, |
| service=service, |
| document_type=document_type, |
| purpose=purpose, |
| retention_policy=RetentionPolicy.SESSION, |
| title=title, |
| metadata=metadata, |
| context=ScopedUploadContext( |
| source_scope=SourceScope.GUEST_SESSION, |
| session_id=session_id, |
| authority_level=AuthorityLevel.UPLOADED_CONTEXT, |
| document_status=_document_status_from_metadata(metadata), |
| ), |
| ) |
|
|
|
|
| @router.get("/guest", response_model=ScopedDocumentListResponse) |
| def list_guest_documents( |
| db: DbSessionDep, |
| settings: SettingsDep, |
| guest_session_id: GuestSessionHeaderDep = None, |
| ) -> ScopedDocumentListResponse: |
| service = DocumentLibraryService(db, settings) |
| session_id = service.ensure_guest_session_id(guest_session_id) |
| items = service.list_documents( |
| ScopedUploadContext(source_scope=SourceScope.GUEST_SESSION, session_id=session_id) |
| ) |
| return ScopedDocumentListResponse( |
| items=[service.to_item(item) for item in items], |
| total=len(items), |
| ) |
|
|
|
|
| @router.delete("/guest/{document_id}", response_model=ScopedDocumentItem) |
| def delete_guest_document( |
| document_id: Annotated[str, Path()], |
| db: DbSessionDep, |
| settings: SettingsDep, |
| guest_session_id: GuestSessionHeaderDep = None, |
| ) -> ScopedDocumentItem | JSONResponse: |
| try: |
| service = DocumentLibraryService(db, settings) |
| record = service.delete_document( |
| document_id, |
| ScopedUploadContext( |
| source_scope=SourceScope.GUEST_SESSION, |
| session_id=service.ensure_guest_session_id(guest_session_id), |
| ), |
| ) |
| except DocumentServiceError as exc: |
| return _document_error(exc) |
| return service.to_item(record) |
|
|
|
|
|
|
| async def _upload_scoped( |
| *, |
| file: UploadFile, |
| service: DocumentLibraryService, |
| document_type: DocumentType, |
| purpose: Purpose, |
| retention_policy: RetentionPolicy, |
| title: str | None, |
| metadata: str | None, |
| context: ScopedUploadContext, |
| ) -> ScopedDocumentUploadResult | JSONResponse: |
| try: |
| raw_metadata = parse_upload_metadata(metadata) |
| document_metadata = build_document_metadata(raw_metadata, title) |
| record, chunk_count, warnings = service.upload_scoped_document( |
| DocumentUploadInput( |
| filename=file.filename or "", |
| content=await file.read(), |
| document_type=document_type, |
| purpose=purpose, |
| retention_policy=retention_policy, |
| title=title, |
| metadata=document_metadata, |
| ), |
| context=context, |
| mime_type=file.content_type or "", |
| ) |
| except DocumentServiceError as exc: |
| return _document_error(exc) |
| return ScopedDocumentUploadResult( |
| document=service.to_item(record), |
| chunk_count=chunk_count, |
| warnings=warnings, |
| ) |
|
|
|
|
| def _document_status_from_metadata(metadata: str | None) -> SourceDocumentStatus: |
| try: |
| raw = parse_upload_metadata(metadata) |
| except DocumentServiceError: |
| return SourceDocumentStatus.UNKNOWN |
| value = str(raw.get("document_status") or raw.get("effective_status") or "active") |
| if value in {"repealed", "expired", "amended"}: |
| return SourceDocumentStatus.OUTDATED |
| try: |
| return SourceDocumentStatus(value) |
| except ValueError: |
| return SourceDocumentStatus.UNKNOWN |
|
|
|
|
| def _document_error(exc: DocumentServiceError) -> JSONResponse: |
| return error_response( |
| status_code=exc.status_code, |
| code=exc.code, |
| message=exc.message, |
| details=exc.details, |
| ) |
|
|