Merge pull request #144 from scub-france/fix/decoupling-audit
fix(decoupling): eliminate cross-feature imports & typed health endpoint
This commit is contained in:
commit
6d0c4dd192
22 changed files with 276 additions and 239 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,26 +45,6 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
}
|
||||
})
|
||||
|
||||
const rechunking = ref(false)
|
||||
|
||||
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
9
frontend/src/features/chunking/api.ts
Normal file
9
frontend/src/features/chunking/api.ts
Normal file
|
|
@ -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<Chunk[]> {
|
||||
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ chunkingOptions }),
|
||||
})
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { default as ChunkPanel } from './ui/ChunkPanel.vue'
|
||||
export { useChunkingStore } from './store'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
25
frontend/src/features/chunking/store.ts
Normal file
25
frontend/src/features/chunking/store.ts
Normal file
|
|
@ -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<string | null>(null)
|
||||
|
||||
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||
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 }
|
||||
})
|
||||
|
|
@ -60,11 +60,11 @@
|
|||
<button
|
||||
class="chunk-btn primary"
|
||||
data-e2e="chunk-btn"
|
||||
:disabled="!canRechunk || analysisStore.rechunking"
|
||||
:disabled="!canRechunk || chunkingStore.rechunking"
|
||||
@click="doRechunk"
|
||||
>
|
||||
<div v-if="analysisStore.rechunking" class="spinner-sm" />
|
||||
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
||||
<div v-if="chunkingStore.rechunking" class="spinner-sm" />
|
||||
{{ chunkingStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
||||
</button>
|
||||
|
||||
<!-- Batch mode notice -->
|
||||
|
|
@ -111,11 +111,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
|
||||
<div class="chunk-empty" v-else-if="!chunkingStore.rechunking">
|
||||
<p>
|
||||
{{
|
||||
analysisStore.currentChunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks')
|
||||
}}
|
||||
{{ chunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -132,7 +130,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useAnalysisStore } from '../../analysis/store'
|
||||
import { useChunkingStore } from '../store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import { usePagination } from '../../../shared/composables/usePagination'
|
||||
import { PaginationBar } from '../../../shared/ui'
|
||||
|
|
@ -140,13 +138,18 @@ import type { Chunk, ChunkBbox, ChunkingOptions } from '../../../shared/types'
|
|||
|
||||
const props = defineProps<{
|
||||
currentPage: number
|
||||
analysisId: string | null
|
||||
analysisStatus: string | null
|
||||
hasDocumentJson: boolean
|
||||
chunks: Chunk[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'highlight-bboxes': [bboxes: ChunkBbox[]]
|
||||
rechunked: []
|
||||
}>()
|
||||
|
||||
const analysisStore = useAnalysisStore()
|
||||
const chunkingStore = useChunkingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const configOpen = ref(true)
|
||||
|
|
@ -159,19 +162,15 @@ const options = reactive<Required<ChunkingOptions>>({
|
|||
})
|
||||
|
||||
const canRechunk = computed(() => {
|
||||
const analysis = analysisStore.currentAnalysis
|
||||
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
|
||||
return props.analysisStatus === 'COMPLETED' && props.hasDocumentJson
|
||||
})
|
||||
|
||||
/** True when the analysis was batched (document_json unavailable). */
|
||||
const isBatchedAnalysis = computed(() => {
|
||||
const analysis = analysisStore.currentAnalysis
|
||||
return analysis?.status === 'COMPLETED' && !analysis.hasDocumentJson
|
||||
return props.analysisStatus === 'COMPLETED' && !props.hasDocumentJson
|
||||
})
|
||||
|
||||
const pageChunks = computed(() =>
|
||||
analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage),
|
||||
)
|
||||
const pageChunks = computed(() => props.chunks.filter((c) => c.sourcePage === props.currentPage))
|
||||
const pagination = usePagination(pageChunks, { pageSize: 20 })
|
||||
|
||||
function globalIndex(localIdx: number): number {
|
||||
|
|
@ -192,8 +191,9 @@ function onChunkLeave() {
|
|||
}
|
||||
|
||||
async function doRechunk() {
|
||||
if (!analysisStore.currentAnalysis) return
|
||||
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
|
||||
if (!props.analysisId) return
|
||||
await chunkingStore.rechunk(props.analysisId, { ...options })
|
||||
emit('rechunked')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Document[]>([])
|
||||
const selectedId = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
|
@ -26,7 +25,7 @@ export const useDocumentStore = defineStore('document', () => {
|
|||
}
|
||||
|
||||
async function upload(file: File): Promise<Document> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -33,24 +33,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDocumentStore } from '../store'
|
||||
import { useFeatureFlagStore } from '../../feature-flags/store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import { appMaxFileSizeMb, appMaxPageCount } from '../../../shared/appConfig'
|
||||
|
||||
const emit = defineEmits<{ uploaded: [docId: string] }>()
|
||||
|
||||
const store = useDocumentStore()
|
||||
const flags = useFeatureFlagStore()
|
||||
const { t } = useI18n()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
const uploadHint = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (flags.maxFileSizeMb > 0) {
|
||||
parts.push(t('upload.maxSize').replace('{n}', String(flags.maxFileSizeMb)))
|
||||
if (appMaxFileSizeMb.value > 0) {
|
||||
parts.push(t('upload.maxSize').replace('{n}', String(appMaxFileSizeMb.value)))
|
||||
}
|
||||
if (flags.maxPageCount > 0) {
|
||||
parts.push(t('upload.maxPages').replace('{n}', String(flags.maxPageCount)))
|
||||
if (appMaxPageCount.value > 0) {
|
||||
parts.push(t('upload.maxPages').replace('{n}', String(appMaxPageCount.value)))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
import { appMaxFileSizeMb, appMaxPageCount } from '../../shared/appConfig'
|
||||
|
||||
type ConversionEngine = 'local' | 'remote'
|
||||
type DeploymentMode = 'self-hosted' | 'huggingface'
|
||||
|
|
@ -64,6 +65,8 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
|||
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
|
||||
maxPageCount.value = data.maxPageCount ?? 0
|
||||
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
|
||||
appMaxFileSizeMb.value = maxFileSizeMb.value
|
||||
appMaxPageCount.value = maxPageCount.value
|
||||
if (data.version) appVersion.value = data.version
|
||||
loaded.value = true
|
||||
error.value = null
|
||||
|
|
|
|||
10
frontend/src/features/history/api.ts
Normal file
10
frontend/src/features/history/api.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Analysis } from '../../shared/types'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
export function fetchHistory(): Promise<Analysis[]> {
|
||||
return apiFetch<Analysis[]>('/api/analyses')
|
||||
}
|
||||
|
||||
export function deleteHistoryEntry(id: string): Promise<unknown> {
|
||||
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ vi.mock('../analysis/api', () => ({
|
|||
deleteAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
fetchHistory: vi.fn(),
|
||||
deleteHistoryEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../document/api', () => ({
|
||||
fetchDocuments: vi.fn(),
|
||||
uploadDocument: vi.fn(),
|
||||
|
|
@ -33,8 +38,8 @@ describe('History → Studio navigation', () => {
|
|||
|
||||
describe('History store provides data for navigation', () => {
|
||||
it('analyses contain documentId for document selection', async () => {
|
||||
const { fetchAnalyses } = await import('../analysis/api')
|
||||
fetchAnalyses.mockResolvedValue([
|
||||
const { fetchHistory } = await import('./api')
|
||||
fetchHistory.mockResolvedValue([
|
||||
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
|
||||
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,9 +1,70 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useHistoryStore } from './store'
|
||||
import { useAnalysisStore } from '../analysis/store'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
fetchHistory: vi.fn(),
|
||||
deleteHistoryEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as api from './api'
|
||||
|
||||
describe('useHistoryStore', () => {
|
||||
it('is a re-export of useAnalysisStore', () => {
|
||||
expect(useHistoryStore).toBe(useAnalysisStore)
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with empty state', () => {
|
||||
const store = useHistoryStore()
|
||||
expect(store.analyses).toEqual([])
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('load() fetches analyses', async () => {
|
||||
const analyses = [
|
||||
{ id: 'a1', status: 'COMPLETED' },
|
||||
{ id: 'a2', status: 'FAILED' },
|
||||
]
|
||||
vi.mocked(api.fetchHistory).mockResolvedValue(analyses)
|
||||
|
||||
const store = useHistoryStore()
|
||||
await store.load()
|
||||
|
||||
expect(store.analyses).toEqual(analyses)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('load() handles errors gracefully', async () => {
|
||||
vi.mocked(api.fetchHistory).mockRejectedValue(new Error('network'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const store = useHistoryStore()
|
||||
await store.load()
|
||||
|
||||
expect(store.error).toBe('network')
|
||||
})
|
||||
|
||||
it('remove() deletes and filters from list', async () => {
|
||||
vi.mocked(api.deleteHistoryEntry).mockResolvedValue(null)
|
||||
|
||||
const store = useHistoryStore()
|
||||
store.analyses = [{ id: 'a1' }, { id: 'a2' }] as any[]
|
||||
await store.remove('a1')
|
||||
|
||||
expect(store.analyses).toEqual([{ id: 'a2' }])
|
||||
expect(api.deleteHistoryEntry).toHaveBeenCalledWith('a1')
|
||||
})
|
||||
|
||||
it('remove() handles errors gracefully', async () => {
|
||||
vi.mocked(api.deleteHistoryEntry).mockRejectedValue(new Error('fail'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const store = useHistoryStore()
|
||||
store.analyses = [{ id: 'a1' }] as any[]
|
||||
await store.remove('a1')
|
||||
|
||||
expect(store.error).toBe('fail')
|
||||
expect(store.analyses).toEqual([{ id: 'a1' }])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1 +1,31 @@
|
|||
export { useAnalysisStore as useHistoryStore } from '../analysis/store'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Analysis } from '../../shared/types'
|
||||
import * as api from './api'
|
||||
|
||||
export const useHistoryStore = defineStore('history', () => {
|
||||
const analyses = ref<Analysis[]>([])
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
error.value = null
|
||||
analyses.value = await api.fetchHistory()
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'Failed to load history'
|
||||
console.error('Failed to load history', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
try {
|
||||
await api.deleteHistoryEntry(id)
|
||||
analyses.value = analyses.value.filter((a) => a.id !== id)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'Failed to delete analysis'
|
||||
console.error('Failed to delete analysis', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { analyses, error, load, remove }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAnalysisStore } from '../../analysis/store'
|
||||
import { useHistoryStore } from '../store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import type { Analysis } from '../../../shared/types'
|
||||
|
||||
const store = useAnalysisStore()
|
||||
const store = useHistoryStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, watch, watchEffect } from 'vue'
|
||||
import type { Locale, Theme } from '../../shared/types'
|
||||
import { appLocale } from '../../shared/appConfig'
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
try {
|
||||
|
|
@ -24,7 +25,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
|
||||
|
||||
watch(theme, (v) => safeSetItem('docling-theme', v))
|
||||
watch(locale, (v) => safeSetItem('docling-locale', v))
|
||||
watch(
|
||||
locale,
|
||||
(v) => {
|
||||
safeSetItem('docling-locale', v)
|
||||
appLocale.value = v
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('light', theme.value === 'light')
|
||||
|
|
|
|||
|
|
@ -441,7 +441,12 @@
|
|||
<div v-if="mode === 'prepare' && chunkingEnabled" class="prepare-panel">
|
||||
<ChunkPanel
|
||||
:current-page="currentPage"
|
||||
:analysis-id="analysisStore.currentAnalysis?.id ?? null"
|
||||
:analysis-status="analysisStore.currentAnalysis?.status ?? null"
|
||||
:has-document-json="analysisStore.currentAnalysis?.hasDocumentJson ?? false"
|
||||
:chunks="analysisStore.currentChunks"
|
||||
@highlight-bboxes="highlightedChunkBboxes = $event"
|
||||
@rechunked="onRechunked"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -563,6 +568,12 @@ function addMore() {
|
|||
documentStore.selectedId = null
|
||||
}
|
||||
|
||||
async function onRechunked() {
|
||||
if (analysisStore.currentAnalysis?.id) {
|
||||
await analysisStore.select(analysisStore.currentAnalysis.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear highlights when switching modes or pages
|
||||
watch(mode, () => {
|
||||
highlightedElementIndex.value = -1
|
||||
|
|
|
|||
11
frontend/src/shared/appConfig.ts
Normal file
11
frontend/src/shared/appConfig.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ref } from 'vue'
|
||||
import type { Locale } from './types'
|
||||
|
||||
/**
|
||||
* App-wide reactive configuration — populated by feature stores,
|
||||
* consumed by shared utilities and other features.
|
||||
* Breaks the dependency cycle between features and shared.
|
||||
*/
|
||||
export const appLocale = ref<Locale>('fr')
|
||||
export const appMaxFileSizeMb = ref<number>(0)
|
||||
export const appMaxPageCount = ref<number>(0)
|
||||
|
|
@ -1,23 +1,13 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock settings store before importing i18n
|
||||
vi.mock('../features/settings/store', () => ({
|
||||
useSettingsStore: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useSettingsStore } from '../features/settings/store'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { appLocale } from './appConfig'
|
||||
import { useI18n } from './i18n'
|
||||
|
||||
describe('useI18n', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
appLocale.value = 'fr'
|
||||
})
|
||||
|
||||
it('returns French translation by default', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('nav.studio')).toBe('Studio')
|
||||
expect(t('nav.history')).toBe('Historique')
|
||||
|
|
@ -25,7 +15,7 @@ describe('useI18n', () => {
|
|||
})
|
||||
|
||||
it('returns English translation when locale is en', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
||||
appLocale.value = 'en'
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('nav.history')).toBe('History')
|
||||
|
|
@ -33,36 +23,30 @@ describe('useI18n', () => {
|
|||
})
|
||||
|
||||
it('falls back to French when key missing in current locale', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'de' })
|
||||
appLocale.value = 'de' as any
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('nav.studio')).toBe('Studio')
|
||||
})
|
||||
|
||||
it('returns key when not found in any locale', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('unknown.key')).toBe('unknown.key')
|
||||
})
|
||||
|
||||
it('interpolates parameters', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
||||
appLocale.value = 'en'
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('results.pageOf', { current: 3, total: 10 })).toBe('Page 3 of 10')
|
||||
})
|
||||
|
||||
it('interpolates parameters in French', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('results.pageOf', { current: 1, total: 5 })).toBe('Page 1 sur 5')
|
||||
})
|
||||
|
||||
it('has history tab keys in French', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('history.tabAnalyses')).toBe('Analyses')
|
||||
expect(t('history.tabDocuments')).toBe('Documents')
|
||||
|
|
@ -70,7 +54,7 @@ describe('useI18n', () => {
|
|||
})
|
||||
|
||||
it('has history tab keys in English', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
||||
appLocale.value = 'en'
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('history.tabAnalyses')).toBe('Analyses')
|
||||
|
|
@ -79,10 +63,7 @@ describe('useI18n', () => {
|
|||
})
|
||||
|
||||
it('has detailed pipeline option hints in French', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'fr' })
|
||||
|
||||
const { t } = useI18n()
|
||||
// Hints should be longer than simple labels
|
||||
expect(t('config.ocrHint').length).toBeGreaterThan(40)
|
||||
expect(t('config.tableStructureHint').length).toBeGreaterThan(40)
|
||||
expect(t('config.codeEnrichmentHint').length).toBeGreaterThan(40)
|
||||
|
|
@ -94,7 +75,7 @@ describe('useI18n', () => {
|
|||
})
|
||||
|
||||
it('has detailed pipeline option hints in English', () => {
|
||||
useSettingsStore.mockReturnValue({ locale: 'en' })
|
||||
appLocale.value = 'en'
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t('config.ocrHint').length).toBeGreaterThan(40)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Locale } from './types'
|
||||
import { useSettingsStore } from '../features/settings/store'
|
||||
import { appLocale } from './appConfig'
|
||||
|
||||
type MessageMap = Record<string, string>
|
||||
type Messages = Record<Locale, MessageMap>
|
||||
|
|
@ -268,10 +268,8 @@ const messages: Messages = {
|
|||
}
|
||||
|
||||
export function useI18n() {
|
||||
const settings = useSettingsStore()
|
||||
|
||||
function t(key: string, params: Record<string, string | number> = {}): string {
|
||||
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
|
||||
let str = messages[appLocale.value]?.[key] || messages['fr'][key] || key
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
str = str.replaceAll(`{${k}}`, String(v))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue