feat(chunking): inline chunk text editing

Closes #89
This commit is contained in:
Pier-Jean Malandrino 2026-04-10 19:37:29 +02:00
parent e311454e86
commit c74a3277b8
13 changed files with 476 additions and 3 deletions

View file

@ -4,6 +4,16 @@ 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.4.0] - Unreleased
### Added
- Inline chunk text editing: double-click or edit button to modify chunk text, with save/cancel and "modified" badge
### Fixed
### Changed
## [0.3.1] - 2026-04-09
### Added

View file

@ -13,6 +13,7 @@ from api.schemas import (
ChunkResponse,
CreateAnalysisRequest,
RechunkRequest,
UpdateChunkTextRequest,
)
from services.analysis_service import AnalysisService
@ -110,6 +111,28 @@ async def rechunk_analysis(
]
@router.patch("/{job_id}/chunks/{chunk_index}", response_model=list[ChunkResponse])
async def update_chunk_text(
job_id: str, chunk_index: int, body: UpdateChunkTextRequest, service: ServiceDep
) -> list[ChunkResponse]:
"""Update the text of a single chunk by index."""
try:
chunks = await service.update_chunk_text(job_id, chunk_index, body.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return [
ChunkResponse(
text=c["text"],
headings=c.get("headings", []),
source_page=c.get("sourcePage"),
token_count=c.get("tokenCount", 0),
bboxes=[ChunkBboxResponse(page=b["page"], bbox=b["bbox"]) for b in c.get("bboxes", [])],
modified=c.get("modified", False),
)
for c in chunks
]
@router.delete("/{job_id}", status_code=204)
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
"""Delete an analysis job."""

View file

@ -158,6 +158,11 @@ class ChunkResponse(_CamelModel):
source_page: int | None = None
token_count: int = 0
bboxes: list[ChunkBboxResponse] = []
modified: bool = False
class UpdateChunkTextRequest(BaseModel):
text: str
class CreateAnalysisRequest(BaseModel):

View file

@ -160,6 +160,28 @@ class AnalysisService:
return chunks
async def update_chunk_text(self, job_id: str, chunk_index: int, text: str) -> list[dict]:
"""Update the text of a single chunk by index. Returns the full updated chunks list."""
job = await self._analysis_repo.find_by_id(job_id)
if not job:
raise ValueError(f"Analysis not found: {job_id}")
if job.status != AnalysisStatus.COMPLETED:
raise ValueError(f"Analysis is not completed: {job_id}")
if not job.chunks_json:
raise ValueError(f"No chunks available: {job_id}")
chunks = json.loads(job.chunks_json)
if chunk_index < 0 or chunk_index >= len(chunks):
raise ValueError(f"Chunk index out of range: {chunk_index}")
chunks[chunk_index]["text"] = text
chunks[chunk_index]["modified"] = True
chunks_json = json.dumps(chunks)
await self._analysis_repo.update_chunks(job_id, chunks_json)
return chunks
async def _run_batched_conversion(
self,
job_id: str,

View file

@ -4,10 +4,12 @@ from __future__ import annotations
import asyncio
import functools
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from domain.models import AnalysisStatus
from domain.services import extract_html_body, merge_results
from domain.value_objects import ConversionResult, PageDetail
from services.analysis_service import AnalysisConfig, AnalysisService, _count_pdf_pages
@ -515,3 +517,80 @@ class TestBatchedConversion:
assert result is None
# Only first batch should have been converted
assert converter.convert.call_count == 1
class TestUpdateChunkText:
"""Tests for AnalysisService.update_chunk_text."""
@pytest.mark.asyncio
async def test_update_chunk_text_success(self):
chunks = [
{"text": "original", "headings": [], "sourcePage": 1, "tokenCount": 5, "bboxes": []},
{"text": "second", "headings": [], "sourcePage": 2, "tokenCount": 3, "bboxes": []},
]
job = MagicMock()
job.status = AnalysisStatus.COMPLETED
job.chunks_json = json.dumps(chunks)
repo = MagicMock()
repo.find_by_id = AsyncMock(return_value=job)
repo.update_chunks = AsyncMock(return_value=True)
service = _make_service(analysis_repo=repo)
result = await service.update_chunk_text("j1", 0, "updated text")
assert result[0]["text"] == "updated text"
assert result[0]["modified"] is True
assert result[1]["text"] == "second"
assert result[1].get("modified", False) is False
repo.update_chunks.assert_called_once()
@pytest.mark.asyncio
async def test_update_chunk_text_not_found(self):
repo = MagicMock()
repo.find_by_id = AsyncMock(return_value=None)
service = _make_service(analysis_repo=repo)
with pytest.raises(ValueError, match="Analysis not found"):
await service.update_chunk_text("missing", 0, "text")
@pytest.mark.asyncio
async def test_update_chunk_text_not_completed(self):
job = MagicMock()
job.status = AnalysisStatus.RUNNING
repo = MagicMock()
repo.find_by_id = AsyncMock(return_value=job)
service = _make_service(analysis_repo=repo)
with pytest.raises(ValueError, match="not completed"):
await service.update_chunk_text("j1", 0, "text")
@pytest.mark.asyncio
async def test_update_chunk_text_index_out_of_range(self):
chunks = [
{"text": "only one", "headings": [], "sourcePage": 1, "tokenCount": 5, "bboxes": []}
]
job = MagicMock()
job.status = AnalysisStatus.COMPLETED
job.chunks_json = json.dumps(chunks)
repo = MagicMock()
repo.find_by_id = AsyncMock(return_value=job)
service = _make_service(analysis_repo=repo)
with pytest.raises(ValueError, match="out of range"):
await service.update_chunk_text("j1", 5, "text")
@pytest.mark.asyncio
async def test_update_chunk_text_no_chunks(self):
job = MagicMock()
job.status = AnalysisStatus.COMPLETED
job.chunks_json = None
repo = MagicMock()
repo.find_by_id = AsyncMock(return_value=job)
service = _make_service(analysis_repo=repo)
with pytest.raises(ValueError, match="No chunks available"):
await service.update_chunk_text("j1", 0, "text")

View file

@ -278,6 +278,70 @@ class TestCreateAnalysisWithChunking:
assert chunks[0]["text"] == "chunk1"
class TestUpdateChunkTextEndpoint:
def test_update_chunk_text_success(self, client, mock_analysis_service):
updated_chunks = [
{
"text": "updated text",
"headings": ["H1"],
"sourcePage": 1,
"tokenCount": 10,
"bboxes": [],
"modified": True,
},
{
"text": "chunk2",
"headings": [],
"sourcePage": 2,
"tokenCount": 20,
"bboxes": [],
"modified": False,
},
]
mock_analysis_service.update_chunk_text = AsyncMock(return_value=updated_chunks)
resp = client.patch(
"/api/analyses/j1/chunks/0",
json={"text": "updated text"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["text"] == "updated text"
assert data[0]["modified"] is True
assert data[1]["modified"] is False
def test_update_chunk_text_invalid_index(self, client, mock_analysis_service):
mock_analysis_service.update_chunk_text = AsyncMock(
side_effect=ValueError("Chunk index out of range: 99"),
)
resp = client.patch(
"/api/analyses/j1/chunks/99",
json={"text": "new"},
)
assert resp.status_code == 400
def test_update_chunk_text_not_completed(self, client, mock_analysis_service):
mock_analysis_service.update_chunk_text = AsyncMock(
side_effect=ValueError("Analysis is not completed: j1"),
)
resp = client.patch(
"/api/analyses/j1/chunks/0",
json={"text": "new"},
)
assert resp.status_code == 400
def test_update_chunk_text_not_found(self, client, mock_analysis_service):
mock_analysis_service.update_chunk_text = AsyncMock(
side_effect=ValueError("Analysis not found: j1"),
)
resp = client.patch(
"/api/analyses/j1/chunks/0",
json={"text": "new"},
)
assert resp.status_code == 400
class TestRechunkEndpoint:
def test_rechunk_success(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { rechunkAnalysis } from './api'
import { rechunkAnalysis, updateChunkText } from './api'
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
@ -25,4 +25,19 @@ describe('chunking API', () => {
})
expect(result).toEqual(chunks)
})
it('updateChunkText sends PATCH to chunk endpoint', async () => {
const chunks = [
{ text: 'updated', headings: [], sourcePage: 1, tokenCount: 10, bboxes: [], modified: true },
]
apiFetch.mockResolvedValue(chunks)
const result = await updateChunkText('job-1', 0, 'updated')
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/job-1/chunks/0', {
method: 'PATCH',
body: JSON.stringify({ text: 'updated' }),
})
expect(result).toEqual(chunks)
})
})

