From c74a3277b8cb81a7a561d2fdbc10360616c383b4 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 10 Apr 2026 19:37:29 +0200 Subject: [PATCH] feat(chunking): inline chunk text editing Closes #89 --- CHANGELOG.md | 10 + document-parser/api/analyses.py | 23 +++ document-parser/api/schemas.py | 5 + document-parser/services/analysis_service.py | 22 +++ .../tests/test_analysis_service.py | 79 ++++++++ document-parser/tests/test_chunking.py | 64 +++++++ frontend/src/features/chunking/api.test.ts | 17 +- frontend/src/features/chunking/api.ts | 7 + frontend/src/features/chunking/store.test.ts | 44 +++++ frontend/src/features/chunking/store.ts | 21 ++- .../src/features/chunking/ui/ChunkPanel.vue | 176 +++++++++++++++++- frontend/src/shared/i18n.ts | 10 + frontend/src/shared/types.ts | 1 + 13 files changed, 476 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcdb5a7..52b42aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/document-parser/api/analyses.py b/document-parser/api/analyses.py index f54ae9d..f15c6e2 100644 --- a/document-parser/api/analyses.py +++ b/document-parser/api/analyses.py @@ -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.""" diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index 1df46d6..1d77e73 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -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): diff --git a/document-parser/services/analysis_service.py b/document-parser/services/analysis_service.py index b158272..e1a366a 100644 --- a/document-parser/services/analysis_service.py +++ b/document-parser/services/analysis_service.py @@ -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, diff --git a/document-parser/tests/test_analysis_service.py b/document-parser/tests/test_analysis_service.py index f48f4cf..6fb1523 100644 --- a/document-parser/tests/test_analysis_service.py +++ b/document-parser/tests/test_analysis_service.py @@ -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") diff --git a/document-parser/tests/test_chunking.py b/document-parser/tests/test_chunking.py index 52e84c8..a1353fb 100644 --- a/document-parser/tests/test_chunking.py +++ b/document-parser/tests/test_chunking.py @@ -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( diff --git a/frontend/src/features/chunking/api.test.ts b/frontend/src/features/chunking/api.test.ts index 4fbd990..89d7bf7 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 } 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) + }) }) diff --git a/frontend/src/features/chunking/api.ts b/frontend/src/features/chunking/api.ts index 5a79601..d15a835 100644 --- a/frontend/src/features/chunking/api.ts +++ b/frontend/src/features/chunking/api.ts @@ -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 { + return apiFetch(`/api/analyses/${jobId}/chunks/${chunkIndex}`, { + method: 'PATCH', + body: JSON.stringify({ text }), + }) +} diff --git a/frontend/src/features/chunking/store.test.ts b/frontend/src/features/chunking/store.test.ts index e7296f4..1ff9bba 100644 --- a/frontend/src/features/chunking/store.test.ts +++ b/frontend/src/features/chunking/store.test.ts @@ -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') + }) }) diff --git a/frontend/src/features/chunking/store.ts b/frontend/src/features/chunking/store.ts index 86feaa2..24a6bcc 100644 --- a/frontend/src/features/chunking/store.ts +++ b/frontend/src/features/chunking/store.ts @@ -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(null) async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise { @@ -21,5 +22,23 @@ export const useChunkingStore = defineStore('chunking', () => { } } - return { rechunking, error, rechunk } + async function updateChunkText( + jobId: string, + chunkIndex: number, + text: string, + ): Promise { + 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 } }) diff --git a/frontend/src/features/chunking/ui/ChunkPanel.vue b/frontend/src/features/chunking/ui/ChunkPanel.vue index de70ad3..ba4dfbb 100644 --- a/frontend/src/features/chunking/ui/ChunkPanel.vue +++ b/frontend/src/features/chunking/ui/ChunkPanel.vue @@ -102,11 +102,64 @@ {{ chunk.tokenCount }} tokens p.{{ chunk.sourcePage }} + + {{ t('chunking.modified') }} + +
{{ h }}
-
{{ chunk.text }}
+ + +
+