From bae00e4025cb8232bd37edd8a949e221a4c92f62 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Sat, 11 Apr 2026 10:14:02 +0200 Subject: [PATCH 1/3] feat(#159): extract search into dedicated sidebar tab Move chunk search from DocumentsPage into a new Search bounded context (features/search/) with its own store, API layer, page and route. Clean search state out of the ingestion module. Closes #159 --- frontend/src/app/router/index.ts | 5 + frontend/src/features/ingestion/api.ts | 26 --- frontend/src/features/ingestion/store.ts | 34 ---- frontend/src/features/search/api.test.ts | 54 ++++++ frontend/src/features/search/api.ts | 27 +++ frontend/src/features/search/index.ts | 1 + frontend/src/features/search/store.test.ts | 87 ++++++++++ frontend/src/features/search/store.ts | 41 +++++ frontend/src/pages/DocumentsPage.vue | 127 -------------- frontend/src/pages/SearchPage.vue | 192 +++++++++++++++++++++ frontend/src/shared/i18n.ts | 7 + frontend/src/shared/ui/AppSidebar.vue | 16 ++ 12 files changed, 430 insertions(+), 187 deletions(-) create mode 100644 frontend/src/features/search/api.test.ts create mode 100644 frontend/src/features/search/api.ts create mode 100644 frontend/src/features/search/index.ts create mode 100644 frontend/src/features/search/store.test.ts create mode 100644 frontend/src/features/search/store.ts create mode 100644 frontend/src/pages/SearchPage.vue diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts index e78265e..19f8141 100644 --- a/frontend/src/app/router/index.ts +++ b/frontend/src/app/router/index.ts @@ -22,6 +22,11 @@ const routes: RouteRecordRaw[] = [ name: 'documents', component: () => import('../../pages/DocumentsPage.vue'), }, + { + path: '/search', + name: 'search', + component: () => import('../../pages/SearchPage.vue'), + }, { path: '/settings', name: 'settings', diff --git a/frontend/src/features/ingestion/api.ts b/frontend/src/features/ingestion/api.ts index ac749c4..f65480f 100644 --- a/frontend/src/features/ingestion/api.ts +++ b/frontend/src/features/ingestion/api.ts @@ -11,22 +11,6 @@ export interface IngestionStatus { opensearchConnected: boolean } -export interface SearchResultItem { - docId: string - filename: string - content: string - chunkIndex: number - pageNumber: number - score: number - headings: string[] -} - -export interface SearchResponse { - results: SearchResultItem[] - total: number - query: string -} - export function ingestAnalysis(jobId: string): Promise { return apiFetch(`/api/ingestion/${jobId}`, { method: 'POST', @@ -40,13 +24,3 @@ export function deleteIngested(docId: string): Promise { export function fetchIngestionStatus(): Promise { return apiFetch('/api/ingestion/status') } - -export function searchChunks( - query: string, - options: { docId?: string; k?: number } = {}, -): Promise { - const params = new URLSearchParams({ q: query }) - if (options.docId) params.set('doc_id', options.docId) - if (options.k) params.set('k', String(options.k)) - return apiFetch(`/api/ingestion/search?${params}`) -} diff --git a/frontend/src/features/ingestion/store.ts b/frontend/src/features/ingestion/store.ts index 9b2620b..7ffc01c 100644 --- a/frontend/src/features/ingestion/store.ts +++ b/frontend/src/features/ingestion/store.ts @@ -13,11 +13,6 @@ export const useIngestionStore = defineStore('ingestion', () => { 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 { @@ -77,30 +72,6 @@ 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, @@ -108,15 +79,10 @@ export const useIngestionStore = defineStore('ingestion', () => { error, ingestedDocs, currentStep, - searchResults, - searchQuery, - searching, checkAvailability, startPolling, stopPolling, ingest, deleteIngested, - search, - clearSearch, } }) diff --git a/frontend/src/features/search/api.test.ts b/frontend/src/features/search/api.test.ts new file mode 100644 index 0000000..bfe43aa --- /dev/null +++ b/frontend/src/features/search/api.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { searchChunks } from './api' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +beforeEach(() => { + mockFetch.mockReset() +}) + +describe('searchChunks', () => { + it('calls /api/ingestion/search with query', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + results: [ + { + docId: 'doc-1', + filename: 'test.pdf', + content: 'hello', + chunkIndex: 0, + pageNumber: 1, + score: 0.95, + headings: [], + }, + ], + total: 1, + query: 'hello', + }), + }) + const result = await searchChunks('hello') + expect(mockFetch).toHaveBeenCalledWith( + '/api/ingestion/search?q=hello', + expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), + ) + expect(result.results).toHaveLength(1) + expect(result.results[0].score).toBe(0.95) + }) + + it('passes docId and k options', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ results: [], total: 0, query: 'test' }), + }) + await searchChunks('test', { docId: 'doc-1', k: 5 }) + expect(mockFetch).toHaveBeenCalledWith( + '/api/ingestion/search?q=test&doc_id=doc-1&k=5', + expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), + ) + }) +}) diff --git a/frontend/src/features/search/api.ts b/frontend/src/features/search/api.ts new file mode 100644 index 0000000..5777972 --- /dev/null +++ b/frontend/src/features/search/api.ts @@ -0,0 +1,27 @@ +import { apiFetch } from '../../shared/api/http' + +export interface SearchResultItem { + docId: string + filename: string + content: string + chunkIndex: number + pageNumber: number + score: number + headings: string[] +} + +export interface SearchResponse { + results: SearchResultItem[] + total: number + query: string +} + +export function searchChunks( + query: string, + options: { docId?: string; k?: number } = {}, +): Promise { + const params = new URLSearchParams({ q: query }) + if (options.docId) params.set('doc_id', options.docId) + if (options.k) params.set('k', String(options.k)) + return apiFetch(`/api/ingestion/search?${params}`) +} diff --git a/frontend/src/features/search/index.ts b/frontend/src/features/search/index.ts new file mode 100644 index 0000000..6b320fb --- /dev/null +++ b/frontend/src/features/search/index.ts @@ -0,0 +1 @@ +export { useSearchStore } from './store' diff --git a/frontend/src/features/search/store.test.ts b/frontend/src/features/search/store.test.ts new file mode 100644 index 0000000..be52b2b --- /dev/null +++ b/frontend/src/features/search/store.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useSearchStore } from './store' +import * as api from './api' + +vi.mock('./api', () => ({ + searchChunks: vi.fn(), +})) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('useSearchStore', () => { + describe('search', () => { + it('stores results on success', async () => { + vi.mocked(api.searchChunks).mockResolvedValue({ + results: [ + { + docId: 'doc-1', + filename: 'test.pdf', + content: 'hello world', + chunkIndex: 0, + pageNumber: 1, + score: 0.9, + headings: [], + }, + ], + total: 1, + query: 'hello', + }) + const store = useSearchStore() + await store.search('hello') + expect(store.results).toHaveLength(1) + expect(store.query).toBe('hello') + expect(store.searching).toBe(false) + }) + + it('clears results on empty query', async () => { + const store = useSearchStore() + store.results = [ + { + docId: 'doc-1', + filename: 'test.pdf', + content: 'hello', + chunkIndex: 0, + pageNumber: 1, + score: 0.9, + headings: [], + }, + ] + await store.search('') + expect(store.results).toHaveLength(0) + expect(store.query).toBe('') + }) + + it('clears results on error', async () => { + vi.mocked(api.searchChunks).mockRejectedValue(new Error('fail')) + const store = useSearchStore() + await store.search('hello') + expect(store.results).toHaveLength(0) + expect(store.searching).toBe(false) + }) + }) + + describe('clear', () => { + it('resets state', () => { + const store = useSearchStore() + store.query = 'test' + store.results = [ + { + docId: 'doc-1', + filename: 'test.pdf', + content: 'hello', + chunkIndex: 0, + pageNumber: 1, + score: 0.9, + headings: [], + }, + ] + store.clear() + expect(store.query).toBe('') + expect(store.results).toHaveLength(0) + }) + }) +}) diff --git a/frontend/src/features/search/store.ts b/frontend/src/features/search/store.ts new file mode 100644 index 0000000..2014607 --- /dev/null +++ b/frontend/src/features/search/store.ts @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as api from './api' + +export const useSearchStore = defineStore('search', () => { + const results = ref([]) + const query = ref('') + const searching = ref(false) + + async function search(q: string, docId?: string): Promise { + if (!q.trim()) { + results.value = [] + query.value = '' + return + } + searching.value = true + query.value = q + try { + const resp = await api.searchChunks(q, { docId }) + results.value = resp.results + } catch (e) { + console.error('Search failed', e) + results.value = [] + } finally { + searching.value = false + } + } + + function clear(): void { + results.value = [] + query.value = '' + } + + return { + results, + query, + searching, + search, + clear, + } +}) diff --git a/frontend/src/pages/DocumentsPage.vue b/frontend/src/pages/DocumentsPage.vue index a4759e5..cc5727e 100644 --- a/frontend/src/pages/DocumentsPage.vue +++ b/frontend/src/pages/DocumentsPage.vue @@ -30,46 +30,6 @@ - - - - -
-
- - {{ t('ingestion.stepEmbedding') }} -
-
-
- - {{ t('ingestion.stepIndexing') }} -
-
-
- - {{ t('ingestion.stepDone') }}
@@ -507,6 +467,15 @@ @rechunked="onRechunked" />
+ + +
+ +
@@ -522,6 +491,7 @@ import { DocumentUpload, DocumentList } from '../features/document/index' import { ResultTabs } from '../features/analysis/index' import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue' import { ChunkPanel } from '../features/chunking' +import { IngestPanel } from '../features/ingestion' import { useFeatureFlag } from '../features/feature-flags' import { getPreviewUrl } from '../features/document/api' import { useI18n } from '../shared/i18n' @@ -632,11 +602,6 @@ 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 } @@ -658,22 +623,12 @@ 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, - async (status) => { + (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) - } } }, ) @@ -895,21 +850,6 @@ 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; @@ -924,79 +864,6 @@ 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; @@ -1541,9 +1408,10 @@ onBeforeUnmount(() => { padding-top: 16px; } -/* Verify panel */ +/* Verify / Prepare / Ingest panels */ .verify-panel, -.prepare-panel { +.prepare-panel, +.ingest-panel-wrapper { height: 100%; overflow: hidden; display: flex; diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index f0c5c46..2c7ff81 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -109,6 +109,7 @@ const messages: Messages = { // Chunking 'studio.prepare': 'Préparer', + 'studio.ingest': 'Ingérer', 'chunking.settings': 'Chunking', 'chunking.chunkerType': 'Type de chunker', 'chunking.maxTokens': 'Tokens max', @@ -137,6 +138,9 @@ const messages: Messages = { // Ingestion / My Documents 'ingestion.ingest': 'Ingérer', + 'ingestion.document': 'Document', + 'ingestion.chunkCount': 'Chunks prêts', + 'ingestion.successMessage': 'Indexation terminée avec succès !', 'ingestion.ingesting': 'Ingestion...', 'ingestion.reindex': 'Ré-indexer', 'ingestion.indexed': 'Indexé', @@ -274,6 +278,7 @@ const messages: Messages = { 'history.open': 'Open', 'studio.prepare': 'Prepare', + 'studio.ingest': 'Ingest', 'chunking.settings': 'Chunking', 'chunking.chunkerType': 'Chunker type', 'chunking.maxTokens': 'Max tokens', @@ -300,6 +305,9 @@ const messages: Messages = { 'search.hint': 'Enter a term to search through indexed chunks.', 'ingestion.ingest': 'Ingest', + 'ingestion.document': 'Document', + 'ingestion.chunkCount': 'Chunks ready', + 'ingestion.successMessage': 'Indexing completed successfully!', 'ingestion.ingesting': 'Ingesting...', 'ingestion.reindex': 'Re-index', 'ingestion.indexed': 'Indexed', From 2823a3eb5b3bd62ad862a634e38cc0e4a08a8e4a Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Sat, 11 Apr 2026 10:35:20 +0200 Subject: [PATCH 3/3] fix(#159): display raw relevance score instead of percentage OpenSearch BM25 scores are not normalized to 0-1, so multiplying by 100 produced misleading values like 300%. --- frontend/src/pages/SearchPage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/SearchPage.vue b/frontend/src/pages/SearchPage.vue index 2db93df..fb0b705 100644 --- a/frontend/src/pages/SearchPage.vue +++ b/frontend/src/pages/SearchPage.vue @@ -27,7 +27,7 @@ p.{{ result.pageNumber }} — chunk #{{ result.chunkIndex }} - {{ (result.score * 100).toFixed(0) }}% + {{ result.score.toFixed(1) }}

{{ result.content.slice(0, 200) }}{{ result.content.length > 200 ? '…' : '' }}