From d810fda3331787ebefd697cc091d5cfec34cf831 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 9 Apr 2026 15:51:36 +0200 Subject: [PATCH 1/7] fix: preserve batch progress on analysis completion Re-read the job from DB before mark_completed so that progress_current/progress_total written during batched conversion are not overwritten by the stale in-memory object. Add regression unit test and e2e assertion on final progress values. --- document-parser/services/analysis_service.py | 3 + .../tests/test_analysis_service.py | 84 +++++++++++++++++++ .../resources/analyses/batch-progress.feature | 5 ++ 3 files changed, 92 insertions(+) diff --git a/document-parser/services/analysis_service.py b/document-parser/services/analysis_service.py index 1cffe2d..d4a5190 100644 --- a/document-parser/services/analysis_service.py +++ b/document-parser/services/analysis_service.py @@ -327,6 +327,9 @@ class AnalysisService: chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks]) logger.info("Chunking produced %d chunks for job %s", len(chunks), job_id) + # Re-read the job so we don't lose progress_current/progress_total + # written to the DB during batched conversion. + job = await analysis_repo.find_by_id(job_id) or job job.mark_completed( markdown=result.content_markdown, html=result.content_html, diff --git a/document-parser/tests/test_analysis_service.py b/document-parser/tests/test_analysis_service.py index 09edf4a..afb8c4c 100644 --- a/document-parser/tests/test_analysis_service.py +++ b/document-parser/tests/test_analysis_service.py @@ -385,6 +385,90 @@ class TestBatchedConversion: batch_size=5, ) + @pytest.mark.asyncio + async def test_progress_preserved_through_full_analysis_flow(self): + """Progress written during batches must survive the final update_status. + + Regression: _run_analysis_inner used to re-read the job from DB at the + start, then call update_status(job) at the end — overwriting + progress_current/progress_total with None because the in-memory object + was stale. The fix re-reads the job before mark_completed. + """ + from domain.models import AnalysisJob, AnalysisStatus + + converter = AsyncMock() + converter.convert.side_effect = [ + ConversionResult( + page_count=5, + content_markdown="# B1", + content_html="

B1

", + pages=[PageDetail(page_number=i, width=612, height=792) for i in range(1, 6)], + ), + ConversionResult( + page_count=3, + content_markdown="# B2", + content_html="

B2

