Merge pull request #41 from scub-france/feature/quality-improvements

Feature/quality improvements
This commit is contained in:
Pier-Jean Malandrino 2026-04-03 15:22:48 +02:00 committed by GitHub
commit 4e6fa0908d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 654 additions and 62 deletions

View file

@ -46,7 +46,7 @@ def _to_response(job) -> AnalysisResponse:
@router.post("", response_model=AnalysisResponse)
async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep):
async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep) -> AnalysisResponse:
"""Create a new analysis job for a document."""
if not body.documentId or not body.documentId.strip():
raise HTTPException(status_code=400, detail="documentId is required")
@ -72,14 +72,14 @@ async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep):
@router.get("", response_model=list[AnalysisResponse])
async def list_analyses(service: ServiceDep):
async def list_analyses(service: ServiceDep) -> list[AnalysisResponse]:
"""List all analysis jobs."""
jobs = await service.find_all()
return [_to_response(j) for j in jobs]
@router.get("/{job_id}", response_model=AnalysisResponse)
async def get_analysis(job_id: str, service: ServiceDep):
async def get_analysis(job_id: str, service: ServiceDep) -> AnalysisResponse:
"""Get a single analysis job."""
job = await service.find_by_id(job_id)
if not job:
@ -88,7 +88,9 @@ async def get_analysis(job_id: str, service: ServiceDep):
@router.post("/{job_id}/rechunk", response_model=list[ChunkResponse])
async def rechunk_analysis(job_id: str, body: RechunkRequest, service: ServiceDep):
async def rechunk_analysis(
job_id: str, body: RechunkRequest, service: ServiceDep
) -> list[ChunkResponse]:
"""Re-chunk a completed analysis with new chunking options."""
try:
chunks = await service.rechunk(job_id, body.chunkingOptions.model_dump())
@ -107,7 +109,7 @@ async def rechunk_analysis(job_id: str, body: RechunkRequest, service: ServiceDe
@router.delete("/{job_id}", status_code=204)
async def delete_analysis(job_id: str, service: ServiceDep):
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
"""Delete an analysis job."""
deleted = await service.delete(job_id)
if not deleted:

View file

@ -13,6 +13,8 @@ from services import document_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["documents"])
_READ_CHUNK_SIZE = 64 * 1024 # 64 KB
def _to_response(doc) -> DocumentResponse:
return DocumentResponse(
@ -25,8 +27,8 @@ def _to_response(doc) -> DocumentResponse:
)
@router.post("/upload", response_model=DocumentResponse)
async def upload(file: UploadFile):
@router.post("/upload", response_model=DocumentResponse, status_code=200)
async def upload(file: UploadFile) -> DocumentResponse:
"""Upload a PDF document."""
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
@ -35,7 +37,15 @@ async def upload(file: UploadFile):
if file.size and file.size > document_service.MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
content = await file.read()
# 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 total > document_service.MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
chunks.append(chunk)
content = b"".join(chunks)
try:
doc = await document_service.upload(
@ -44,20 +54,20 @@ async def upload(file: UploadFile):
file_content=content,
)
except ValueError as e:
raise HTTPException(status_code=413, detail=str(e)) from 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():
async def list_documents() -> list[DocumentResponse]:
"""List all documents."""
docs = await document_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):
async def get_document(doc_id: str) -> DocumentResponse:
"""Get a single document."""
doc = await document_service.find_by_id(doc_id)
if not doc:
@ -66,7 +76,7 @@ async def get_document(doc_id: str):
@router.delete("/{doc_id}", status_code=204)
async def delete_document(doc_id: str):
async def delete_document(doc_id: str) -> None:
"""Delete a document and its file."""
deleted = await document_service.delete(doc_id)
if not deleted:
@ -78,12 +88,18 @@ async def preview(
doc_id: str,
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 document_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:
with open(doc.storage_path, "rb") as f:
file_content = f.read()
@ -91,6 +107,11 @@ async def preview(
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("Failed to generate preview")
logger.exception("Unexpected error generating preview for %s", doc_id)
raise HTTPException(status_code=422, detail="Failed to generate preview") from exc

View file

@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
def _to_camel(name: str) -> str:
@ -29,6 +29,7 @@ class _CamelModel(BaseModel):
class DocumentResponse(_CamelModel):
id: str
filename: str
status: str = "uploaded" # Document status (always "uploaded" for now)
content_type: str | None = None
file_size: int | None = None
page_count: int | None = None
@ -54,16 +55,39 @@ class AnalysisResponse(_CamelModel):
class PipelineOptionsRequest(BaseModel):
"""Docling pipeline configuration options."""
do_ocr: bool = True
do_table_structure: bool = True
table_mode: str = "accurate" # "accurate" or "fast"
do_code_enrichment: bool = False
do_formula_enrichment: bool = False
do_picture_classification: bool = False
do_picture_description: bool = False
generate_picture_images: bool = False
generate_page_images: bool = False
images_scale: float = 1.0
model_config = ConfigDict(populate_by_name=True)
do_ocr: bool = Field(default=True, validation_alias=AliasChoices("do_ocr", "doOcr"))
do_table_structure: bool = Field(
default=True, validation_alias=AliasChoices("do_table_structure", "doTableStructure")
)
table_mode: str = Field(
default="accurate", validation_alias=AliasChoices("table_mode", "tableMode")
)
do_code_enrichment: bool = Field(
default=False, validation_alias=AliasChoices("do_code_enrichment", "doCodeEnrichment")
)
do_formula_enrichment: bool = Field(
default=False, validation_alias=AliasChoices("do_formula_enrichment", "doFormulaEnrichment")
)
do_picture_classification: bool = Field(
default=False,
validation_alias=AliasChoices("do_picture_classification", "doPictureClassification"),
)
do_picture_description: bool = Field(
default=False,
validation_alias=AliasChoices("do_picture_description", "doPictureDescription"),
)
generate_picture_images: bool = Field(
default=False,
validation_alias=AliasChoices("generate_picture_images", "generatePictureImages"),
)
generate_page_images: bool = Field(
default=False, validation_alias=AliasChoices("generate_page_images", "generatePageImages")
)
images_scale: float = Field(
default=1.0, validation_alias=AliasChoices("images_scale", "imagesScale")
)
@field_validator("table_mode")
@classmethod
@ -83,10 +107,18 @@ class PipelineOptionsRequest(BaseModel):
class ChunkingOptionsRequest(BaseModel):
"""Docling chunking configuration options."""
chunker_type: str = "hybrid" # "hybrid", "hierarchical"
max_tokens: int = 512
merge_peers: bool = True
repeat_table_header: bool = True
model_config = ConfigDict(populate_by_name=True)
chunker_type: str = Field(
default="hybrid", validation_alias=AliasChoices("chunker_type", "chunkerType")
)
max_tokens: int = Field(default=512, validation_alias=AliasChoices("max_tokens", "maxTokens"))
merge_peers: bool = Field(
default=True, validation_alias=AliasChoices("merge_peers", "mergePeers")
)
repeat_table_header: bool = Field(
default=True, validation_alias=AliasChoices("repeat_table_header", "repeatTableHeader")
)
@field_validator("chunker_type")
@classmethod
@ -117,10 +149,16 @@ class ChunkResponse(_CamelModel):
class CreateAnalysisRequest(BaseModel):
documentId: str # camelCase to match existing frontend contract
pipelineOptions: PipelineOptionsRequest | None = None
chunkingOptions: ChunkingOptionsRequest | None = None
documentId: str = Field(validation_alias=AliasChoices("documentId", "document_id"))
pipelineOptions: PipelineOptionsRequest | None = Field(
default=None, validation_alias=AliasChoices("pipelineOptions", "pipeline_options")
)
chunkingOptions: ChunkingOptionsRequest | None = Field(
default=None, validation_alias=AliasChoices("chunkingOptions", "chunking_options")
)
class RechunkRequest(BaseModel):
chunkingOptions: ChunkingOptionsRequest
chunkingOptions: ChunkingOptionsRequest = Field(
validation_alias=AliasChoices("chunkingOptions", "chunking_options")
)

View file

@ -53,6 +53,7 @@ class AnalysisJob:
document_filename: str | None = None
def mark_running(self) -> None:
"""Transition to RUNNING and record the start timestamp."""
self.status = AnalysisStatus.RUNNING
self.started_at = _utcnow()
@ -64,6 +65,7 @@ class AnalysisJob:
document_json: str | None = None,
chunks_json: str | None = None,
) -> None:
"""Transition to COMPLETED with conversion results."""
self.status = AnalysisStatus.COMPLETED
self.content_markdown = markdown
self.content_html = html
@ -73,6 +75,7 @@ class AnalysisJob:
self.completed_at = _utcnow()
def mark_failed(self, error: str) -> None:
"""Transition to FAILED with an error message."""
self.status = AnalysisStatus.FAILED
self.error_message = error
self.completed_at = _utcnow()

View file

@ -39,6 +39,7 @@ class ConversionOptions:
images_scale: float = 1.0
def is_default(self) -> bool:
"""Return True if all options match their defaults."""
return self == ConversionOptions()
@ -60,6 +61,7 @@ class ChunkingOptions:
repeat_table_header: bool = True
def is_default(self) -> bool:
"""Return True if all options match their defaults."""
return self == ChunkingOptions()

View file

@ -15,8 +15,8 @@ from docling_core.transforms.chunker import HierarchicalChunker
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
from docling_core.types.doc.document import DoclingDocument
from infra.bbox import EMPTY_BBOX, to_topleft_list
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
from infra.bbox import EMPTY_BBOX, to_topleft_list
logger = logging.getLogger(__name__)

View file

@ -35,13 +35,13 @@ from docling_core.types.doc import (
TitleItem,
)
from infra.bbox import to_topleft_list
from domain.value_objects import (
ConversionOptions,
ConversionResult,
PageDetail,
PageElement,
)
from infra.bbox import to_topleft_list
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,89 @@
"""Lightweight in-memory rate limiter middleware for FastAPI.
Uses a sliding-window counter per client IP. No external dependency
required suitable for single-process deployments with SQLite.
For multi-process or distributed setups, replace with a Redis-backed
solution (e.g. slowapi).
"""
from __future__ import annotations
import logging
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse, Response
if TYPE_CHECKING:
from starlette.requests import Request
logger = logging.getLogger(__name__)
@dataclass
class _ClientBucket:
"""Sliding window of request timestamps for a single client."""
timestamps: list[float] = field(default_factory=list)
def count_recent(self, window: float, now: float) -> int:
"""Remove expired entries and return the count of recent requests."""
cutoff = now - window
self.timestamps = [t for t in self.timestamps if t > cutoff]
return len(self.timestamps)
def add(self, now: float) -> None:
self.timestamps.append(now)
class RateLimiterMiddleware(BaseHTTPMiddleware):
"""Per-IP rate limiter using in-memory sliding windows.
Args:
app: The ASGI application.
requests_per_window: Max requests allowed per window.
window_seconds: Size of the sliding window in seconds.
exclude_paths: Paths exempt from rate limiting (e.g. health checks).
"""
def __init__(
self,
app,
*,
requests_per_window: int = 60,
window_seconds: float = 60.0,
exclude_paths: tuple[str, ...] = ("/api/health",),
):
super().__init__(app)
self._max_requests = requests_per_window
self._window = window_seconds
self._exclude = exclude_paths
self._buckets: dict[str, _ClientBucket] = defaultdict(_ClientBucket)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in self._exclude:
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
now = time.monotonic()
bucket = self._buckets[client_ip]
recent = bucket.count_recent(self._window, now)
if recent >= self._max_requests:
retry_after = int(self._window)
logger.warning(
"Rate limit exceeded for %s (%d/%d)", client_ip, recent, self._max_requests
)
return JSONResponse(
status_code=429,
content={"detail": "Too many requests"},
headers={"Retry-After": str(retry_after)},
)
bucket.add(now)
return await call_next(request)

View file

@ -13,6 +13,7 @@ class Settings:
docling_serve_url: str = "http://localhost:5001"
docling_serve_api_key: str | None = None
conversion_timeout: int = 600
max_concurrent_analyses: int = 3
upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db"
cors_origins: list[str] = field(
@ -21,6 +22,7 @@ class Settings:
@classmethod
def from_env(cls) -> Settings:
"""Build a Settings instance from environment variables."""
cors_raw = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173")
return cls(
app_version=os.environ.get("APP_VERSION", "dev"),
@ -28,7 +30,12 @@ class Settings:
docling_serve_url=os.environ.get("DOCLING_SERVE_URL", "http://localhost:5001"),
docling_serve_api_key=os.environ.get("DOCLING_SERVE_API_KEY"),
conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "600")),
max_concurrent_analyses=int(os.environ.get("MAX_CONCURRENT_ANALYSES", "3")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),
cors_origins=[o.strip() for o in cors_raw.split(",")],
)
# Module-level singleton — import this from other modules.
settings = Settings.from_env()

