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 @@ - -