diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index e157df4..1df46d6 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -26,6 +26,16 @@ class _CamelModel(BaseModel): ) +class HealthResponse(_CamelModel): + status: str + version: str + engine: str + deployment_mode: str + database: str + max_page_count: int | None = None + max_file_size_mb: int | None = None + + class DocumentResponse(_CamelModel): id: str filename: str diff --git a/document-parser/main.py b/document-parser/main.py index 3be3921..aa6573c 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.schemas import HealthResponse from infra.rate_limiter import RateLimiterMiddleware from infra.settings import settings from persistence.analysis_repo import SqliteAnalysisRepository @@ -141,8 +142,8 @@ app.include_router(documents_router) app.include_router(analyses_router) -@app.get("/api/health") -async def health() -> dict[str, str | int]: +@app.get("/api/health", response_model=HealthResponse) +async def health() -> HealthResponse: """Health check endpoint — verifies database connectivity.""" db_status = "ok" try: @@ -153,15 +154,12 @@ async def health() -> dict[str, str | int]: logger.warning("Health check: database unreachable", exc_info=True) status = "ok" if db_status == "ok" else "degraded" - result: dict[str, str | int] = { - "status": status, - "version": settings.app_version, - "engine": settings.conversion_engine, - "deploymentMode": settings.deployment_mode, - "database": db_status, - } - if settings.max_page_count > 0: - result["maxPageCount"] = settings.max_page_count - if settings.max_file_size_mb > 0: - result["maxFileSizeMb"] = settings.max_file_size_mb - return result + return HealthResponse( + status=status, + version=settings.app_version, + engine=settings.conversion_engine, + deployment_mode=settings.deployment_mode, + database=db_status, + max_page_count=settings.max_page_count if settings.max_page_count > 0 else None, + max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None, + ) diff --git a/frontend/src/features/analysis/store.ts b/frontend/src/features/analysis/store.ts index 6c509a6..06bb1b9 100644 --- a/frontend/src/features/analysis/store.ts +++ b/frontend/src/features/analysis/store.ts @@ -45,26 +45,6 @@ export const useAnalysisStore = defineStore('analysis', () => { } }) - 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, @@ -157,12 +137,10 @@ export const useAnalysisStore = defineStore('analysis', () => { 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 index d258010..4fbd990 100644 --- a/frontend/src/features/chunking/api.test.ts +++ b/frontend/src/features/chunking/api.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { rechunkAnalysis, createAnalysis } from '../analysis/api' +import { rechunkAnalysis } from './api' vi.mock('../../shared/api/http', () => ({ apiFetch: vi.fn(), @@ -12,30 +12,6 @@ describe('chunking API', () => { 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) diff --git a/frontend/src/features/chunking/api.ts b/frontend/src/features/chunking/api.ts new file mode 100644 index 0000000..5a79601 --- /dev/null +++ b/frontend/src/features/chunking/api.ts @@ -0,0 +1,9 @@ +import type { Chunk, ChunkingOptions } from '../../shared/types' +import { apiFetch } from '../../shared/api/http' + +export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions): Promise { + return apiFetch(`/api/analyses/${jobId}/rechunk`, { + method: 'POST', + body: JSON.stringify({ chunkingOptions }), + }) +} diff --git a/frontend/src/features/chunking/index.ts b/frontend/src/features/chunking/index.ts index a8d8572..05475d7 100644 --- a/frontend/src/features/chunking/index.ts +++ b/frontend/src/features/chunking/index.ts @@ -1 +1,2 @@ export { default as ChunkPanel } from './ui/ChunkPanel.vue' +export { useChunkingStore } from './store' diff --git a/frontend/src/features/chunking/store.test.ts b/frontend/src/features/chunking/store.test.ts index 17fd7de..e7296f4 100644 --- a/frontend/src/features/chunking/store.test.ts +++ b/frontend/src/features/chunking/store.test.ts @@ -1,109 +1,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' -import { useAnalysisStore } from '../analysis/store' +import { useChunkingStore } from './store' -vi.mock('../analysis/api', () => ({ - createAnalysis: vi.fn(), - fetchAnalyses: vi.fn().mockResolvedValue([]), - fetchAnalysis: vi.fn(), - deleteAnalysis: vi.fn(), +vi.mock('./api', () => ({ rechunkAnalysis: vi.fn(), })) -import * as api from '../analysis/api' +import * as api from './api' -describe('analysis store — chunking', () => { +describe('useChunkingStore', () => { 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, - bboxes: [{ page: 1, bbox: [10, 20, 100, 80] }], - }, - { text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20, bboxes: [] }, - ] - 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('starts with default state', () => { + const store = useChunkingStore() + expect(store.rechunking).toBe(false) + expect(store.error).toBeNull() }) - 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() + it('rechunk calls API and returns chunks', async () => { const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [] }] 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 store = useChunkingStore() const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 }) expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', { @@ -114,28 +35,31 @@ describe('analysis store — chunking', () => { 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', - }) + it('rechunk sets rechunking during execution', async () => { + let resolve: (v: any) => void + vi.mocked(api.rechunkAnalysis).mockImplementation( + () => + new Promise((r) => { + resolve = r + }), + ) - await store.run('d1', null, { chunker_type: 'hierarchical' }) + const store = useChunkingStore() + const promise = store.rechunk('j1', { chunker_type: 'hybrid' }) - expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, { - chunker_type: 'hierarchical', - }) + expect(store.rechunking).toBe(true) + resolve!([]) + await promise + expect(store.rechunking).toBe(false) + }) + + it('rechunk handles errors', async () => { + vi.mocked(api.rechunkAnalysis).mockRejectedValue(new Error('fail')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const store = useChunkingStore() + await expect(store.rechunk('j1', { chunker_type: 'hybrid' })).rejects.toThrow('fail') + expect(store.rechunking).toBe(false) + expect(store.error).toBe('fail') }) }) diff --git a/frontend/src/features/chunking/store.ts b/frontend/src/features/chunking/store.ts new file mode 100644 index 0000000..86feaa2 --- /dev/null +++ b/frontend/src/features/chunking/store.ts @@ -0,0 +1,25 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Chunk, ChunkingOptions } from '../../shared/types' +import * as api from './api' + +export const useChunkingStore = defineStore('chunking', () => { + const rechunking = ref(false) + const error = ref(null) + + async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise { + rechunking.value = true + error.value = null + try { + return await api.rechunkAnalysis(jobId, chunkingOptions) + } catch (e) { + error.value = (e as Error).message || 'Failed to rechunk' + console.error('Failed to rechunk', e) + throw e + } finally { + rechunking.value = false + } + } + + return { rechunking, error, rechunk } +}) diff --git a/frontend/src/features/chunking/ui/ChunkPanel.vue b/frontend/src/features/chunking/ui/ChunkPanel.vue index ff631fa..de70ad3 100644 --- a/frontend/src/features/chunking/ui/ChunkPanel.vue +++ b/frontend/src/features/chunking/ui/ChunkPanel.vue @@ -60,11 +60,11 @@ @@ -111,11 +111,9 @@ -
+

- {{ - analysisStore.currentChunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks') - }} + {{ chunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks') }}

@@ -132,7 +130,7 @@ diff --git a/frontend/src/features/document/store.ts b/frontend/src/features/document/store.ts index 7dd935a..d43aaf1 100644 --- a/frontend/src/features/document/store.ts +++ b/frontend/src/features/document/store.ts @@ -1,11 +1,10 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import type { Document } from '../../shared/types' -import { useFeatureFlagStore } from '../feature-flags/store' +import { appMaxFileSizeMb } from '../../shared/appConfig' import * as api from './api' export const useDocumentStore = defineStore('document', () => { - const flags = useFeatureFlagStore() const documents = ref([]) const selectedId = ref(null) const uploading = ref(false) @@ -26,7 +25,7 @@ export const useDocumentStore = defineStore('document', () => { } async function upload(file: File): Promise { - const maxMb = flags.maxFileSizeMb + const maxMb = appMaxFileSizeMb.value if (maxMb > 0 && file.size > maxMb * 1024 * 1024) { error.value = `File too large (max ${maxMb} MB)` throw new Error(error.value) diff --git a/frontend/src/features/document/ui/DocumentUpload.vue b/frontend/src/features/document/ui/DocumentUpload.vue index e12f46e..64fcf28 100644 --- a/frontend/src/features/document/ui/DocumentUpload.vue +++ b/frontend/src/features/document/ui/DocumentUpload.vue @@ -33,24 +33,23 @@