", + pages=[PageDetail(page_number=i, width=612, height=792) for i in range(6, 9)], + ), + ] + + service = AnalysisService(converter=converter, conversion_timeout=60) + + # Simulated DB state: find_by_id is called 4 times: + # 1) start of _run_analysis_inner (initial load) + # 2) batch 1 mid-flight deletion check + # 3) batch 2 mid-flight deletion check + # 4) re-read before mark_completed (must carry progress) + initial_job = AnalysisJob( + id="job-progress", + document_id="doc-1", + status=AnalysisStatus.PENDING, + ) + batch_check_job = AnalysisJob( + id="job-progress", + document_id="doc-1", + status=AnalysisStatus.RUNNING, + ) + refreshed_job = AnalysisJob( + id="job-progress", + document_id="doc-1", + status=AnalysisStatus.RUNNING, + progress_current=8, + progress_total=8, + ) + + saved_jobs: list[AnalysisJob] = [] + + async def capture_update_status(job): + saved_jobs.append(job) + + with ( + patch("services.analysis_service.analysis_repo") as mock_repo, + patch("services.analysis_service.document_repo") as mock_doc_repo, + patch("services.analysis_service._count_pdf_pages", return_value=8), + patch("services.analysis_service.settings") as mock_settings, + ): + mock_settings.batch_page_size = 5 + mock_settings.default_table_mode = "accurate" + mock_repo.find_by_id = AsyncMock( + side_effect=[initial_job, batch_check_job, batch_check_job, refreshed_job] + ) + mock_repo.update_status = AsyncMock(side_effect=capture_update_status) + mock_repo.update_progress = AsyncMock() + mock_doc_repo.update_page_count = AsyncMock() + + await service._run_analysis_inner("job-progress", "/fake.pdf", "fake.pdf") + + # The last update_status call is the COMPLETED one + completed_job = saved_jobs[-1] + assert completed_job.status == AnalysisStatus.COMPLETED + assert completed_job.progress_current == 8, ( + "progress_current must be preserved (was the bug: got None)" + ) + assert completed_job.progress_total == 8, ( + "progress_total must be preserved (was the bug: got None)" + ) + @pytest.mark.asyncio async def test_job_deleted_mid_batch_returns_none(self): """If the job is deleted between batches, conversion aborts with None.""" diff --git a/e2e/api/src/test/resources/analyses/batch-progress.feature b/e2e/api/src/test/resources/analyses/batch-progress.feature index f193d26..afeb3dd 100644 --- a/e2e/api/src/test/resources/analyses/batch-progress.feature +++ b/e2e/api/src/test/resources/analyses/batch-progress.feature @@ -27,6 +27,11 @@ Feature: Batched conversion with progress reporting And match response.contentMarkdown == '#string' And match response.pagesJson == '#string' + # When batched, progress must be preserved at completion (not reset to null) + # progressTotal > 0 proves batching was active + # progressCurrent == progressTotal proves the final update_status didn't erase them + * if (response.progressTotal > 0) karate.match('response.progressCurrent', response.progressTotal) + # Cleanup * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } From 26fd46967cfcd06db18b6ae6d870c6854a050f2e Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 9 Apr 2026 15:51:41 +0200 Subject: [PATCH 2/7] fix: forward BATCH_PAGE_SIZE env var in docker-compose The variable was defined in settings but never passed to the container, making batch mode silently disabled in Docker deployments. --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index ab0842f..3eb3d1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: DOCLING_SERVE_API_KEY: ${DOCLING_SERVE_API_KEY:-} RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100} MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-50} + BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-0} deploy: resources: limits: From f6992d91bc3c4f0926f1c220736f03afa4b63777 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 9 Apr 2026 15:51:46 +0200 Subject: [PATCH 3/7] feat: segmented batch progress bar with ring indicator Replace the basic progress bar with a ring percentage indicator, segmented batch bar with pulse animation, and an inline mini progress bar in the top banner visible from any tab. --- .../src/features/analysis/ui/ResultTabs.vue | 178 +++++++++++++++--- frontend/src/pages/StudioPage.vue | 55 ++++++ 2 files changed, 208 insertions(+), 25 deletions(-) diff --git a/frontend/src/features/analysis/ui/ResultTabs.vue b/frontend/src/features/analysis/ui/ResultTabs.vue index 94031c3..afb7897 100644 --- a/frontend/src/features/analysis/ui/ResultTabs.vue +++ b/frontend/src/features/analysis/ui/ResultTabs.vue @@ -109,20 +109,54 @@
-
- {{ t('studio.analysisRunning') }} +
-
-
+
+ + + + + {{ progressPercent }}% +
+
+ {{ t('studio.analysisRunning') }} +
+
+
+ + {{ store.currentAnalysis.progressCurrent ?? 0 }} + / + {{ store.currentAnalysis.progressTotal }} pages +
- - Pages {{ store.currentAnalysis.progressCurrent ?? 0 }} / - {{ store.currentAnalysis.progressTotal }} -
+ +
{ return Math.min(100, Math.round(((a.progressCurrent ?? 0) / a.progressTotal) * 100)) }) +/** Number of batch segments to render in the segmented progress bar. */ +const batchSegments = computed(() => { + const a = store.currentAnalysis + if (!a?.progressTotal || a.progressTotal <= 0) return 0 + // Each segment = batch_page_size pages. We infer count from total & current. + // Backend updates progressCurrent in increments, so we derive segment count. + const current = a.progressCurrent ?? 0 + if (current <= 0) return 0 + // Guess batch size from the first non-zero progressCurrent value. + // Fallback: assume ~5 segments for a clean visual. + const total = a.progressTotal + // We can't know batch_page_size on frontend, so compute a clean segment count + // by aiming for 3-8 segments (visually optimal). + let count = Math.round(total / Math.max(1, current || total / 5)) + if (count < 2) count = Math.ceil(total / Math.ceil(total / 5)) + return Math.max(2, Math.min(8, count)) +}) + +/** How many segments are "filled" (completed batches). */ +const filledSegments = computed(() => { + const total = batchSegments.value + if (total <= 0) return 0 + return Math.round((progressPercent.value / 100) * total) +}) + const currentPageData = computed(() => { return store.currentPages.find((p) => p.page_number === props.currentPage) || null }) @@ -534,29 +593,98 @@ async function copyElement(idx: number, content: string) { } } +/* ── Batch progress: ring + segmented bar ── */ .batch-progress { display: flex; - flex-direction: column; align-items: center; - gap: 6px; - margin-top: 8px; - width: 200px; + gap: 20px; } -.progress-bar { + +/* Circular ring */ +.batch-progress-ring { + position: relative; + width: 56px; + height: 56px; + flex-shrink: 0; +} +.progress-ring-svg { width: 100%; - height: 6px; - background: var(--border-light); - border-radius: 3px; - overflow: hidden; -} -.progress-fill { height: 100%; - background: var(--accent); - border-radius: 3px; - transition: width 0.3s ease; + transform: rotate(-90deg); } -.progress-text { - font-size: 0.8rem; - color: var(--text-secondary); +.progress-ring-fill { + transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1); + filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4)); +} +.progress-ring-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + font-family: 'IBM Plex Mono', monospace; + color: var(--text); +} + +/* Right side: text + segments */ +.batch-progress-detail { + display: flex; + flex-direction: column; + gap: 6px; +} +.batch-progress-title { + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +/* Segmented bar */ +.batch-segments { + display: flex; + gap: 3px; +} +.batch-segment { + width: 28px; + height: 5px; + border-radius: 2.5px; + background: var(--border); + transition: + background 0.4s ease, + box-shadow 0.4s ease; +} +.batch-segment.filled { + background: var(--accent); + box-shadow: 0 0 6px rgba(249, 115, 22, 0.3); +} +.batch-segment.active { + background: var(--accent-muted); + animation: segment-pulse 1.5s ease-in-out infinite; +} +@keyframes segment-pulse { + 0%, + 100% { + background: var(--accent-muted); + } + 50% { + background: var(--accent); + box-shadow: 0 0 8px rgba(249, 115, 22, 0.35); + } +} + +/* Page counter */ +.batch-progress-sub { + font-size: 12px; + font-family: 'IBM Plex Mono', monospace; + color: var(--text-muted); +} +.batch-progress-pages { + color: var(--accent); + font-weight: 600; +} +.batch-progress-sep { + margin: 0 2px; + opacity: 0.4; } diff --git a/frontend/src/pages/StudioPage.vue b/frontend/src/pages/StudioPage.vue index 51040bf..b0801c5 100644 --- a/frontend/src/pages/StudioPage.vue +++ b/frontend/src/pages/StudioPage.vue @@ -118,6 +118,35 @@
{{ t('studio.analysisRunning') }} + + + + + {{ analysisStore.currentAnalysis.progressCurrent ?? 0 }}/{{ + analysisStore.currentAnalysis.progressTotal + }} + @@ -867,6 +896,32 @@ onBeforeUnmount(() => { animation: spin 0.6s linear infinite; } +/* Inline mini progress in top bar */ +.info-badge-progress { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 4px; +} +.info-badge-bar { + width: 48px; + height: 3px; + background: var(--border); + border-radius: 1.5px; + overflow: hidden; +} +.info-badge-fill { + display: block; + height: 100%; + background: var(--accent); + border-radius: 1.5px; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} +.info-badge-count { + font-size: 11px; + color: var(--text-muted); +} + /* Main content */ .studio-main { flex: 1; From f487b752dcee4d38158a17a0c1ab37b4d235a351 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 9 Apr 2026 15:51:52 +0200 Subject: [PATCH 4/7] feat: informational notice when chunking is unavailable in batch mode Show an info banner in the Prepare panel explaining that batched analyses do not generate the internal structure required for chunking. --- .../src/features/chunking/ui/ChunkPanel.vue | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/frontend/src/features/chunking/ui/ChunkPanel.vue b/frontend/src/features/chunking/ui/ChunkPanel.vue index 34eb7b0..ff631fa 100644 --- a/frontend/src/features/chunking/ui/ChunkPanel.vue +++ b/frontend/src/features/chunking/ui/ChunkPanel.vue @@ -66,6 +66,18 @@
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }} + + +
+ + + + {{ t('chunking.batchNotice') }} +
@@ -151,6 +163,12 @@ const canRechunk = computed(() => { return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson }) +/** True when the analysis was batched (document_json unavailable). */ +const isBatchedAnalysis = computed(() => { + const analysis = analysisStore.currentAnalysis + return analysis?.status === 'COMPLETED' && !analysis.hasDocumentJson +}) + const pageChunks = computed(() => analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage), ) @@ -426,6 +444,28 @@ async function doRechunk() { font-size: 13px; } +/* Batch mode info notice */ +.batch-notice { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 4px; + padding: 10px 12px; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: var(--radius-sm); + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); +} +.batch-notice-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--info); + margin-top: 1px; +} + .spinner-sm { width: 14px; height: 14px; From abf3325923eae7ffd5b368712710153ea7701da5 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 9 Apr 2026 15:51:59 +0200 Subject: [PATCH 5/7] feat: single source of truth for app version from health check Read version from the backend /api/health endpoint instead of hardcoding from package.json at build time. Add About section in Settings with link to the DZone design article. --- frontend/src/features/feature-flags/store.ts | 15 ++++- .../features/settings/ui/SettingsPanel.vue | 61 ++++++++++++++++++- frontend/src/shared/ui/AppSidebar.vue | 5 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/feature-flags/store.ts b/frontend/src/features/feature-flags/store.ts index 967aaa7..8beff0e 100644 --- a/frontend/src/features/feature-flags/store.ts +++ b/frontend/src/features/feature-flags/store.ts @@ -7,6 +7,7 @@ type DeploymentMode = 'self-hosted' | 'huggingface' interface HealthResponse { status: string + version?: string engine: ConversionEngine deploymentMode?: DeploymentMode maxPageCount?: number @@ -41,6 +42,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { const deploymentMode = ref(null) const maxPageCount = ref(0) const maxFileSizeMb = ref(0) + const appVersion = ref(__APP_VERSION__) const loaded = ref(false) const error = ref(null) @@ -62,6 +64,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { deploymentMode.value = data.deploymentMode ?? 'self-hosted' maxPageCount.value = data.maxPageCount ?? 0 maxFileSizeMb.value = data.maxFileSizeMb ?? 0 + if (data.version) appVersion.value = data.version loaded.value = true error.value = null } catch (e) { @@ -70,5 +73,15 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { } } - return { engine, deploymentMode, maxPageCount, maxFileSizeMb, loaded, error, isEnabled, load } + return { + engine, + deploymentMode, + maxPageCount, + maxFileSizeMb, + appVersion, + loaded, + error, + isEnabled, + load, + } }) diff --git a/frontend/src/features/settings/ui/SettingsPanel.vue b/frontend/src/features/settings/ui/SettingsPanel.vue index 0356e1d..40fba3b 100644 --- a/frontend/src/features/settings/ui/SettingsPanel.vue +++ b/frontend/src/features/settings/ui/SettingsPanel.vue @@ -36,15 +36,43 @@ {{ version }}
+ +
@@ -118,4 +146,35 @@ const { t } = useI18n() color: var(--text); font-family: 'IBM Plex Mono', monospace; } + +.about-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + text-decoration: none; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elevated); + transition: all var(--transition); + width: fit-content; +} +.about-link:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-muted); +} +.about-link-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} +.about-link-external { + width: 12px; + height: 12px; + flex-shrink: 0; + opacity: 0.4; +} diff --git a/frontend/src/shared/ui/AppSidebar.vue b/frontend/src/shared/ui/AppSidebar.vue index 7a163f1..acf7523 100644 --- a/frontend/src/shared/ui/AppSidebar.vue +++ b/frontend/src/shared/ui/AppSidebar.vue @@ -97,10 +97,13 @@