docling-studio/document-parser/api/documents.py
Pier-Jean Malandrino 8ae9dcdc04 refactor(audit): remediate 0.5.0 audit findings — clean architecture, security, DRY, SOLID, perf
Closes the 12 MAJ raised by the release/0.5.0 audit pipeline (cf.
docs/audit/reports/release-0.5.0/summary.md → summary-reaudit.md).

Volet 1 — Reasoning architecture (audits 01/02/06/07 strengthening)
  * Domain ports: LLMProvider, ReasoningRunner, ReasoningParseError
  * Domain DTOs: LLMProviderType, ReasoningResult, ReasoningIteration
  * infra/llm/ollama_provider.py — OllamaProvider with health_check
  * infra/docling_agent_reasoning.py — runner adapter, encapsulates the
    private _rag_loop call (tracked at docling-project/docling-agent#26),
    commits OLLAMA_HOST once at boot (eliminates the per-request env race),
    translates upstream IndexError into ReasoningParseError
  * api/reasoning.py — zero coupling to docling-agent / mellea / docling-core,
    consumes app.state.reasoning_runner via the port
  * main.py — DI wires OllamaProvider + DoclingAgentReasoningRunner at boot
    when REASONING_ENABLED=true and deps are importable
  * Rename RAG_* env vars → REASONING_*, endpoint /rag → /reasoning,
    type RAGResult → ReasoningResult, frontend feature flag wiring,
    i18n strings, tests, docs (BREAKING — pre-1.0 surface, no external
    consumers in production)
  * 17 new tests: adapter unit tests with sys.modules stubs, OllamaProvider
    httpx tests, R3 concurrent-host isolation, R6 multi-iteration trace
    serialization, R13 Protocol conformance via isinstance
  * E2E Karate scenario: nav-reasoning hidden when REASONING_ENABLED=false
  * README — Live Reasoning section (env vars, archi, link to issue #26)

Bloc B — Security (audit 08, dev-only context)
  * docker-compose.yml — DEV DEFAULTS header, OpenSearch DISABLE_SECURITY_PLUGIN
    flagged as dev-only with link to OpenSearch security docs
  * main.py — boot warning if NEO4J_URI is set with the default 'changeme'
    password, so prod operators can't silently inherit it

Bloc C — DRY frontend (audit 05)
  * shared/storage/keys.ts — STORAGE_KEYS centralised (theme, locale)
  * features/settings/store.ts — dead apiUrl ref + orphan i18n keys removed
  * api/schemas.py — DOCUMENT_STATUS_UPLOADED constant

Bloc D — Quality (audits 02/06/07/09/10/12)
  * domain/ports.py — DocumentConverter.supports_page_batching property
    (LSP fix, replaces isinstance(ServeConverter) check)
  * domain/ports.py — VectorStore.ping() (encapsulation, replaces
    _vector_store._client.info() reach-around)
  * api/analyses.py + api/ingestion.py — path params {job_id} → {analysis_id}
    aligned with the user-facing terminology (URLs unchanged)
  * api/documents.py — Path.read_bytes() + generate_preview() wrapped in
    asyncio.to_thread, unblocks the FastAPI event loop on /preview
  * infra/docling_tree.py — PEP 604 union for isinstance (Ruff UP038)
  * src/__tests__/integration/ — cross-feature integration test relocated
    out of features/history/ so feature folders stay self-contained
  * Tightened terminal `assert X is not None` checks (isinstance(.., datetime),
    exact value comparisons)

Validation
  * 446 backend pytest, 202 frontend vitest — all green
  * ruff + ruff format + ESLint + Prettier + vue-tsc clean
  * Re-audit verdict: 0 CRIT / 0 MAJ, score ~94/100, GO

Closes #200
2026-04-29 09:23:09 +02:00

133 lines
4.7 KiB
Python

"""Document API router — upload, list, get, delete, preview."""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from api.schemas import DocumentResponse
from services.document_service import DocumentService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["documents"])
_READ_CHUNK_SIZE = 64 * 1024 # 64 KB
def _get_service(request: Request) -> DocumentService:
return request.app.state.document_service
ServiceDep = Annotated[DocumentService, Depends(_get_service)]
def _to_response(doc) -> DocumentResponse:
return DocumentResponse(
id=doc.id,
filename=doc.filename,
content_type=doc.content_type,
file_size=doc.file_size,
page_count=doc.page_count,
created_at=str(doc.created_at),
)
@router.post("/upload", response_model=DocumentResponse, status_code=200)
async def upload(file: UploadFile, service: ServiceDep) -> DocumentResponse:
"""Upload a PDF document."""
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
# Reject early if Content-Length exceeds limit (before reading body)
_max = service.max_file_size
_detail = f"File too large (max {service.max_file_size_mb} MB)"
if _max > 0 and file.size and file.size > _max:
raise HTTPException(status_code=413, detail=_detail)
# Read in chunks to avoid holding the full upload in a single allocation
chunks: list[bytes] = []
total = 0
while chunk := await file.read(_READ_CHUNK_SIZE):
total += len(chunk)
if _max > 0 and total > _max:
raise HTTPException(status_code=413, detail=_detail)
chunks.append(chunk)
content = b"".join(chunks)
try:
doc = await service.upload(
filename=file.filename,
content_type=file.content_type or "application/pdf",
file_content=content,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _to_response(doc)
@router.get("", response_model=list[DocumentResponse])
async def list_documents(service: ServiceDep) -> list[DocumentResponse]:
"""List all documents."""
docs = await service.find_all()
return [_to_response(d) for d in docs]
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_document(doc_id: str, service: ServiceDep) -> DocumentResponse:
"""Get a single document."""
doc = await service.find_by_id(doc_id)
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return _to_response(doc)
@router.delete("/{doc_id}", status_code=204, response_model=None)
async def delete_document(doc_id: str, service: ServiceDep) -> None:
"""Delete a document and its file."""
deleted = await service.delete(doc_id)
if not deleted:
raise HTTPException(status_code=404, detail="Document not found")
@router.get("/{doc_id}/preview")
async def preview(
doc_id: str,
service: ServiceDep,
page: int = Query(1, ge=1),
dpi: int = Query(150, ge=72, le=300),
) -> Response:
"""Generate a PNG preview of a specific PDF page."""
doc = await service.find_by_id(doc_id)
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if doc.page_count and page > doc.page_count:
raise HTTPException(
status_code=400,
detail=f"Page {page} out of range (document has {doc.page_count} pages)",
)
try:
# File read + PDF rasterisation are both blocking; offload to a
# worker thread so the event loop stays free for other requests.
file_content = await asyncio.to_thread(Path(doc.storage_path).read_bytes)
png_bytes = await asyncio.to_thread(
DocumentService.generate_preview, file_content, page=page, dpi=dpi
)
return Response(content=png_bytes, media_type="image/png")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="PDF file not found on disk") from exc
except OSError as exc:
logger.exception("I/O error generating preview for %s", doc_id)
raise HTTPException(status_code=422, detail="Failed to read PDF file") from exc
except Exception as exc:
logger.exception("Unexpected error generating preview for %s", doc_id)
raise HTTPException(status_code=422, detail="Failed to generate preview") from exc