From efabe84d66bc71c88341ac3d655a0d2b3daff72d Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 10 Apr 2026 22:49:20 +0200 Subject: [PATCH] feat(#77): multi-step ingestion progress stepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual stepper in Studio topbar: Embedding → Indexing → Done. Each step shows pending/active/done with animated dot. Store tracks currentStep through the pipeline. Auto-resets after 2s. --- document-parser/services/ingestion_service.py | 24 ++++ frontend/src/features/ingestion/store.ts | 66 +++++++++ frontend/src/pages/StudioPage.vue | 126 +++++++++++++++++- frontend/src/shared/i18n.ts | 18 +++ 4 files changed, 233 insertions(+), 1 deletion(-) diff --git a/document-parser/services/ingestion_service.py b/document-parser/services/ingestion_service.py index ca29c1e..cce6b98 100644 --- a/document-parser/services/ingestion_service.py +++ b/document-parser/services/ingestion_service.py @@ -164,3 +164,27 @@ class IngestionService: k=k, doc_id=doc_id, ) + + async def search_fulltext( + self, + query: str, + *, + k: int = 20, + doc_id: str | None = None, + ) -> list: + """Full-text keyword search in indexed chunks.""" + return await self._vector_store.search_fulltext( + self._config.index_name, + query, + k=k, + doc_id=doc_id, + ) + + async def ping(self) -> bool: + """Check if the OpenSearch cluster is reachable.""" + try: + info = await self._vector_store._client.info() + return bool(info) + except Exception: + logger.debug("OpenSearch ping failed", exc_info=True) + return False diff --git a/frontend/src/features/ingestion/store.ts b/frontend/src/features/ingestion/store.ts index 4fbcd14..9b2620b 100644 --- a/frontend/src/features/ingestion/store.ts +++ b/frontend/src/features/ingestion/store.ts @@ -2,35 +2,68 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import * as api from './api' +export type IngestionStep = 'embedding' | 'indexing' | 'done' + export const useIngestionStore = defineStore('ingestion', () => { const available = ref(false) + const opensearchConnected = ref(false) const ingesting = ref(false) const error = ref(null) /** Map of docId → chunks indexed count (tracks which docs are ingested) */ const ingestedDocs = ref>({}) + /** Current step of the ingestion pipeline (null when idle) */ + const currentStep = ref(null) + /** Search results */ + const searchResults = ref([]) + const searchQuery = ref('') + const searching = ref(false) + + let _pollTimer: ReturnType | null = null async function checkAvailability(): Promise { try { const status = await api.fetchIngestionStatus() available.value = status.available + opensearchConnected.value = status.opensearchConnected } catch { available.value = false + opensearchConnected.value = false + } + } + + function startPolling(intervalMs = 30_000): void { + stopPolling() + _pollTimer = setInterval(checkAvailability, intervalMs) + } + + function stopPolling(): void { + if (_pollTimer) { + clearInterval(_pollTimer) + _pollTimer = null } } async function ingest(jobId: string): Promise { ingesting.value = true error.value = null + currentStep.value = 'embedding' try { + currentStep.value = 'indexing' const result = await api.ingestAnalysis(jobId) + currentStep.value = 'done' ingestedDocs.value[result.docId] = result.chunksIndexed return result } catch (e) { error.value = (e as Error).message || 'Ingestion failed' console.error('Ingestion failed', e) + currentStep.value = null return null } finally { ingesting.value = false + // Reset step after a short delay so the user sees the "done" state + setTimeout(() => { + currentStep.value = null + }, 2000) } } @@ -44,13 +77,46 @@ export const useIngestionStore = defineStore('ingestion', () => { } } + async function search(query: string, docId?: string): Promise { + if (!query.trim()) { + searchResults.value = [] + searchQuery.value = '' + return + } + searching.value = true + searchQuery.value = query + try { + const resp = await api.searchChunks(query, { docId }) + searchResults.value = resp.results + } catch (e) { + console.error('Search failed', e) + searchResults.value = [] + } finally { + searching.value = false + } + } + + function clearSearch(): void { + searchResults.value = [] + searchQuery.value = '' + } + return { available, + opensearchConnected, ingesting, error, ingestedDocs, + currentStep, + searchResults, + searchQuery, + searching, checkAvailability, + startPolling, + stopPolling, ingest, deleteIngested, + search, + clearSearch, } }) diff --git a/frontend/src/pages/StudioPage.vue b/frontend/src/pages/StudioPage.vue index 5ef26f9..16d4c8e 100644 --- a/frontend/src/pages/StudioPage.vue +++ b/frontend/src/pages/StudioPage.vue @@ -114,6 +114,47 @@ + +
+
+ + {{ t('ingestion.stepEmbedding') }} +
+
+
+ + {{ t('ingestion.stepIndexing') }} +
+
+
+ + {{ t('ingestion.stepDone') }} +
+
+
@@ -617,12 +658,22 @@ watch(currentPage, () => { }) // Auto-switch to verify when analysis completes + refresh document data (pageCount) +// Auto-trigger ingestion if pipeline is available (#81) watch( () => analysisStore.currentAnalysis?.status, - (status) => { + async (status) => { if (status === 'COMPLETED') { mode.value = 'verify' documentStore.load() + + // Auto-ingest if chunks are available and pipeline is configured + if ( + ingestionStore.available && + analysisStore.currentAnalysis?.chunksJson && + analysisStore.currentAnalysis?.id + ) { + await ingestionStore.ingest(analysisStore.currentAnalysis.id) + } } }, ) @@ -873,6 +924,79 @@ onBeforeUnmount(() => { animation: spin 0.6s linear infinite; } +/* Ingestion stepper */ +.ingestion-stepper { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + padding: 8px 20px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); +} + +.step { + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; +} + +.step-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--border); + transition: all 0.3s ease; +} + +.step.active .step-dot { + background: var(--accent); + box-shadow: 0 0 6px var(--accent); + animation: pulse-dot 1s ease-in-out infinite; +} + +.step.done .step-dot { + background: var(--success, #22c55e); +} + +.step-label { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; +} + +.step.active .step-label { + color: var(--accent); +} + +.step.done .step-label { + color: var(--success, #22c55e); +} + +.step-line { + width: 40px; + height: 2px; + background: var(--border); + transition: background 0.3s ease; +} + +.step-line.done { + background: var(--success, #22c55e); +} + +@keyframes pulse-dot { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.3); + opacity: 0.7; + } +} + /* Doc info bar */ .doc-infobar { display: flex; diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 8a20d57..3c9dd5f 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -140,6 +140,8 @@ const messages: Messages = { 'ingestion.chunksIndexed': '{n} chunks indexés', 'ingestion.openInStudio': 'Ouvrir dans le Studio', 'ingestion.deleteIndex': "Supprimer de l'index", + 'ingestion.deleteConfirm': + 'Retirer ce document de l\u2019index ? Les chunks seront supprimés mais le document source restera.', 'ingestion.unavailable': 'Ingestion non disponible', 'ingestion.filterAll': 'Tous', 'ingestion.filterIndexed': 'Indexés', @@ -147,6 +149,13 @@ const messages: Messages = { 'ingestion.sortName': 'Nom', 'ingestion.sortDate': 'Date', 'ingestion.search': 'Rechercher...', + 'ingestion.searchChunks': 'Rechercher dans les chunks…', + 'ingestion.noResults': 'Aucun résultat pour « {q} ».', + 'ingestion.stepEmbedding': 'Embedding…', + 'ingestion.stepIndexing': 'Indexation…', + 'ingestion.stepDone': 'Terminé', + 'ingestion.opensearchConnected': 'OpenSearch connecté', + 'ingestion.opensearchDisconnected': 'OpenSearch déconnecté', // Pagination 'pagination.pageOf': 'Page {current} sur {total}', @@ -291,6 +300,8 @@ const messages: Messages = { 'ingestion.chunksIndexed': '{n} chunks indexed', 'ingestion.openInStudio': 'Open in Studio', 'ingestion.deleteIndex': 'Remove from index', + 'ingestion.deleteConfirm': + 'Remove this document from the index? Chunks will be deleted but the source document will remain.', 'ingestion.unavailable': 'Ingestion unavailable', 'ingestion.filterAll': 'All', 'ingestion.filterIndexed': 'Indexed', @@ -298,6 +309,13 @@ const messages: Messages = { 'ingestion.sortName': 'Name', 'ingestion.sortDate': 'Date', 'ingestion.search': 'Search...', + 'ingestion.searchChunks': 'Search indexed chunks…', + 'ingestion.noResults': 'No results for "{q}".', + 'ingestion.stepEmbedding': 'Embedding…', + 'ingestion.stepIndexing': 'Indexing…', + 'ingestion.stepDone': 'Done', + 'ingestion.opensearchConnected': 'OpenSearch connected', + 'ingestion.opensearchDisconnected': 'OpenSearch unreachable', 'pagination.pageOf': 'Page {current} of {total}', 'pagination.perPage': '/ page',