docling-studio/document-parser/api/ingestion.py
Pier-Jean Malandrino 4c3870bf3e feat(#72): orchestrated ingestion pipeline — Docling → embedding → OpenSearch
Add IngestionService chaining analysis chunks → EmbeddingClient → OpenSearchStore.
Idempotent: existing doc chunks deleted before re-indexing. REST API:
  POST /api/ingestion/{jobId}, DELETE /api/ingestion/{docId}, GET /api/ingestion/status.
Wired in lifespan when OPENSEARCH_URL + EMBEDDING_URL are set. 25 new tests.
2026-04-10 21:45:52 +02:00

120 lines
4.2 KiB
Python

"""Ingestion API — REST endpoints for the embedding → OpenSearch pipeline.
Routes:
POST /api/ingestion/{job_id} — Trigger ingestion for a completed analysis
DELETE /api/ingestion/{doc_id} — Remove indexed chunks for a document
GET /api/ingestion/status — Check whether the ingestion pipeline is available
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ingestion", tags=["ingestion"])
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class IngestionResponse(BaseModel):
doc_id: str
chunks_indexed: int
embedding_dimension: int
model_config = {"populate_by_name": True}
class IngestionStatusResponse(BaseModel):
available: bool
reason: str = ""
# ---------------------------------------------------------------------------
# Dependency helpers
# ---------------------------------------------------------------------------
def _get_ingestion_service(request: Request):
svc = getattr(request.app.state, "ingestion_service", None)
if svc is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Ingestion pipeline not available (OpenSearch or embedding service not configured).",
)
return svc
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/status", response_model=IngestionStatusResponse)
async def get_ingestion_status(request: Request) -> IngestionStatusResponse:
"""Return whether the ingestion pipeline (OpenSearch + embedding) is available."""
svc = getattr(request.app.state, "ingestion_service", None)
if svc is None:
return IngestionStatusResponse(
available=False,
reason="OpenSearch or embedding service not configured",
)
return IngestionStatusResponse(available=True)
@router.post("/{job_id}", response_model=IngestionResponse, status_code=status.HTTP_200_OK)
async def ingest_analysis(job_id: str, request: Request) -> IngestionResponse:
"""Run the full ingestion pipeline for a completed analysis job.
Chains: loaded chunks → embedding → OpenSearch indexing.
Idempotent: re-ingesting a document replaces existing indexed chunks.
"""
svc = _get_ingestion_service(request)
try:
from services.ingestion_service import IngestionError
result = await svc.ingest(job_id)
except IngestionError as exc:
logger.warning("Ingestion failed for job %s: %s", job_id, exc)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
except Exception as exc:
logger.exception("Unexpected error during ingestion of job %s", job_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Ingestion error: {exc}",
) from exc
return IngestionResponse(
doc_id=result.doc_id,
chunks_indexed=result.chunks_indexed,
embedding_dimension=result.embedding_dimension,
)
@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ingested(doc_id: str, request: Request) -> None:
"""Remove all indexed chunks for a document from OpenSearch."""
svc = _get_ingestion_service(request)
try:
from services.ingestion_service import IngestionError
await svc.delete(doc_id)
except IngestionError as exc:
logger.warning("Delete failed for document %s: %s", doc_id, exc)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
except Exception as exc:
logger.exception("Unexpected error deleting chunks for document %s", doc_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Delete error: {exc}",
) from exc