diff --git a/CHANGELOG.md b/CHANGELOG.md index 2031ddf..abdc198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - OpenSearch adapter (`OpenSearchStore`): kNN vector search, full-text search, bulk indexing, document CRUD - Embedding microservice (`embedding-service/`): sentence-transformers REST API with batch processing and Dockerfile - `EmbeddingService` port and `EmbeddingClient` HTTP adapter for remote embedding generation +- Orchestrated ingestion pipeline: Docling → chunking → embedding → OpenSearch indexing (idempotent) +- Ingestion REST API: `POST /api/ingestion/{jobId}`, `DELETE /api/ingestion/{docId}`, `GET /api/ingestion/status` +- Production docker-compose with OpenSearch and embedding service +- E2E Karate test for full ingestion workflow (PDF → chunks in OpenSearch) +- My Documents screen: search, filter (all/indexed/not indexed), sort (name/date), ingestion status badges +- Ingest button in Studio: one-click ingestion from completed analysis with progress feedback ### Fixed diff --git a/docker-compose.yml b/docker-compose.yml index 3eb3d1e..e965fc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,38 @@ services: + # --- OpenSearch (single-node, security disabled) --- + opensearch: + image: opensearchproject/opensearch:2 + environment: + discovery.type: single-node + DISABLE_SECURITY_PLUGIN: "true" + OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m" + volumes: + - opensearch_data:/usr/share/opensearch/data + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + + # --- Embedding service (sentence-transformers) --- + embedding: + build: + context: ./embedding-service + environment: + EMBEDDING_MODEL: ${EMBEDDING_MODEL:-all-MiniLM-L6-v2} + EMBEDDING_BATCH_SIZE: ${EMBEDDING_BATCH_SIZE:-64} + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8001/health || exit 1"] + interval: 15s + timeout: 10s + retries: 20 + start_period: 120s + deploy: + resources: + limits: + memory: 2g + + # --- Backend (FastAPI) --- document-parser: build: context: ./document-parser @@ -15,11 +49,19 @@ services: RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100} MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-50} BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-0} + OPENSEARCH_URL: http://opensearch:9200 + EMBEDDING_URL: http://embedding:8001 + depends_on: + opensearch: + condition: service_healthy + embedding: + condition: service_healthy deploy: resources: limits: memory: 4g + # --- Frontend (nginx) --- frontend: build: context: ./frontend @@ -29,5 +71,6 @@ services: - document-parser volumes: + opensearch_data: uploads_data: db_data: diff --git a/document-parser/api/ingestion.py b/document-parser/api/ingestion.py new file mode 100644 index 0000000..2f48e7d --- /dev/null +++ b/document-parser/api/ingestion.py @@ -0,0 +1,82 @@ +"""Ingestion API router — trigger and manage vector ingestion pipeline.""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request + +from api.schemas import IngestionResponse, IngestionStatusResponse +from services.analysis_service import AnalysisService +from services.ingestion_service import IngestionService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ingestion", tags=["ingestion"]) + + +def _get_ingestion_service(request: Request) -> IngestionService: + svc = request.app.state.ingestion_service + if svc is None: + raise HTTPException( + status_code=503, + detail="Ingestion not available (EMBEDDING_URL and OPENSEARCH_URL required)", + ) + return svc + + +def _get_analysis_service(request: Request) -> AnalysisService: + return request.app.state.analysis_service + + +IngestionDep = Annotated[IngestionService, Depends(_get_ingestion_service)] +AnalysisDep = Annotated[AnalysisService, Depends(_get_analysis_service)] + + +@router.post("/{job_id}", response_model=IngestionResponse) +async def ingest_analysis( + job_id: str, + ingestion: IngestionDep, + analysis: AnalysisDep, +) -> IngestionResponse: + """Ingest a completed analysis into the vector index. + + Takes the chunks from an existing analysis job, embeds them, + and indexes them into OpenSearch. + """ + job = await analysis.find_by_id(job_id) + if not job: + raise HTTPException(status_code=404, detail="Analysis not found") + if job.status.value != "COMPLETED": + raise HTTPException(status_code=400, detail="Analysis is not completed") + if not job.chunks_json: + raise HTTPException(status_code=400, detail="Analysis has no chunks — run chunking first") + + try: + result = await ingestion.ingest( + doc_id=job.document_id, + filename=job.document_filename or "unknown", + chunks_json=job.chunks_json, + ) + except Exception as e: + logger.exception("Ingestion failed for job %s", job_id) + raise HTTPException(status_code=500, detail=f"Ingestion failed: {e}") from e + + return IngestionResponse( + doc_id=result.doc_id, + chunks_indexed=result.chunks_indexed, + embedding_dimension=result.embedding_dimension, + ) + + +@router.delete("/{doc_id}", status_code=204) +async def delete_ingested_document(doc_id: str, ingestion: IngestionDep) -> None: + """Delete all indexed chunks for a document.""" + await ingestion.delete_document(doc_id) + + +@router.get("/status", response_model=IngestionStatusResponse) +async def ingestion_status(request: Request) -> IngestionStatusResponse: + """Check if the ingestion pipeline is available.""" + available = request.app.state.ingestion_service is not None + return IngestionStatusResponse(available=available) diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index 42079a6..808b70b 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -180,3 +180,13 @@ class RechunkRequest(BaseModel): chunkingOptions: ChunkingOptionsRequest = Field( validation_alias=AliasChoices("chunkingOptions", "chunking_options") ) + + +class IngestionResponse(_CamelModel): + doc_id: str + chunks_indexed: int + embedding_dimension: int + + +class IngestionStatusResponse(_CamelModel): + available: bool diff --git a/document-parser/main.py b/document-parser/main.py index aa6573c..a316b34 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -20,6 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.analyses import router as analyses_router from api.documents import router as documents_router +from api.ingestion import router as ingestion_router from api.schemas import HealthResponse from infra.rate_limiter import RateLimiterMiddleware from infra.settings import settings @@ -28,6 +29,7 @@ from persistence.database import get_connection, init_db from persistence.document_repo import SqliteDocumentRepository from services.analysis_service import AnalysisConfig, AnalysisService from services.document_service import DocumentConfig, DocumentService +from services.ingestion_service import IngestionConfig, IngestionService logging.basicConfig( level=logging.INFO, @@ -87,6 +89,28 @@ def _build_analysis_service( ) +def _build_ingestion_service() -> IngestionService | None: + """Build the ingestion service — only if embedding + opensearch are configured.""" + if not settings.embedding_url or not settings.opensearch_url: + logger.info("Ingestion disabled (EMBEDDING_URL or OPENSEARCH_URL not set)") + return None + + from infra.embedding_client import EmbeddingClient + from infra.opensearch_store import OpenSearchStore + + embedding = EmbeddingClient(settings.embedding_url) + vector_store = OpenSearchStore(settings.opensearch_url) + config = IngestionConfig( + embedding_dimension=settings.embedding_dimension, + ) + logger.info( + "Ingestion enabled (embedding=%s, opensearch=%s)", + settings.embedding_url, + settings.opensearch_url, + ) + return IngestionService(embedding, vector_store, config) + + def _build_document_service( document_repo: SqliteDocumentRepository, analysis_repo: SqliteAnalysisRepository, @@ -114,6 +138,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: document_repo, analysis_repo = _build_repos() app.state.analysis_service = _build_analysis_service(document_repo, analysis_repo) app.state.document_service = _build_document_service(document_repo, analysis_repo) + app.state.ingestion_service = _build_ingestion_service() logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine) yield @@ -128,7 +153,7 @@ app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, - allow_methods=["GET", "POST", "DELETE", "OPTIONS"], + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], ) if settings.rate_limit_rpm > 0: @@ -140,6 +165,7 @@ if settings.rate_limit_rpm > 0: app.include_router(documents_router) app.include_router(analyses_router) +app.include_router(ingestion_router) @app.get("/api/health", response_model=HealthResponse) diff --git a/document-parser/services/ingestion_service.py b/document-parser/services/ingestion_service.py new file mode 100644 index 0000000..ca29c1e --- /dev/null +++ b/document-parser/services/ingestion_service.py @@ -0,0 +1,166 @@ +"""Ingestion service — orchestrates Docling → embedding → OpenSearch. + +Chains the full ingestion pipeline: +1. Convert document via Docling (reuse existing analysis) +2. Chunk with selected strategy +3. Embed all chunk texts via EmbeddingService +4. Index into OpenSearch via VectorStore + +Idempotent: re-ingesting a document deletes old chunks before re-indexing. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from domain.vector_schema import ( + ChunkBboxEntry, + ChunkOrigin, + IndexedChunk, + build_index_mapping, +) + +if TYPE_CHECKING: + from domain.ports import EmbeddingService, VectorStore + +logger = logging.getLogger(__name__) + + +@dataclass +class IngestionConfig: + """Configuration for the ingestion pipeline.""" + + index_name: str = "docling-studio-chunks" + embedding_dimension: int = 384 + + +@dataclass +class IngestionResult: + """Result of an ingestion pipeline run.""" + + doc_id: str + chunks_indexed: int + embedding_dimension: int + + +class IngestionService: + """Orchestrates the embedding + indexing pipeline.""" + + def __init__( + self, + embedding_service: EmbeddingService, + vector_store: VectorStore, + config: IngestionConfig | None = None, + ) -> None: + self._embedding = embedding_service + self._vector_store = vector_store + self._config = config or IngestionConfig() + + async def ensure_index(self) -> None: + """Ensure the vector index exists with the correct mapping.""" + mapping = build_index_mapping(self._config.embedding_dimension) + await self._vector_store.ensure_index(self._config.index_name, mapping) + + async def ingest( + self, + doc_id: str, + filename: str, + chunks_json: str, + *, + binary_hash: str | None = None, + ) -> IngestionResult: + """Run the embedding + indexing pipeline on pre-chunked data. + + This method is idempotent: it deletes any existing chunks for the + document before re-indexing. + + Args: + doc_id: Unique document identifier. + filename: Original filename. + chunks_json: JSON-serialized list of chunk dicts (from analysis). + binary_hash: Optional hash of the source file for provenance. + + Returns: + IngestionResult with the number of chunks indexed. + """ + await self.ensure_index() + + chunks_data: list[dict] = json.loads(chunks_json) + active_chunks = [c for c in chunks_data if not c.get("deleted")] + if not active_chunks: + logger.info("No active chunks for doc %s — skipping ingestion", doc_id) + return IngestionResult(doc_id=doc_id, chunks_indexed=0, embedding_dimension=0) + + # 1. Embed all chunk texts + texts = [c["text"] for c in active_chunks] + logger.info("Embedding %d chunks for doc %s", len(texts), doc_id) + embeddings = await self._embedding.embed(texts) + + # 2. Build IndexedChunk domain objects + origin = ( + ChunkOrigin(binary_hash=binary_hash or "", filename=filename) if binary_hash else None + ) + indexed_chunks: list[IndexedChunk] = [] + for i, (chunk_data, embedding) in enumerate(zip(active_chunks, embeddings, strict=True)): + bboxes = [ + ChunkBboxEntry( + page=b["page"], + x=b["bbox"][0] if b.get("bbox") else 0, + y=b["bbox"][1] if b.get("bbox") else 0, + w=(b["bbox"][2] - b["bbox"][0]) if b.get("bbox") and len(b["bbox"]) >= 4 else 0, + h=(b["bbox"][3] - b["bbox"][1]) if b.get("bbox") and len(b["bbox"]) >= 4 else 0, + ) + for b in chunk_data.get("bboxes", []) + ] + indexed_chunks.append( + IndexedChunk( + doc_id=doc_id, + filename=filename, + content=chunk_data["text"], + embedding=embedding, + chunk_index=i, + chunk_type=chunk_data.get("chunkType", "text"), + page_number=chunk_data.get("sourcePage", 0) or 0, + bboxes=bboxes, + headings=chunk_data.get("headings", []), + origin=origin, + ) + ) + + # 3. Delete old chunks (idempotent re-indexing) + deleted = await self._vector_store.delete_document(self._config.index_name, doc_id) + if deleted: + logger.info("Deleted %d old chunks for doc %s", deleted, doc_id) + + # 4. Index new chunks + indexed = await self._vector_store.index_chunks(self._config.index_name, indexed_chunks) + logger.info("Indexed %d/%d chunks for doc %s", indexed, len(indexed_chunks), doc_id) + + return IngestionResult( + doc_id=doc_id, + chunks_indexed=indexed, + embedding_dimension=len(embeddings[0]) if embeddings else 0, + ) + + async def delete_document(self, doc_id: str) -> int: + """Remove all indexed chunks for a document.""" + return await self._vector_store.delete_document(self._config.index_name, doc_id) + + async def search( + self, + query: str, + *, + k: int = 10, + doc_id: str | None = None, + ) -> list: + """Semantic search: embed the query then find nearest chunks.""" + embeddings = await self._embedding.embed([query]) + return await self._vector_store.search_similar( + self._config.index_name, + embeddings[0], + k=k, + doc_id=doc_id, + ) diff --git a/document-parser/tests/test_ingestion_api.py b/document-parser/tests/test_ingestion_api.py new file mode 100644 index 0000000..ee124a8 --- /dev/null +++ b/document-parser/tests/test_ingestion_api.py @@ -0,0 +1,114 @@ +"""Tests for the ingestion API endpoints (api.ingestion).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.ingestion import router +from domain.models import AnalysisJob +from services.ingestion_service import IngestionResult + + +@pytest.fixture +def mock_ingestion_service() -> AsyncMock: + svc = AsyncMock() + svc.ingest.return_value = IngestionResult( + doc_id="doc-1", chunks_indexed=5, embedding_dimension=384 + ) + svc.delete_document.return_value = 3 + return svc + + +@pytest.fixture +def mock_analysis_service() -> AsyncMock: + svc = AsyncMock() + job = AnalysisJob(document_id="doc-1") + job.document_filename = "test.pdf" + job.mark_running() + job.mark_completed( + markdown="# Test", + html="