View file

@ -12,6 +12,7 @@ Conversion engine is selected via CONVERSION_ENGINE env var:
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
@ -19,8 +20,9 @@ from fastapi.middleware.cors import CORSMiddleware
from api.analyses import router as analyses_router
from api.documents import router as documents_router
from infra.settings import Settings
from persistence.database import init_db
from infra.rate_limiter import RateLimiterMiddleware
from infra.settings import settings
from persistence.database import get_connection, init_db
from services.analysis_service import AnalysisService
logging.basicConfig(
@ -29,12 +31,6 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Settings & dependency wiring
# ---------------------------------------------------------------------------
settings = Settings.from_env()
def _build_converter():
"""Build the converter adapter based on configuration."""
@ -69,6 +65,7 @@ def _build_analysis_service() -> AnalysisService:
converter=converter,
chunker=chunker,
conversion_timeout=settings.conversion_timeout,
max_concurrent=settings.max_concurrent_analyses,
)
@ -78,7 +75,7 @@ def _build_analysis_service() -> AnalysisService:
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await init_db()
app.state.analysis_service = _build_analysis_service()
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
@ -98,16 +95,27 @@ app.add_middleware(
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
app.add_middleware(RateLimiterMiddleware, requests_per_window=100, window_seconds=60)
app.include_router(documents_router)
app.include_router(analyses_router)
@app.get("/api/health")
def health():
"""Health check endpoint."""
async def health() -> dict[str, str]:
"""Health check endpoint — verifies database connectivity."""
db_status = "ok"
try:
async with get_connection() as db:
await db.execute("SELECT 1")
except Exception:
db_status = "error"
logger.warning("Health check: database unreachable", exc_info=True)
status = "ok" if db_status == "ok" else "degraded"
return {
"status": "ok",
"status": status,
"version": settings.app_version,
"engine": settings.conversion_engine,
"database": db_status,
}

View file

@ -42,6 +42,7 @@ _SELECT_WITH_DOC = """
async def insert(job: AnalysisJob) -> None:
"""Persist a new analysis job record."""
async with get_connection() as db:
await db.execute(
"""INSERT INTO analysis_jobs (id, document_id, status, created_at)
@ -52,6 +53,7 @@ async def insert(job: AnalysisJob) -> None:
async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
"""Return analysis jobs with document info, newest first."""
async with get_connection() as db:
cursor = await db.execute(
f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC LIMIT ? OFFSET ?",
@ -62,6 +64,7 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
async def find_by_id(job_id: str) -> AnalysisJob | None:
"""Find an analysis job by ID (with document filename), or return None."""
async with get_connection() as db:
cursor = await db.execute(f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,))
row = await cursor.fetchone()
@ -69,6 +72,7 @@ async def find_by_id(job_id: str) -> AnalysisJob | None:
async def update_status(job: AnalysisJob) -> None:
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
async with get_connection() as db:
await db.execute(
"""UPDATE analysis_jobs
@ -104,6 +108,7 @@ async def update_chunks(job_id: str, chunks_json: str) -> bool:
async def delete(job_id: str) -> bool:
"""Delete an analysis job by ID. Returns True if a row was removed."""
async with get_connection() as db:
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
await db.commit()

View file

@ -4,13 +4,16 @@ from __future__ import annotations
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import aiosqlite
from infra.settings import settings
logger = logging.getLogger(__name__)
DB_PATH = os.environ.get("DB_PATH", "./data/docling_studio.db")
DB_PATH = settings.db_path
_SCHEMA = """
CREATE TABLE IF NOT EXISTS documents (
@ -80,7 +83,7 @@ async def get_db() -> aiosqlite.Connection:
@asynccontextmanager
async def get_connection():
async def get_connection() -> AsyncIterator[aiosqlite.Connection]:
"""Context manager that opens and auto-closes a database connection."""
db = await get_db()
try:

View file

@ -26,6 +26,7 @@ def _row_to_document(row) -> Document:
async def insert(doc: Document) -> None:
"""Persist a new document record."""
async with get_connection() as db:
await db.execute(
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
@ -44,6 +45,7 @@ async def insert(doc: Document) -> None:
async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
"""Return documents ordered by creation date (newest first)."""
async with get_connection() as db:
cursor = await db.execute(
"SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
@ -54,6 +56,7 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
async def find_by_id(doc_id: str) -> Document | None:
"""Find a document by its ID, or return None."""
async with get_connection() as db:
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
row = await cursor.fetchone()
@ -61,6 +64,7 @@ async def find_by_id(doc_id: str) -> Document | None:
async def update_page_count(doc_id: str, page_count: int) -> None:
"""Update the page count after conversion has determined it."""
async with get_connection() as db:
await db.execute(
"UPDATE documents SET page_count = ? WHERE id = ?",
@ -70,6 +74,7 @@ async def update_page_count(doc_id: str, page_count: int) -> None:
async def delete(doc_id: str) -> bool:
"""Delete a document by ID. Returns True if a row was removed."""
async with get_connection() as db:
cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
await db.commit()

View file

@ -34,6 +34,10 @@ def _chunk_to_dict(c: ChunkResult) -> dict:
}
# Maximum number of concurrent analysis jobs to prevent resource exhaustion.
_DEFAULT_MAX_CONCURRENT = 3
class AnalysisService:
"""Orchestrates document analysis using an injected converter."""
@ -42,10 +46,12 @@ class AnalysisService:
converter: DocumentConverter,
chunker: DocumentChunker | None = None,
conversion_timeout: int = 600,
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
):
self._converter = converter
self._chunker = chunker
self._conversion_timeout = conversion_timeout
self._semaphore = asyncio.Semaphore(max_concurrent)
async def create(
self,
@ -77,12 +83,15 @@ class AnalysisService:
return job
async def find_all(self) -> list[AnalysisJob]:
"""Return all analysis jobs, newest first."""
return await analysis_repo.find_all()
async def find_by_id(self, job_id: str) -> AnalysisJob | None:
"""Find an analysis job by ID, or return None."""
return await analysis_repo.find_by_id(job_id)
async def delete(self, job_id: str) -> bool:
"""Delete an analysis job. Returns True if it existed."""
return await analysis_repo.delete(job_id)
async def rechunk(self, job_id: str, chunking_options: dict) -> list[ChunkResult]:
@ -113,7 +122,25 @@ class AnalysisService:
pipeline_options: dict | None = None,
chunking_options: dict | None = None,
) -> None:
"""Background task: run conversion and optionally chunk."""
"""Background task: run conversion and optionally chunk.
Acquires the concurrency semaphore to limit parallel conversions
and prevent CPU/memory exhaustion on modest hardware.
"""
async with self._semaphore:
await self._run_analysis_inner(
job_id, file_path, filename, pipeline_options, chunking_options
)
async def _run_analysis_inner(
self,
job_id: str,
file_path: str,
filename: str,
pipeline_options: dict | None = None,
chunking_options: dict | None = None,
) -> None:
"""Inner analysis logic — called under the concurrency semaphore."""
try:
job = await analysis_repo.find_by_id(job_id)
if not job:
@ -192,5 +219,7 @@ async def _mark_failed(job_id: str, error: str) -> None:
if job:
job.mark_failed(error)
await analysis_repo.update_status(job)
except OSError:
logger.exception("Database I/O error marking job %s as failed", job_id)
except Exception:
logger.exception("Could not mark job %s as failed", job_id)
logger.exception("Unexpected error marking job %s as failed", job_id)

View file

@ -10,11 +10,12 @@ import uuid
from pdf2image import convert_from_bytes, pdfinfo_from_bytes
from domain.models import Document
from infra.settings import settings
from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__)
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "./uploads")
UPLOAD_DIR = settings.upload_dir
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
@ -22,8 +23,14 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
_PDF_MAGIC = b"%PDF"
_UPLOAD_CHUNK_SIZE = 64 * 1024 # 64 KB chunks for streaming writes
async def upload(filename: str, content_type: str, file_content: bytes) -> Document:
"""Save uploaded file to disk and persist metadata."""
"""Save uploaded file to disk and persist metadata.
Writes the file in fixed-size chunks to keep peak memory usage low.
"""
if len(file_content) > MAX_FILE_SIZE:
raise ValueError("File too large (max 50 MB)")
@ -36,8 +43,10 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
safe_name = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(UPLOAD_DIR, safe_name)
# Write in chunks to avoid doubling memory usage for large files
with open(file_path, "wb") as f:
f.write(file_content)
for offset in range(0, len(file_content), _UPLOAD_CHUNK_SIZE):
f.write(file_content[offset : offset + _UPLOAD_CHUNK_SIZE])
# Count PDF pages
page_count = _count_pages(file_content)
@ -54,10 +63,12 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
async def find_all() -> list[Document]:
"""Return all documents, newest first."""
return await document_repo.find_all()
async def find_by_id(doc_id: str) -> Document | None:
"""Find a document by its ID, or return None."""
return await document_repo.find_by_id(doc_id)
@ -78,8 +89,12 @@ async def delete(doc_id: str) -> bool:
os.unlink(real_path)
elif os.path.exists(doc.storage_path):
logger.warning("Refused to delete file outside upload dir: %s", doc.storage_path)
except FileNotFoundError:
logger.info("File already removed: %s", doc.storage_path)
except PermissionError:
logger.error("Permission denied deleting file: %s", doc.storage_path)
except OSError:
logger.warning("Could not delete file: %s", doc.storage_path)
logger.warning("Could not delete file: %s", doc.storage_path, exc_info=True)
return await document_repo.delete(doc_id)
@ -100,6 +115,9 @@ def _count_pages(file_content: bytes) -> int | None:
try:
info = pdfinfo_from_bytes(file_content)
return info.get("Pages")
except Exception:
logger.warning("Could not count pages", exc_info=True)
except (FileNotFoundError, OSError) as exc:
logger.warning("Could not count pages: %s", exc)
return None
except Exception:
logger.warning("Unexpected error counting pages", exc_info=True)
return None

View file

@ -1,13 +1,13 @@
"""Tests for AnalysisService — focus on _on_task_done callback (bug #1 fix)."""
"""Tests for AnalysisService — callbacks, concurrency, and orchestration."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from services.analysis_service import _on_task_done
from services.analysis_service import AnalysisService, _on_task_done
class TestOnTaskDone:
@ -69,3 +69,46 @@ class TestOnTaskDone:
await asyncio.sleep(0)
mock_mark.assert_not_called()
class TestAnalysisServiceConcurrency:
"""Verify that the semaphore limits concurrent analysis jobs."""
def test_semaphore_initialized_with_max_concurrent(self):
converter = MagicMock()
service = AnalysisService(converter=converter, max_concurrent=5)
assert service._semaphore._value == 5
def test_default_max_concurrent(self):
converter = MagicMock()
service = AnalysisService(converter=converter)
assert service._semaphore._value == 3
@pytest.mark.asyncio
async def test_semaphore_limits_parallel_jobs(self):
"""Only max_concurrent jobs should run in parallel; others must wait."""
call_order: list[str] = []
blocker = asyncio.Event()
converter = MagicMock()
service = AnalysisService(converter=converter, max_concurrent=1)
async def fake_inner(self, *args, **kwargs):
call_order.append("start")
await blocker.wait()
call_order.append("end")
with patch.object(AnalysisService, "_run_analysis_inner", fake_inner):
t1 = asyncio.create_task(service._run_analysis("j1", "/f", "f.pdf"))
t2 = asyncio.create_task(service._run_analysis("j2", "/f", "f.pdf"))
await asyncio.sleep(0.05)
# With max_concurrent=1, only one task should have started
assert call_order.count("start") == 1
blocker.set()
await asyncio.gather(t1, t2)
# Both should have completed
assert call_order.count("start") == 2
assert call_order.count("end") == 2

View file

@ -29,8 +29,9 @@ class TestHealthEndpoint:
resp = client.get("/api/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["status"] in ("ok", "degraded")
assert "engine" in data
assert "database" in data
class TestDocumentEndpoints:
@ -102,7 +103,20 @@ class TestDocumentEndpoints:
"/api/documents/upload",
files={"file": ("big.pdf", b"x", "application/pdf")},
)
assert resp.status_code == 413
assert resp.status_code == 400
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
def test_preview_page_out_of_range(self, mock_find, client):
mock_find.return_value = Document(
id="d1",
filename="test.pdf",
page_count=3,
storage_path="/tmp/test.pdf",
)
resp = client.get("/api/documents/d1/preview?page=10")
assert resp.status_code == 400
assert "out of range" in resp.json()["detail"]
@patch("services.document_service.delete", new_callable=AsyncMock)
def test_delete_document(self, mock_delete, client):

View file

@ -0,0 +1,137 @@
"""Tests for document_service — upload, preview, page counting, and deletion."""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from domain.models import Document
from services import document_service
class TestUploadValidation:
@pytest.mark.asyncio
async def test_rejects_oversized_file(self):
content = b"x" * (document_service.MAX_FILE_SIZE + 1)
with pytest.raises(ValueError, match="File too large"):
await document_service.upload("big.pdf", "application/pdf", content)
@pytest.mark.asyncio
async def test_rejects_non_pdf(self):
content = b"NOT-A-PDF-FILE"
with pytest.raises(ValueError, match="not a PDF"):
await document_service.upload("fake.pdf", "application/pdf", content)
@pytest.mark.asyncio
async def test_accepts_valid_pdf(self, tmp_path, monkeypatch):
monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path))
mock_insert = AsyncMock()
with (
patch("persistence.document_repo.insert", mock_insert),
patch.object(document_service, "_count_pages", return_value=5),
):
content = b"%PDF-1.4 fake pdf content"
doc = await document_service.upload("test.pdf", "application/pdf", content)
assert doc.filename == "test.pdf"
assert doc.file_size == len(content)
assert doc.page_count == 5
mock_insert.assert_called_once()
# Verify file was actually written to disk
assert os.path.exists(doc.storage_path)
with open(doc.storage_path, "rb") as f:
assert f.read() == content
class TestGeneratePreview:
def test_raises_on_invalid_page(self):
"""generate_preview should raise ValueError when page is out of range."""
with (
patch("services.document_service.convert_from_bytes", return_value=[]),
pytest.raises(ValueError, match="Page 1 not found"),
):
document_service.generate_preview(b"%PDF-fake", page=1)
def test_returns_png_bytes(self):
"""generate_preview should return PNG bytes from pdf2image."""
mock_image = MagicMock()
mock_image.save = MagicMock(side_effect=lambda buf, format: buf.write(b"PNG-DATA"))
with patch("services.document_service.convert_from_bytes", return_value=[mock_image]):
result = document_service.generate_preview(b"%PDF-fake", page=1, dpi=72)
assert result == b"PNG-DATA"
class TestCountPages:
def test_returns_page_count(self):
with patch(
"services.document_service.pdfinfo_from_bytes",
return_value={"Pages": 42},
):
assert document_service._count_pages(b"pdf") == 42
def test_returns_none_on_error(self):
with patch(
"services.document_service.pdfinfo_from_bytes",
side_effect=FileNotFoundError("poppler not found"),
):
assert document_service._count_pages(b"pdf") is None
class TestDelete:
@pytest.mark.asyncio
async def test_delete_removes_file_and_records(self, tmp_path, monkeypatch):
monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path))
# Create a fake file
fake_file = tmp_path / "test.pdf"
fake_file.write_bytes(b"content")
doc = Document(
id="doc-1",
filename="test.pdf",
storage_path=str(fake_file),
)
with (
patch("persistence.document_repo.find_by_id", AsyncMock(return_value=doc)),
patch("persistence.analysis_repo.delete_by_document", AsyncMock(return_value=2)),
patch("persistence.document_repo.delete", AsyncMock(return_value=True)),
):
result = await document_service.delete("doc-1")
assert result is True
assert not fake_file.exists()
@pytest.mark.asyncio
async def test_delete_refuses_file_outside_upload_dir(self, tmp_path, monkeypatch):
"""Files outside UPLOAD_DIR should not be deleted (path traversal protection)."""
monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path / "uploads"))
os.makedirs(tmp_path / "uploads", exist_ok=True)
# File is outside the upload dir
outside_file = tmp_path / "secret.txt"
outside_file.write_bytes(b"secret")
doc = Document(id="doc-1", filename="x.pdf", storage_path=str(outside_file))
with (
patch("persistence.document_repo.find_by_id", AsyncMock(return_value=doc)),
patch("persistence.analysis_repo.delete_by_document", AsyncMock(return_value=0)),
patch("persistence.document_repo.delete", AsyncMock(return_value=True)),
):
await document_service.delete("doc-1")
# File should NOT have been deleted
assert outside_file.exists()
@pytest.mark.asyncio
async def test_delete_not_found_returns_false(self):
with patch("persistence.document_repo.find_by_id", AsyncMock(return_value=None)):
result = await document_service.delete("missing")
assert result is False

View file

@ -0,0 +1,93 @@
"""Tests for the in-memory rate limiter middleware."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from infra.rate_limiter import RateLimiterMiddleware, _ClientBucket
class TestClientBucket:
def test_count_recent_filters_old_entries(self):
bucket = _ClientBucket(timestamps=[1.0, 2.0, 3.0, 10.0])
count = bucket.count_recent(window=5.0, now=12.0)
assert count == 1 # only 10.0 is within [7.0, 12.0]
def test_count_recent_keeps_all_when_within_window(self):
bucket = _ClientBucket(timestamps=[10.0, 11.0, 12.0])
count = bucket.count_recent(window=60.0, now=15.0)
assert count == 3
def test_add(self):
bucket = _ClientBucket()
bucket.add(1.0)
bucket.add(2.0)
assert len(bucket.timestamps) == 2
@pytest.fixture
def limited_app():
"""FastAPI app with a very low rate limit for testing."""
app = FastAPI()
app.add_middleware(
RateLimiterMiddleware,
requests_per_window=3,
window_seconds=60,
exclude_paths=("/health",),
)
@app.get("/test")
def test_endpoint():
return {"ok": True}
@app.get("/health")
def health():
return {"status": "ok"}
return app
@pytest.fixture
def client(limited_app):
return TestClient(limited_app)
class TestRateLimiterMiddleware:
def test_allows_requests_under_limit(self, client):
for _ in range(3):
resp = client.get("/test")
assert resp.status_code == 200
def test_blocks_requests_over_limit(self, client):
for _ in range(3):
client.get("/test")
resp = client.get("/test")
assert resp.status_code == 429
assert resp.json()["detail"] == "Too many requests"
assert "Retry-After" in resp.headers
def test_health_excluded_from_limit(self, client):
# Exhaust the limit
for _ in range(3):
client.get("/test")
# Health should still work
resp = client.get("/health")
assert resp.status_code == 200
def test_window_resets(self, client):
"""After the window expires, requests should be allowed again."""
for _ in range(3):
client.get("/test")
assert client.get("/test").status_code == 429
# Simulate time passing beyond the window
with patch("time.monotonic", return_value=1e12):
resp = client.get("/test")
assert resp.status_code == 200

View file

@ -0,0 +1,75 @@
"""Tests for Settings — environment variable parsing and defaults."""
from __future__ import annotations
from infra.settings import Settings
class TestSettingsDefaults:
def test_default_values(self):
s = Settings()
assert s.app_version == "dev"
assert s.conversion_engine == "local"
assert s.docling_serve_url == "http://localhost:5001"
assert s.docling_serve_api_key is None
assert s.conversion_timeout == 600
assert s.upload_dir == "./uploads"
assert s.db_path == "./data/docling_studio.db"
assert "http://localhost:3000" in s.cors_origins
def test_frozen(self):
"""Settings should be immutable."""
import pytest
s = Settings()
with pytest.raises(AttributeError):
s.upload_dir = "/other" # type: ignore[misc]
class TestSettingsFromEnv:
def test_reads_env_vars(self, monkeypatch):
monkeypatch.setenv("APP_VERSION", "1.2.3")
monkeypatch.setenv("CONVERSION_ENGINE", "remote")
monkeypatch.setenv("DOCLING_SERVE_URL", "http://serve:9000")
monkeypatch.setenv("DOCLING_SERVE_API_KEY", "secret-key")
monkeypatch.setenv("CONVERSION_TIMEOUT", "120")
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
monkeypatch.setenv("DB_PATH", "/data/test.db")
monkeypatch.setenv("CORS_ORIGINS", "http://a.com, http://b.com")
s = Settings.from_env()
assert s.app_version == "1.2.3"
assert s.conversion_engine == "remote"
assert s.docling_serve_url == "http://serve:9000"
assert s.docling_serve_api_key == "secret-key"
assert s.conversion_timeout == 120
assert s.upload_dir == "/data/uploads"
assert s.db_path == "/data/test.db"
assert s.cors_origins == ["http://a.com", "http://b.com"]
def test_defaults_when_env_empty(self, monkeypatch):
"""When no env vars set, from_env returns sensible defaults."""
for key in (
"APP_VERSION",
"CONVERSION_ENGINE",
"DOCLING_SERVE_URL",
"DOCLING_SERVE_API_KEY",
"CONVERSION_TIMEOUT",
"UPLOAD_DIR",
"DB_PATH",
"CORS_ORIGINS",
):
monkeypatch.delenv(key, raising=False)
s = Settings.from_env()
assert s.app_version == "dev"
assert s.conversion_engine == "local"
assert s.conversion_timeout == 600
def test_cors_origins_split(self, monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", "http://a.com,http://b.com,http://c.com")
s = Settings.from_env()
assert len(s.cors_origins) == 3
assert s.cors_origins[2] == "http://c.com"