Merge pull request #126 from scub-france/fix/batch-progress-and-version

fix: batch progress, segmented UI, version source, about section
This commit is contained in:
Pier-Jean Malandrino 2026-04-09 16:37:37 +02:00 committed by GitHub
commit 93f37282a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 443 additions and 29 deletions

View file

@ -4,6 +4,21 @@ All notable changes to Docling Studio will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.3.1] - 2026-04-09
### Added
- Batch conversion progress: segmented progress bar with ring indicator and per-batch visual feedback
- Inline mini progress bar in the top banner during analysis
- Informational notice in Prepare mode when chunking is unavailable (batch mode)
- `BATCH_PAGE_SIZE` environment variable forwarded in Docker Compose
### Fixed
- Batch progress reset to null on completion (progress_current/progress_total overwritten by stale in-memory job object)
- Regression test for batch progress preservation in `_run_analysis_inner` flow
- E2E assertion on final progress values in batch-progress feature
## [0.3.0] - 2026-04-07
### Added

View file

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

View file

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

View file

@ -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="<html><body><p>B1</p></body></html>",
pages=[PageDetail(page_number=i, width=612, height=792) for i in range(1, 6)],
),
ConversionResult(
page_count=3,
content_markdown="# B2",
content_html="<html><body><p>B2</p></body></html>",
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."""

View file

@ -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)' }

View file

@ -1,6 +1,6 @@
{
"name": "docling-studio",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"type": "module",
"scripts": {

View file

@ -109,20 +109,54 @@
</div>
</div>
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
<div class="spinner-large" />
<span>{{ t('studio.analysisRunning') }}</span>
<!-- Batch progress: segmented bar -->
<div
v-if="store.currentAnalysis.progressTotal && store.currentAnalysis.progressTotal > 0"
class="batch-progress"
>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
<div class="batch-progress-ring">
<svg viewBox="0 0 48 48" class="progress-ring-svg">
<circle cx="24" cy="24" r="20" fill="none" stroke="var(--border)" stroke-width="3" />
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke="var(--accent)"
stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="125.66"
:stroke-dashoffset="125.66 - (125.66 * progressPercent) / 100"
class="progress-ring-fill"
/>
</svg>
<span class="progress-ring-label">{{ progressPercent }}%</span>
</div>
<div class="batch-progress-detail">
<span class="batch-progress-title">{{ t('studio.analysisRunning') }}</span>
<div class="batch-segments">
<div
v-for="i in batchSegments"
:key="i"
class="batch-segment"
:class="{
filled: i <= filledSegments,
active: i === filledSegments + 1,
}"
/>
</div>
<span class="batch-progress-sub">
<span class="batch-progress-pages">{{ store.currentAnalysis.progressCurrent ?? 0 }}</span>
<span class="batch-progress-sep">/</span>
<span>{{ store.currentAnalysis.progressTotal }} pages</span>
</span>
</div>
<span class="progress-text">
Pages {{ store.currentAnalysis.progressCurrent ?? 0 }} /
{{ store.currentAnalysis.progressTotal }}
</span>
</div>
<!-- Fallback: no batch info -->
<template v-else>
<div class="spinner-large" />
<span>{{ t('studio.analysisRunning') }}</span>
</template>
</div>
<div
v-else-if="store.currentAnalysis?.status === 'FAILED'"
@ -199,6 +233,31 @@ const progressPercent = computed(() => {
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;
}
</style>

View file

@ -66,6 +66,18 @@
<div v-if="analysisStore.rechunking" class="spinner-sm" />
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
</button>
<!-- Batch mode notice -->
<div v-if="isBatchedAnalysis" class="batch-notice" data-e2e="batch-notice">
<svg viewBox="0 0 20 20" fill="currentColor" class="batch-notice-icon">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
<span>{{ t('chunking.batchNotice') }}</span>
</div>
</div>
</div>
@ -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;

View file

@ -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<DeploymentMode | null>(null)
const maxPageCount = ref<number>(0)
const maxFileSizeMb = ref<number>(0)
const appVersion = ref<string>(__APP_VERSION__)
const loaded = ref(false)
const error = ref<string | null>(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,
}
})

View file

@ -36,15 +36,43 @@
<label class="setting-label">{{ t('settings.version') }}</label>
<span class="setting-value">{{ version }}</span>
</div>
<div class="setting-group">
<label class="setting-label">{{ t('settings.about') }}</label>
<a
href="https://dzone.com/articles/designing-docling-studio"
target="_blank"
rel="noopener noreferrer"
class="about-link"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="about-link-icon">
<path
d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"
/>
</svg>
{{ t('settings.designArticle') }}
<svg viewBox="0 0 20 20" fill="currentColor" class="about-link-external">
<path
d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"
/>
<path
d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"
/>
</svg>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useSettingsStore } from '../store'
import { useFeatureFlagStore } from '../../feature-flags/store'
import { useI18n } from '../../../shared/i18n'
const version = __APP_VERSION__
const store = useSettingsStore()
const featureStore = useFeatureFlagStore()
const version = computed(() => featureStore.appVersion)
const { t } = useI18n()
</script>
@ -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;
}
</style>

View file

@ -118,6 +118,35 @@
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
<div class="spinner-xs" />
{{ t('studio.analysisRunning') }}
<span
v-if="
analysisStore.currentAnalysis.progressTotal &&
analysisStore.currentAnalysis.progressTotal > 0
"
class="info-badge-progress"
>
<span class="info-badge-bar">
<span
class="info-badge-fill"
:style="{
width:
Math.min(
100,
Math.round(
((analysisStore.currentAnalysis.progressCurrent ?? 0) /
analysisStore.currentAnalysis.progressTotal) *
100,
),
) + '%',
}"
/>
</span>
<span class="info-badge-count"
>{{ analysisStore.currentAnalysis.progressCurrent ?? 0 }}/{{
analysisStore.currentAnalysis.progressTotal
}}</span
>
</span>
</span>
<span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
<span class="info-dot error" />
@ -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;

View file

@ -119,6 +119,8 @@ const messages: Messages = {
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
'chunking.batchNotice':
'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage.',
// Pagination
'pagination.pageOf': 'Page {current} sur {total}',
@ -132,6 +134,8 @@ const messages: Messages = {
'settings.themeDark': 'Sombre',
'settings.themeLight': 'Clair',
'settings.language': 'Langue',
'settings.about': '\u00C0 propos',
'settings.designArticle': 'Comment Docling Studio a \u00e9t\u00e9 con\u00e7u',
// Disclaimer
'disclaimer.banner':
@ -241,6 +245,8 @@ const messages: Messages = {
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Run chunking to prepare segments.',
'chunking.noChunksOnPage': 'No chunks on this page.',
'chunking.batchNotice':
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.',
'pagination.pageOf': 'Page {current} of {total}',
'pagination.perPage': '/ page',
@ -252,6 +258,8 @@ const messages: Messages = {
'settings.themeDark': 'Dark',
'settings.themeLight': 'Light',
'settings.language': 'Language',
'settings.about': 'About',
'settings.designArticle': 'How Docling Studio was designed',
// Disclaimer
'disclaimer.banner':

View file

@ -97,10 +97,13 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from '../i18n'
import { useFeatureFlagStore } from '../../features/feature-flags/store'
const version = __APP_VERSION__
const featureStore = useFeatureFlagStore()
const version = computed(() => featureStore.appVersion)
const route = useRoute()
const { t } = useI18n()