Test

", + pages_json="[]", + document_json='{"doc": true}', + chunks_json='[{"text": "hello"}]', + ) + svc.find_by_id.return_value = job + return svc + + +@pytest.fixture +def client(mock_ingestion_service: AsyncMock, mock_analysis_service: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router(router) + app.state.ingestion_service = mock_ingestion_service + app.state.analysis_service = mock_analysis_service + return TestClient(app) + + +class TestIngestAnalysis: + def test_ingest_success(self, client: TestClient) -> None: + resp = client.post("/api/ingestion/job-1") + assert resp.status_code == 200 + data = resp.json() + assert data["docId"] == "doc-1" + assert data["chunksIndexed"] == 5 + assert data["embeddingDimension"] == 384 + + def test_ingest_not_found(self, client: TestClient, mock_analysis_service: AsyncMock) -> None: + mock_analysis_service.find_by_id.return_value = None + resp = client.post("/api/ingestion/missing") + assert resp.status_code == 404 + + def test_ingest_not_completed( + self, client: TestClient, mock_analysis_service: AsyncMock + ) -> None: + job = AnalysisJob(document_id="doc-1") + mock_analysis_service.find_by_id.return_value = job + resp = client.post("/api/ingestion/job-1") + assert resp.status_code == 400 + + def test_ingest_no_chunks(self, client: TestClient, mock_analysis_service: AsyncMock) -> None: + job = AnalysisJob(document_id="doc-1") + job.mark_running() + job.mark_completed(markdown="x", html="x", pages_json="[]") + mock_analysis_service.find_by_id.return_value = job + resp = client.post("/api/ingestion/job-1") + assert resp.status_code == 400 + + +class TestDeleteIngested: + def test_delete_success(self, client: TestClient) -> None: + resp = client.delete("/api/ingestion/doc-1") + assert resp.status_code == 204 + + +class TestIngestionStatus: + def test_available(self, client: TestClient) -> None: + resp = client.get("/api/ingestion/status") + assert resp.status_code == 200 + assert resp.json()["available"] is True + + def test_not_available(self) -> None: + app = FastAPI() + app.include_router(router) + app.state.ingestion_service = None + app.state.analysis_service = AsyncMock() + tc = TestClient(app) + resp = tc.get("/api/ingestion/status") + assert resp.status_code == 200 + assert resp.json()["available"] is False + + +class TestIngestionDisabled: + def test_returns_503_when_disabled(self) -> None: + app = FastAPI() + app.include_router(router) + app.state.ingestion_service = None + app.state.analysis_service = AsyncMock() + tc = TestClient(app) + resp = tc.post("/api/ingestion/job-1") + assert resp.status_code == 503 diff --git a/document-parser/tests/test_ingestion_service.py b/document-parser/tests/test_ingestion_service.py new file mode 100644 index 0000000..cbea903 --- /dev/null +++ b/document-parser/tests/test_ingestion_service.py @@ -0,0 +1,150 @@ +"""Tests for the ingestion service (services.ingestion_service).""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock + +import pytest + +from services.ingestion_service import IngestionConfig, IngestionService + + +def _make_chunks_json(count: int = 3, *, with_deleted: bool = False) -> str: + chunks = [] + for i in range(count): + chunk = { + "text": f"chunk text {i}", + "headings": [f"Heading {i}"], + "sourcePage": i + 1, + "tokenCount": 10, + "bboxes": [{"page": i + 1, "bbox": [0.0, 0.0, 100.0, 50.0]}], + } + if with_deleted and i == count - 1: + chunk["deleted"] = True + chunks.append(chunk) + return json.dumps(chunks) + + +@pytest.fixture +def mock_embedding() -> AsyncMock: + svc = AsyncMock() + svc.embed.return_value = [[0.1, 0.2, 0.3]] * 3 + return svc + + +@pytest.fixture +def mock_vector_store() -> AsyncMock: + store = AsyncMock() + store.ensure_index.return_value = None + store.delete_document.return_value = 0 + store.index_chunks.return_value = 3 + return store + + +@pytest.fixture +def service(mock_embedding: AsyncMock, mock_vector_store: AsyncMock) -> IngestionService: + return IngestionService( + embedding_service=mock_embedding, + vector_store=mock_vector_store, + config=IngestionConfig(index_name="test-idx", embedding_dimension=3), + ) + + +class TestIngest: + async def test_full_pipeline( + self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock + ) -> None: + result = await service.ingest("doc-1", "test.pdf", _make_chunks_json(3)) + + assert result.doc_id == "doc-1" + assert result.chunks_indexed == 3 + mock_embedding.embed.assert_awaited_once() + texts = mock_embedding.embed.call_args[0][0] + assert len(texts) == 3 + mock_vector_store.ensure_index.assert_awaited_once() + mock_vector_store.delete_document.assert_awaited_once_with("test-idx", "doc-1") + mock_vector_store.index_chunks.assert_awaited_once() + indexed = mock_vector_store.index_chunks.call_args[0][1] + assert len(indexed) == 3 + assert indexed[0].doc_id == "doc-1" + assert indexed[0].filename == "test.pdf" + assert indexed[0].embedding == [0.1, 0.2, 0.3] + + async def test_skips_deleted_chunks( + self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock + ) -> None: + mock_embedding.embed.return_value = [[0.1, 0.2, 0.3]] * 2 + mock_vector_store.index_chunks.return_value = 2 + result = await service.ingest("doc-1", "test.pdf", _make_chunks_json(3, with_deleted=True)) + + assert result.chunks_indexed == 2 + texts = mock_embedding.embed.call_args[0][0] + assert len(texts) == 2 + + async def test_empty_chunks( + self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock + ) -> None: + result = await service.ingest("doc-1", "test.pdf", json.dumps([])) + assert result.chunks_indexed == 0 + mock_embedding.embed.assert_not_awaited() + + async def test_idempotent_deletes_old( + self, service: IngestionService, mock_vector_store: AsyncMock + ) -> None: + mock_vector_store.delete_document.return_value = 5 + await service.ingest("doc-1", "test.pdf", _make_chunks_json(3)) + mock_vector_store.delete_document.assert_awaited_once_with("test-idx", "doc-1") + + async def test_bbox_conversion( + self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock + ) -> None: + mock_embedding.embed.return_value = [[0.1, 0.2, 0.3]] + mock_vector_store.index_chunks.return_value = 1 + await service.ingest("doc-1", "test.pdf", _make_chunks_json(1)) + indexed = mock_vector_store.index_chunks.call_args[0][1] + bbox = indexed[0].bboxes[0] + assert bbox.x == 0.0 + assert bbox.y == 0.0 + assert bbox.w == 100.0 + assert bbox.h == 50.0 + + async def test_with_binary_hash( + self, service: IngestionService, mock_vector_store: AsyncMock + ) -> None: + mock_embedding = service._embedding + mock_embedding.embed.return_value = [[0.1]] * 1 + await service.ingest("doc-1", "test.pdf", _make_chunks_json(1), binary_hash="abc123") + indexed = mock_vector_store.index_chunks.call_args[0][1] + assert indexed[0].origin is not None + assert indexed[0].origin.binary_hash == "abc123" + + +class TestDeleteDocument: + async def test_delegates_to_vector_store( + self, service: IngestionService, mock_vector_store: AsyncMock + ) -> None: + mock_vector_store.delete_document.return_value = 3 + result = await service.delete_document("doc-1") + assert result == 3 + + +class TestSearch: + async def test_embeds_and_searches( + self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock + ) -> None: + mock_embedding.embed.return_value = [[0.5, 0.6, 0.7]] + mock_vector_store.search_similar.return_value = [] + await service.search("test query", k=5) + mock_embedding.embed.assert_awaited_once_with(["test query"]) + mock_vector_store.search_similar.assert_awaited_once() + + +class TestEnsureIndex: + async def test_calls_vector_store( + self, service: IngestionService, mock_vector_store: AsyncMock + ) -> None: + await service.ensure_index() + mock_vector_store.ensure_index.assert_awaited_once() + call_args = mock_vector_store.ensure_index.call_args + assert call_args[0][0] == "test-idx" diff --git a/e2e/api/src/test/resources/ingestion/ingest-and-verify.feature b/e2e/api/src/test/resources/ingestion/ingest-and-verify.feature new file mode 100644 index 0000000..46ad687 --- /dev/null +++ b/e2e/api/src/test/resources/ingestion/ingest-and-verify.feature @@ -0,0 +1,59 @@ +@e2e @ingestion +Feature: Ingestion pipeline — PDF → chunks → embeddings → OpenSearch + + Background: + * url baseUrl + + Scenario: Upload PDF, analyze with chunking, ingest into OpenSearch, verify + + # Step 1: Check ingestion is available + Given path '/api/ingestion/status' + When method GET + Then status 200 + And match response.available == true + + # Step 2: Upload a PDF + Given path '/api/documents/upload' + And multipart file file = { read: 'classpath:common/data/generated/medium.pdf', filename: 'medium.pdf', contentType: 'application/pdf' } + When method POST + Then status 200 + * def docId = response.id + + # Step 3: Create analysis with chunking + Given path '/api/analyses' + And request { documentId: '#(docId)', pipelineOptions: { doOcr: true, tableMode: 'fast' }, chunkingOptions: { chunkerType: 'hybrid', maxTokens: 256 } } + When method POST + Then status 200 + * def jobId = response.id + + # Step 4: Poll until completed + Given path '/api/analyses', jobId + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 + And match response.status == 'COMPLETED' + And match response.chunksJson == '#string' + + # Step 5: Trigger ingestion + Given path '/api/ingestion', jobId + When method POST + Then status 200 + And match response.docId == docId + And match response.chunksIndexed == '#number' + And assert response.chunksIndexed > 0 + And match response.embeddingDimension == '#number' + And assert response.embeddingDimension > 0 + + # Step 6: Cleanup — delete ingested data + Given path '/api/ingestion', docId + When method DELETE + Then status 204 + + # Step 7: Cleanup — delete analysis and document + Given path '/api/analyses', jobId + When method DELETE + Then status 204 + + Given path '/api/documents', docId + When method DELETE + Then status 204 diff --git a/embedding-service/Dockerfile b/embedding-service/Dockerfile index b7b8e04..7311e03 100644 --- a/embedding-service/Dockerfile +++ b/embedding-service/Dockerfile @@ -2,6 +2,8 @@ FROM python:3.12-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + # Install dependencies first (cache layer) COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/frontend/src/features/ingestion/api.test.ts b/frontend/src/features/ingestion/api.test.ts new file mode 100644 index 0000000..1035474 --- /dev/null +++ b/frontend/src/features/ingestion/api.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ingestAnalysis, deleteIngested, fetchIngestionStatus } from './api' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +beforeEach(() => { + mockFetch.mockReset() +}) + +describe('ingestAnalysis', () => { + it('posts to /api/ingestion/:jobId', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ docId: 'doc-1', chunksIndexed: 5, embeddingDimension: 384 }), + }) + const result = await ingestAnalysis('job-1') + expect(mockFetch).toHaveBeenCalledWith( + '/api/ingestion/job-1', + expect.objectContaining({ method: 'POST' }), + ) + expect(result.chunksIndexed).toBe(5) + }) +}) + +describe('deleteIngested', () => { + it('deletes /api/ingestion/:docId', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => Promise.resolve(null) }) + await deleteIngested('doc-1') + expect(mockFetch).toHaveBeenCalledWith( + '/api/ingestion/doc-1', + expect.objectContaining({ method: 'DELETE' }), + ) + }) +}) + +describe('fetchIngestionStatus', () => { + it('gets /api/ingestion/status', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ available: true }), + }) + const result = await fetchIngestionStatus() + expect(result.available).toBe(true) + }) +}) diff --git a/frontend/src/features/ingestion/api.ts b/frontend/src/features/ingestion/api.ts new file mode 100644 index 0000000..4cdb345 --- /dev/null +++ b/frontend/src/features/ingestion/api.ts @@ -0,0 +1,25 @@ +import { apiFetch } from '../../shared/api/http' + +export interface IngestionResult { + docId: string + chunksIndexed: number + embeddingDimension: number +} + +export interface IngestionStatus { + available: boolean +} + +export function ingestAnalysis(jobId: string): Promise { + return apiFetch(`/api/ingestion/${jobId}`, { + method: 'POST', + }) +} + +export function deleteIngested(docId: string): Promise { + return apiFetch(`/api/ingestion/${docId}`, { method: 'DELETE' }) +} + +export function fetchIngestionStatus(): Promise { + return apiFetch('/api/ingestion/status') +} diff --git a/frontend/src/features/ingestion/index.ts b/frontend/src/features/ingestion/index.ts new file mode 100644 index 0000000..95916a3 --- /dev/null +++ b/frontend/src/features/ingestion/index.ts @@ -0,0 +1 @@ +export { useIngestionStore } from './store' diff --git a/frontend/src/features/ingestion/store.test.ts b/frontend/src/features/ingestion/store.test.ts new file mode 100644 index 0000000..b911903 --- /dev/null +++ b/frontend/src/features/ingestion/store.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useIngestionStore } from './store' +import * as api from './api' + +vi.mock('./api', () => ({ + fetchIngestionStatus: vi.fn(), + ingestAnalysis: vi.fn(), + deleteIngested: vi.fn(), +})) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('useIngestionStore', () => { + describe('checkAvailability', () => { + it('sets available to true when API responds', async () => { + vi.mocked(api.fetchIngestionStatus).mockResolvedValue({ available: true }) + const store = useIngestionStore() + await store.checkAvailability() + expect(store.available).toBe(true) + }) + + it('sets available to false on error', async () => { + vi.mocked(api.fetchIngestionStatus).mockRejectedValue(new Error('fail')) + const store = useIngestionStore() + await store.checkAvailability() + expect(store.available).toBe(false) + }) + }) + + describe('ingest', () => { + it('calls API and tracks ingested doc', async () => { + vi.mocked(api.ingestAnalysis).mockResolvedValue({ + docId: 'doc-1', + chunksIndexed: 5, + embeddingDimension: 384, + }) + const store = useIngestionStore() + const result = await store.ingest('job-1') + expect(result?.chunksIndexed).toBe(5) + expect(store.ingestedDocs['doc-1']).toBe(5) + expect(store.ingesting).toBe(false) + }) + + it('sets error on failure', async () => { + vi.mocked(api.ingestAnalysis).mockRejectedValue(new Error('fail')) + const store = useIngestionStore() + const result = await store.ingest('job-1') + expect(result).toBeNull() + expect(store.error).toBe('fail') + }) + }) + + describe('deleteIngested', () => { + it('removes doc from tracked map', async () => { + vi.mocked(api.deleteIngested).mockResolvedValue(null) + const store = useIngestionStore() + store.ingestedDocs['doc-1'] = 5 + await store.deleteIngested('doc-1') + expect(store.ingestedDocs['doc-1']).toBeUndefined() + }) + }) +}) diff --git a/frontend/src/features/ingestion/store.ts b/frontend/src/features/ingestion/store.ts new file mode 100644 index 0000000..4fbcd14 --- /dev/null +++ b/frontend/src/features/ingestion/store.ts @@ -0,0 +1,56 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as api from './api' + +export const useIngestionStore = defineStore('ingestion', () => { + const available = ref(false) + const ingesting = ref(false) + const error = ref(null) + /** Map of docId → chunks indexed count (tracks which docs are ingested) */ + const ingestedDocs = ref>({}) + + async function checkAvailability(): Promise { + try { + const status = await api.fetchIngestionStatus() + available.value = status.available + } catch { + available.value = false + } + } + + async function ingest(jobId: string): Promise { + ingesting.value = true + error.value = null + try { + const result = await api.ingestAnalysis(jobId) + ingestedDocs.value[result.docId] = result.chunksIndexed + return result + } catch (e) { + error.value = (e as Error).message || 'Ingestion failed' + console.error('Ingestion failed', e) + return null + } finally { + ingesting.value = false + } + } + + async function deleteIngested(docId: string): Promise { + try { + await api.deleteIngested(docId) + delete ingestedDocs.value[docId] + } catch (e) { + error.value = (e as Error).message || 'Failed to delete ingested data' + console.error('Failed to delete ingested data', e) + } + } + + return { + available, + ingesting, + error, + ingestedDocs, + checkAvailability, + ingest, + deleteIngested, + } +}) diff --git a/frontend/src/pages/DocumentsPage.vue b/frontend/src/pages/DocumentsPage.vue index 9dd2351..3256bff 100644 --- a/frontend/src/pages/DocumentsPage.vue +++ b/frontend/src/pages/DocumentsPage.vue @@ -2,13 +2,40 @@
-
+
{{ t('history.emptyDocs') }}
-
+
- +
+ + {{ t('ingestion.indexed') }} + {{ ingestionStore.ingestedDocs[doc.id] }} + + + {{ t('ingestion.notIndexed') }} + + + +
@@ -42,21 +93,75 @@ @@ -72,6 +177,11 @@ onMounted(() => { padding: 16px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; } .page-title { @@ -80,6 +190,57 @@ onMounted(() => { color: var(--text); } +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.search-input { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-size: 13px; + width: 180px; + outline: none; + transition: border-color var(--transition); +} + +.search-input:focus { + border-color: var(--accent); +} + +.filter-group, +.sort-group { + display: flex; + gap: 2px; + background: var(--bg-surface); + border-radius: var(--radius-sm); + padding: 2px; + border: 1px solid var(--border); +} + +.filter-btn, +.sort-btn { + padding: 4px 10px; + border: none; + background: none; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + border-radius: 4px; + cursor: pointer; + transition: all var(--transition); +} + +.filter-btn.active, +.sort-btn.active { + background: var(--accent); + color: white; +} + .page-content { flex: 1; overflow-y: auto; @@ -152,7 +313,41 @@ onMounted(() => { font-family: 'IBM Plex Mono', monospace; } -.doc-row-delete { +.doc-row-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.status-badge.indexed { + background: rgba(34, 197, 94, 0.15); + color: var(--success); +} + +.status-badge.not-indexed { + background: rgba(156, 163, 175, 0.15); + color: var(--text-muted); +} + +.badge-count { + font-family: 'IBM Plex Mono', monospace; + font-size: 10px; +} + +.action-btn { background: none; border: none; padding: 6px; @@ -164,14 +359,21 @@ onMounted(() => { transition: all var(--transition); } -.doc-row:hover .doc-row-delete { +.doc-row:hover .action-btn { opacity: 1; } -.doc-row-delete:hover { + +.action-btn:hover { + color: var(--accent); + background: rgba(249, 115, 22, 0.1); +} + +.action-btn.delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } -.doc-row-delete svg { + +.action-btn svg { width: 16px; height: 16px; } diff --git a/frontend/src/pages/StudioPage.vue b/frontend/src/pages/StudioPage.vue index e96769b..5ef26f9 100644 --- a/frontend/src/pages/StudioPage.vue +++ b/frontend/src/pages/StudioPage.vue @@ -94,6 +94,23 @@ {{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }} +
@@ -459,6 +476,7 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, reactive } import { useRoute, useRouter } from 'vue-router' import { useDocumentStore } from '../features/document/store' import { useAnalysisStore } from '../features/analysis/store' +import { useIngestionStore } from '../features/ingestion/store' import { DocumentUpload, DocumentList } from '../features/document/index' import { ResultTabs } from '../features/analysis/index' import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue' @@ -472,6 +490,7 @@ const route = useRoute() const router = useRouter() const documentStore = useDocumentStore() const analysisStore = useAnalysisStore() +const ingestionStore = useIngestionStore() const { t } = useI18n() const chunkingEnabled = useFeatureFlag('chunking') @@ -528,6 +547,14 @@ const pipelineOptions = reactive({ images_scale: 1.0, }) +const canIngest = computed(() => { + return ( + ingestionStore.available && + analysisStore.currentAnalysis?.status === 'COMPLETED' && + analysisStore.currentAnalysis?.chunksJson != null + ) +}) + const hasAnalysisResults = computed(() => { return ( analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0 @@ -564,6 +591,11 @@ async function runAnalysis() { await analysisStore.run(documentStore.selectedId, { ...pipelineOptions }) } +async function runIngestion() { + if (!analysisStore.currentAnalysis?.id) return + await ingestionStore.ingest(analysisStore.currentAnalysis.id) +} + function addMore() { documentStore.selectedId = null } @@ -598,6 +630,7 @@ watch( onMounted(async () => { await documentStore.load() analysisStore.load() + ingestionStore.checkAvailability() // Restore analysis from history via query param const analysisId = route.query.analysisId @@ -811,6 +844,21 @@ onBeforeUnmount(() => { cursor: not-allowed; } +.topbar-btn.ingest { + background: var(--success); + border-color: var(--success); + color: white; +} + +.topbar-btn.ingest:hover:not(:disabled) { + filter: brightness(1.1); +} + +.topbar-btn.ingest:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .topbar-btn .btn-icon { width: 16px; height: 16px; diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index d56832e..8a20d57 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -131,6 +131,23 @@ const messages: Messages = { 'chunking.batchNotice': 'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage.', + // Ingestion / My Documents + 'ingestion.ingest': 'Ingérer', + 'ingestion.ingesting': 'Ingestion...', + 'ingestion.reindex': 'Ré-indexer', + 'ingestion.indexed': 'Indexé', + 'ingestion.notIndexed': 'Non indexé', + 'ingestion.chunksIndexed': '{n} chunks indexés', + 'ingestion.openInStudio': 'Ouvrir dans le Studio', + 'ingestion.deleteIndex': "Supprimer de l'index", + 'ingestion.unavailable': 'Ingestion non disponible', + 'ingestion.filterAll': 'Tous', + 'ingestion.filterIndexed': 'Indexés', + 'ingestion.filterNotIndexed': 'Non indexés', + 'ingestion.sortName': 'Nom', + 'ingestion.sortDate': 'Date', + 'ingestion.search': 'Rechercher...', + // Pagination 'pagination.pageOf': 'Page {current} sur {total}', 'pagination.perPage': '/ page', @@ -266,6 +283,22 @@ const messages: Messages = { 'chunking.batchNotice': 'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.', + 'ingestion.ingest': 'Ingest', + 'ingestion.ingesting': 'Ingesting...', + 'ingestion.reindex': 'Re-index', + 'ingestion.indexed': 'Indexed', + 'ingestion.notIndexed': 'Not indexed', + 'ingestion.chunksIndexed': '{n} chunks indexed', + 'ingestion.openInStudio': 'Open in Studio', + 'ingestion.deleteIndex': 'Remove from index', + 'ingestion.unavailable': 'Ingestion unavailable', + 'ingestion.filterAll': 'All', + 'ingestion.filterIndexed': 'Indexed', + 'ingestion.filterNotIndexed': 'Not indexed', + 'ingestion.sortName': 'Name', + 'ingestion.sortDate': 'Date', + 'ingestion.search': 'Search...', + 'pagination.pageOf': 'Page {current} of {total}', 'pagination.perPage': '/ page',