diff --git a/frontend/src/features/analysis/api.ts b/frontend/src/features/analysis/api.ts index 85b6da0..902c744 100644 --- a/frontend/src/features/analysis/api.ts +++ b/frontend/src/features/analysis/api.ts @@ -1,20 +1,34 @@ -import type { Analysis, PipelineOptions } from '../../shared/types' +import type { Analysis, Chunk, ChunkingOptions, PipelineOptions } from '../../shared/types' import { apiFetch } from '../../shared/api/http' export function createAnalysis( documentId: string, pipelineOptions: PipelineOptions | null = null, + chunkingOptions: ChunkingOptions | null = null, ): Promise { const body: Record = { documentId } if (pipelineOptions) { body.pipelineOptions = pipelineOptions } + if (chunkingOptions) { + body.chunkingOptions = chunkingOptions + } return apiFetch('/api/analyses', { method: 'POST', body: JSON.stringify(body), }) } +export function rechunkAnalysis( + jobId: string, + chunkingOptions: ChunkingOptions, +): Promise { + return apiFetch(`/api/analyses/${jobId}/rechunk`, { + method: 'POST', + body: JSON.stringify({ chunkingOptions }), + }) +} + export function fetchAnalyses(): Promise { return apiFetch('/api/analyses') } diff --git a/frontend/src/features/analysis/pipelineOptions.test.ts b/frontend/src/features/analysis/pipelineOptions.test.ts index 422ee50..a646e64 100644 --- a/frontend/src/features/analysis/pipelineOptions.test.ts +++ b/frontend/src/features/analysis/pipelineOptions.test.ts @@ -131,7 +131,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => { const store = useAnalysisStore() await store.run('d1') - expect(api.createAnalysis).toHaveBeenCalledWith('d1', null) + expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null) store.stopPolling() }) @@ -155,7 +155,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => { } await store.run('d1', opts) - expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts) + expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts, null) store.stopPolling() }) @@ -168,7 +168,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => { const opts = { do_ocr: false } await store.run('d1', opts) - expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }) + expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }, null) store.stopPolling() }) diff --git a/frontend/src/features/analysis/store.test.ts b/frontend/src/features/analysis/store.test.ts index 6b96672..6c6fb15 100644 --- a/frontend/src/features/analysis/store.test.ts +++ b/frontend/src/features/analysis/store.test.ts @@ -70,7 +70,7 @@ describe('useAnalysisStore', () => { expect(store.currentAnalysis).toEqual(job) expect(store.analyses[0]).toEqual(job) expect(store.running).toBe(true) - expect(api.createAnalysis).toHaveBeenCalledWith('d1', null) + expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null) // Advance timer to trigger polling await vi.advanceTimersByTimeAsync(2000) @@ -90,7 +90,7 @@ describe('useAnalysisStore', () => { const options = { do_ocr: false, table_mode: 'fast' } await store.run('d1', options) - expect(api.createAnalysis).toHaveBeenCalledWith('d1', options) + expect(api.createAnalysis).toHaveBeenCalledWith('d1', options, null) store.stopPolling() }) diff --git a/frontend/src/features/analysis/store.ts b/frontend/src/features/analysis/store.ts index d66dac5..8891cb7 100644 --- a/frontend/src/features/analysis/store.ts +++ b/frontend/src/features/analysis/store.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import type { Analysis, Page, PipelineOptions } from '../../shared/types' +import type { Analysis, Chunk, ChunkingOptions, Page, PipelineOptions } from '../../shared/types' import * as api from './api' export const useAnalysisStore = defineStore('analysis', () => { @@ -35,14 +35,44 @@ export const useAnalysisStore = defineStore('analysis', () => { } } + const currentChunks = computed(() => { + if (!currentAnalysis.value?.chunksJson) return [] + try { + return JSON.parse(currentAnalysis.value.chunksJson) as Chunk[] + } catch { + return [] + } + }) + + const rechunking = ref(false) + + async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise { + rechunking.value = true + error.value = null + try { + const chunks = await api.rechunkAnalysis(jobId, chunkingOptions) + if (currentAnalysis.value?.id === jobId) { + currentAnalysis.value = await api.fetchAnalysis(jobId) + } + return chunks + } catch (e) { + error.value = (e as Error).message || 'Failed to rechunk' + console.error('Failed to rechunk', e) + throw e + } finally { + rechunking.value = false + } + } + async function run( documentId: string, pipelineOptions: PipelineOptions | null = null, + chunkingOptions: ChunkingOptions | null = null, ): Promise { running.value = true error.value = null try { - const analysis = await api.createAnalysis(documentId, pipelineOptions) + const analysis = await api.createAnalysis(documentId, pipelineOptions, chunkingOptions) currentAnalysis.value = analysis analyses.value.unshift(analysis) startPolling(analysis.id) @@ -118,11 +148,14 @@ export const useAnalysisStore = defineStore('analysis', () => { analyses, currentAnalysis, currentPages, + currentChunks, running, + rechunking, error, clearError, load, run, + rechunk, select, remove, stopPolling, diff --git a/frontend/src/features/chunking/api.test.ts b/frontend/src/features/chunking/api.test.ts new file mode 100644 index 0000000..d258010 --- /dev/null +++ b/frontend/src/features/chunking/api.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { rechunkAnalysis, createAnalysis } from '../analysis/api' + +vi.mock('../../shared/api/http', () => ({ + apiFetch: vi.fn(), +})) + +import { apiFetch } from '../../shared/api/http' + +describe('chunking API', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createAnalysis sends chunkingOptions when provided', async () => { + const job = { id: '1', documentId: 'doc-1', status: 'PENDING' } + apiFetch.mockResolvedValue(job) + + const chunkingOpts = { chunker_type: 'hybrid' as const, max_tokens: 256 } + await createAnalysis('doc-1', null, chunkingOpts) + + expect(apiFetch).toHaveBeenCalledWith('/api/analyses', { + method: 'POST', + body: JSON.stringify({ documentId: 'doc-1', chunkingOptions: chunkingOpts }), + }) + }) + + it('createAnalysis omits chunkingOptions when null', async () => { + apiFetch.mockResolvedValue({ id: '1' }) + + await createAnalysis('doc-1', null, null) + + expect(apiFetch).toHaveBeenCalledWith('/api/analyses', { + method: 'POST', + body: JSON.stringify({ documentId: 'doc-1' }), + }) + }) + + it('rechunkAnalysis sends POST to rechunk endpoint', async () => { + const chunks = [{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10 }] + apiFetch.mockResolvedValue(chunks) + + const opts = { chunker_type: 'hybrid' as const, max_tokens: 512 } + const result = await rechunkAnalysis('job-1', opts) + + expect(apiFetch).toHaveBeenCalledWith('/api/analyses/job-1/rechunk', { + method: 'POST', + body: JSON.stringify({ chunkingOptions: opts }), + }) + expect(result).toEqual(chunks) + }) +}) diff --git a/frontend/src/features/chunking/index.ts b/frontend/src/features/chunking/index.ts new file mode 100644 index 0000000..a8d8572 --- /dev/null +++ b/frontend/src/features/chunking/index.ts @@ -0,0 +1 @@ +export { default as ChunkPanel } from './ui/ChunkPanel.vue' diff --git a/frontend/src/features/chunking/store.test.ts b/frontend/src/features/chunking/store.test.ts new file mode 100644 index 0000000..80b3fa3 --- /dev/null +++ b/frontend/src/features/chunking/store.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAnalysisStore } from '../analysis/store' + +vi.mock('../analysis/api', () => ({ + createAnalysis: vi.fn(), + fetchAnalyses: vi.fn().mockResolvedValue([]), + fetchAnalysis: vi.fn(), + deleteAnalysis: vi.fn(), + rechunkAnalysis: vi.fn(), +})) + +import * as api from '../analysis/api' + +describe('analysis store — chunking', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('currentChunks parses chunksJson from current analysis', () => { + const store = useAnalysisStore() + const chunks = [ + { text: 'chunk1', headings: ['H1'], sourcePage: 1, tokenCount: 10 }, + { text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20 }, + ] + store.currentAnalysis = { + id: 'j1', + documentId: 'd1', + documentFilename: null, + status: 'COMPLETED', + contentMarkdown: null, + contentHtml: null, + pagesJson: null, + chunksJson: JSON.stringify(chunks), + hasDocumentJson: true, + errorMessage: null, + startedAt: null, + completedAt: null, + createdAt: '2024-01-01', + } + expect(store.currentChunks).toEqual(chunks) + }) + + it('currentChunks returns empty array when no chunksJson', () => { + const store = useAnalysisStore() + store.currentAnalysis = { + id: 'j1', + documentId: 'd1', + documentFilename: null, + status: 'COMPLETED', + contentMarkdown: null, + contentHtml: null, + pagesJson: null, + chunksJson: null, + hasDocumentJson: false, + errorMessage: null, + startedAt: null, + completedAt: null, + createdAt: '2024-01-01', + } + expect(store.currentChunks).toEqual([]) + }) + + it('rechunk calls API and refreshes analysis', async () => { + const store = useAnalysisStore() + const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5 }] + vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks) + vi.mocked(api.fetchAnalysis).mockResolvedValue({ + id: 'j1', + documentId: 'd1', + documentFilename: null, + status: 'COMPLETED', + contentMarkdown: null, + contentHtml: null, + pagesJson: null, + chunksJson: JSON.stringify(chunks), + hasDocumentJson: true, + errorMessage: null, + startedAt: null, + completedAt: null, + createdAt: '2024-01-01', + }) + + store.currentAnalysis = { + id: 'j1', + documentId: 'd1', + documentFilename: null, + status: 'COMPLETED', + contentMarkdown: null, + contentHtml: null, + pagesJson: null, + chunksJson: null, + hasDocumentJson: true, + errorMessage: null, + startedAt: null, + completedAt: null, + createdAt: '2024-01-01', + } + + const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 }) + + expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', { + chunker_type: 'hybrid', + max_tokens: 256, + }) + expect(result).toEqual(chunks) + expect(store.rechunking).toBe(false) + }) + + it('run passes chunkingOptions to API', async () => { + const store = useAnalysisStore() + vi.mocked(api.createAnalysis).mockResolvedValue({ + id: 'j1', + documentId: 'd1', + documentFilename: null, + status: 'PENDING', + contentMarkdown: null, + contentHtml: null, + pagesJson: null, + chunksJson: null, + hasDocumentJson: false, + errorMessage: null, + startedAt: null, + completedAt: null, + createdAt: '2024-01-01', + }) + + await store.run('d1', null, { chunker_type: 'hierarchical' }) + + expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, { + chunker_type: 'hierarchical', + }) + }) +}) diff --git a/frontend/src/features/chunking/ui/ChunkPanel.vue b/frontend/src/features/chunking/ui/ChunkPanel.vue new file mode 100644 index 0000000..b93bcde --- /dev/null +++ b/frontend/src/features/chunking/ui/ChunkPanel.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/frontend/src/pages/StudioPage.vue b/frontend/src/pages/StudioPage.vue index aedc9a4..fd802d0 100644 --- a/frontend/src/pages/StudioPage.vue +++ b/frontend/src/pages/StudioPage.vue @@ -37,6 +37,15 @@ > {{ t('studio.verify') }} +
@@ -280,6 +289,11 @@ @highlight-element="highlightedElementIndex = $event" />
+ + +
+ +
@@ -293,6 +307,8 @@ import { useAnalysisStore } from '../features/analysis/store' 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 { useFeatureFlag } from '../features/feature-flags' import { getPreviewUrl } from '../features/document/api' import { useI18n } from '../shared/i18n' import type { PipelineOptions } from '../shared/types' @@ -302,6 +318,7 @@ const router = useRouter() const documentStore = useDocumentStore() const analysisStore = useAnalysisStore() const { t } = useI18n() +const chunkingEnabled = useFeatureFlag('chunking') const mode = ref('configurer') const currentPage = ref(1) diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 92b314b..6c4b759 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -95,6 +95,18 @@ const messages: Messages = { 'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.', 'history.open': 'Ouvrir', + // Chunking + 'studio.prepare': 'Préparer', + 'chunking.settings': 'Chunking', + 'chunking.chunkerType': 'Type de chunker', + 'chunking.maxTokens': 'Tokens max', + 'chunking.mergePeers': 'Fusionner les pairs', + 'chunking.repeatTableHeader': 'Répéter en-têtes tableaux', + 'chunking.run': 'Chunker', + 'chunking.chunking': 'Chunking...', + 'chunking.chunks': 'chunks', + 'chunking.noChunks': 'Lancez le chunking pour préparer les segments.', + // Settings 'settings.title': 'Paramètres', 'settings.apiUrl': 'API URL', @@ -185,6 +197,17 @@ const messages: Messages = { 'history.emptyDocs': 'No documents yet. Upload a document from the Studio.', 'history.open': 'Open', + 'studio.prepare': 'Prepare', + 'chunking.settings': 'Chunking', + 'chunking.chunkerType': 'Chunker type', + 'chunking.maxTokens': 'Max tokens', + 'chunking.mergePeers': 'Merge peers', + 'chunking.repeatTableHeader': 'Repeat table headers', + 'chunking.run': 'Chunk', + 'chunking.chunking': 'Chunking...', + 'chunking.chunks': 'chunks', + 'chunking.noChunks': 'Run chunking to prepare segments.', + 'settings.title': 'Settings', 'settings.apiUrl': 'API URL', 'settings.version': 'Version', diff --git a/frontend/src/shared/types.ts b/frontend/src/shared/types.ts index 8035836..5aa7968 100644 --- a/frontend/src/shared/types.ts +++ b/frontend/src/shared/types.ts @@ -30,12 +30,28 @@ export interface Analysis { contentMarkdown: string | null contentHtml: string | null pagesJson: string | null + chunksJson: string | null + hasDocumentJson: boolean errorMessage: string | null startedAt: string | null completedAt: string | null createdAt: string } +export interface ChunkingOptions { + chunker_type?: 'hybrid' | 'hierarchical' + max_tokens?: number + merge_peers?: boolean + repeat_table_header?: boolean +} + +export interface Chunk { + text: string + headings: string[] + sourcePage: number | null + tokenCount: number +} + export interface PageElement { type: string bbox: [number, number, number, number]