View file

@ -7,3 +7,10 @@ export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions)
body: JSON.stringify({ chunkingOptions }),
})
}
export function updateChunkText(jobId: string, chunkIndex: number, text: string): Promise<Chunk[]> {
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/chunks/${chunkIndex}`, {
method: 'PATCH',
body: JSON.stringify({ text }),
})
}

View file

@ -4,6 +4,7 @@ import { useChunkingStore } from './store'
vi.mock('./api', () => ({
rechunkAnalysis: vi.fn(),
updateChunkText: vi.fn(),
}))
import * as api from './api'
@ -17,6 +18,7 @@ describe('useChunkingStore', () => {
it('starts with default state', () => {
const store = useChunkingStore()
expect(store.rechunking).toBe(false)
expect(store.saving).toBe(false)
expect(store.error).toBeNull()
})
@ -62,4 +64,46 @@ describe('useChunkingStore', () => {
expect(store.rechunking).toBe(false)
expect(store.error).toBe('fail')
})
it('updateChunkText calls API and returns chunks', async () => {
const chunks = [
{ text: 'updated', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [], modified: true },
]
vi.mocked(api.updateChunkText).mockResolvedValue(chunks)
const store = useChunkingStore()
const result = await store.updateChunkText('j1', 0, 'updated')
expect(api.updateChunkText).toHaveBeenCalledWith('j1', 0, 'updated')
expect(result).toEqual(chunks)
expect(store.saving).toBe(false)
})
it('updateChunkText sets saving during execution', async () => {
let resolve: (v: any) => void
vi.mocked(api.updateChunkText).mockImplementation(
() =>
new Promise((r) => {
resolve = r
}),
)
const store = useChunkingStore()
const promise = store.updateChunkText('j1', 0, 'updated')
expect(store.saving).toBe(true)
resolve!([])
await promise
expect(store.saving).toBe(false)
})
it('updateChunkText handles errors', async () => {
vi.mocked(api.updateChunkText).mockRejectedValue(new Error('save failed'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChunkingStore()
await expect(store.updateChunkText('j1', 0, 'text')).rejects.toThrow('save failed')
expect(store.saving).toBe(false)
expect(store.error).toBe('save failed')
})
})

View file

@ -5,6 +5,7 @@ import * as api from './api'
export const useChunkingStore = defineStore('chunking', () => {
const rechunking = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
@ -21,5 +22,23 @@ export const useChunkingStore = defineStore('chunking', () => {
}
}
return { rechunking, error, rechunk }
async function updateChunkText(
jobId: string,
chunkIndex: number,
text: string,
): Promise<Chunk[]> {
saving.value = true
error.value = null
try {
return await api.updateChunkText(jobId, chunkIndex, text)
} catch (e) {
error.value = (e as Error).message || 'Failed to update chunk'
console.error('Failed to update chunk', e)
throw e
} finally {
saving.value = false
}
}
return { rechunking, saving, error, rechunk, updateChunkText }
})

View file

@ -102,11 +102,64 @@
{{ chunk.tokenCount }} tokens
</span>
<span class="chunk-page" v-if="chunk.sourcePage"> p.{{ chunk.sourcePage }} </span>
<span v-if="chunk.modified" class="chunk-modified" data-e2e="chunk-modified">
{{ t('chunking.modified') }}
</span>
<button
v-if="editingIdx !== globalIndex(localIdx)"
class="chunk-edit-icon"
data-e2e="chunk-edit-btn"
:title="t('chunking.edit')"
@click.stop="startEdit(globalIndex(localIdx), chunk.text)"
>
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
/>
</svg>
</button>
</div>
<div class="chunk-headings" v-if="chunk.headings.length">
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
</div>
<div class="chunk-text" data-e2e="chunk-text">{{ chunk.text }}</div>
<!-- Edit mode -->
<div v-if="editingIdx === globalIndex(localIdx)" class="chunk-edit">
<textarea
ref="editTextarea"
class="chunk-edit-textarea"
data-e2e="chunk-edit-textarea"
v-model="editText"
rows="6"
/>
<div class="chunk-edit-actions">
<button
class="chunk-edit-btn save"
data-e2e="chunk-edit-save"
:disabled="chunkingStore.saving"
@click="saveEdit(globalIndex(localIdx))"
>
{{ chunkingStore.saving ? t('chunking.saving') : t('chunking.save') }}
</button>
<button
class="chunk-edit-btn cancel"
data-e2e="chunk-edit-cancel"
@click="cancelEdit"
>
{{ t('chunking.cancel') }}
</button>
</div>
</div>
<!-- Read mode -->
<div
v-else
class="chunk-text"
data-e2e="chunk-text"
@dblclick="startEdit(globalIndex(localIdx), chunk.text)"
>
{{ chunk.text }}
</div>
</div>
</div>
</div>
@ -177,6 +230,32 @@ function globalIndex(localIdx: number): number {
return (pagination.page.value - 1) * pagination.pageSize.value + localIdx
}
const editingIdx = ref(-1)
const editText = ref('')
function startEdit(idx: number, text: string) {
editingIdx.value = idx
editText.value = text
}
function cancelEdit() {
editingIdx.value = -1
editText.value = ''
}
async function saveEdit(chunkIndex: number) {
if (!props.analysisId) return
const allChunks = props.chunks
const originalText = allChunks[chunkIndex]?.text
if (editText.value === originalText) {
cancelEdit()
return
}
await chunkingStore.updateChunkText(props.analysisId, chunkIndex, editText.value)
emit('rechunked')
cancelEdit()
}
const hoveredChunkIdx = ref(-1)
function onChunkHover(chunk: Chunk, localIdx: number) {
@ -425,6 +504,100 @@ async function doRechunk() {
border-radius: 4px;
}
.chunk-modified {
font-size: 10px;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.12);
padding: 1px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.chunk-edit-icon {
margin-left: auto;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s;
}
.chunk-card:hover .chunk-edit-icon {
opacity: 1;
}
.chunk-edit-icon:hover {
color: var(--accent);
background: var(--bg-tertiary);
}
.chunk-edit {
display: flex;
flex-direction: column;
gap: 8px;
}
.chunk-edit-textarea {
width: 100%;
font-size: 12px;
font-family: inherit;
line-height: 1.5;
color: var(--text);
background: var(--bg);
border: 1px solid var(--accent);
border-radius: var(--radius-sm, 4px);
padding: 8px;
resize: vertical;
box-sizing: border-box;
}
.chunk-edit-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.chunk-edit-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.chunk-edit-btn {
padding: 4px 12px;
border: none;
border-radius: var(--radius-sm, 4px);
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.chunk-edit-btn.save {
background: var(--accent);
color: white;
}
.chunk-edit-btn.save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chunk-edit-btn.cancel {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.chunk-edit-btn.cancel:hover {
color: var(--text);
}
.chunk-text {
font-size: 12px;
color: var(--text);
@ -433,6 +606,7 @@ async function doRechunk() {
word-break: break-word;
max-height: 120px;
overflow-y: auto;
cursor: text;
}
.chunk-empty {

View file

@ -119,6 +119,11 @@ const messages: Messages = {
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
'chunking.edit': 'Modifier',
'chunking.save': 'Enregistrer',
'chunking.saving': 'Enregistrement...',
'chunking.cancel': 'Annuler',
'chunking.modified': 'modifié',
'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.',
@ -245,6 +250,11 @@ const messages: Messages = {
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Run chunking to prepare segments.',
'chunking.noChunksOnPage': 'No chunks on this page.',
'chunking.edit': 'Edit',
'chunking.save': 'Save',
'chunking.saving': 'Saving...',
'chunking.cancel': 'Cancel',
'chunking.modified': 'modified',
'chunking.batchNotice':
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.',

View file

@ -58,6 +58,7 @@ export interface Chunk {
sourcePage: number | null
tokenCount: number
bboxes: ChunkBbox[]
modified?: boolean
}
export interface PageElement {