Merge pull request #150 from scub-france/feature/edit-chunk-text

feat(chunking): inline chunk text editing
This commit is contained in:
Pier-Jean Malandrino 2026-04-10 20:26:54 +02:00 committed by GitHub
commit 7864a2c9e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 467 additions and 3 deletions

View file

@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added ### Added
- Inline chunk text editing: double-click or edit button to modify chunk text, with save/cancel and "modified" badge
- Docker Compose dev stack (`docker-compose.dev.yml`) with OpenSearch, Dashboards, hot-reload backend and Vite frontend - Docker Compose dev stack (`docker-compose.dev.yml`) with OpenSearch, Dashboards, hot-reload backend and Vite frontend
### Fixed ### Fixed

View file

@ -13,6 +13,7 @@ from api.schemas import (
ChunkResponse, ChunkResponse,
CreateAnalysisRequest, CreateAnalysisRequest,
RechunkRequest, RechunkRequest,
UpdateChunkTextRequest,
) )
from services.analysis_service import AnalysisService 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) @router.delete("/{job_id}", status_code=204)
async def delete_analysis(job_id: str, service: ServiceDep) -> None: async def delete_analysis(job_id: str, service: ServiceDep) -> None:
"""Delete an analysis job.""" """Delete an analysis job."""

View file

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

View file

@ -160,6 +160,28 @@ class AnalysisService:
return chunks 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( async def _run_batched_conversion(
self, self,
job_id: str, job_id: str,

View file

@ -4,10 +4,12 @@ from __future__ import annotations
import asyncio import asyncio
import functools import functools
import json
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from domain.models import AnalysisStatus
from domain.services import extract_html_body, merge_results from domain.services import extract_html_body, merge_results
from domain.value_objects import ConversionResult, PageDetail from domain.value_objects import ConversionResult, PageDetail
from services.analysis_service import AnalysisConfig, AnalysisService, _count_pdf_pages from services.analysis_service import AnalysisConfig, AnalysisService, _count_pdf_pages
@ -515,3 +517,80 @@ class TestBatchedConversion:
assert result is None assert result is None
# Only first batch should have been converted # Only first batch should have been converted
assert converter.convert.call_count == 1 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" 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: class TestRechunkEndpoint:
def test_rechunk_success(self, client, mock_analysis_service): def test_rechunk_success(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock( mock_analysis_service.rechunk = AsyncMock(

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { rechunkAnalysis } from './api' import { rechunkAnalysis, updateChunkText } from './api'
vi.mock('../../shared/api/http', () => ({ vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(), apiFetch: vi.fn(),
@ -25,4 +25,19 @@ describe('chunking API', () => {
}) })
expect(result).toEqual(chunks) 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 }), 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', () => ({ vi.mock('./api', () => ({
rechunkAnalysis: vi.fn(), rechunkAnalysis: vi.fn(),
updateChunkText: vi.fn(),
})) }))
import * as api from './api' import * as api from './api'
@ -17,6 +18,7 @@ describe('useChunkingStore', () => {
it('starts with default state', () => { it('starts with default state', () => {
const store = useChunkingStore() const store = useChunkingStore()
expect(store.rechunking).toBe(false) expect(store.rechunking).toBe(false)
expect(store.saving).toBe(false)
expect(store.error).toBeNull() expect(store.error).toBeNull()
}) })
@ -62,4 +64,46 @@ describe('useChunkingStore', () => {
expect(store.rechunking).toBe(false) expect(store.rechunking).toBe(false)
expect(store.error).toBe('fail') 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', () => { export const useChunkingStore = defineStore('chunking', () => {
const rechunking = ref(false) const rechunking = ref(false)
const saving = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> { 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 {{ chunk.tokenCount }} tokens
</span> </span>
<span class="chunk-page" v-if="chunk.sourcePage"> p.{{ chunk.sourcePage }} </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>
<div class="chunk-headings" v-if="chunk.headings.length"> <div class="chunk-headings" v-if="chunk.headings.length">
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span> <span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
</div> </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> </div>
</div> </div>
@ -177,6 +230,32 @@ function globalIndex(localIdx: number): number {
return (pagination.page.value - 1) * pagination.pageSize.value + localIdx 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) const hoveredChunkIdx = ref(-1)
function onChunkHover(chunk: Chunk, localIdx: number) { function onChunkHover(chunk: Chunk, localIdx: number) {
@ -425,6 +504,100 @@ async function doRechunk() {
border-radius: 4px; 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 { .chunk-text {
font-size: 12px; font-size: 12px;
color: var(--text); color: var(--text);
@ -433,6 +606,7 @@ async function doRechunk() {
word-break: break-word; word-break: break-word;
max-height: 120px; max-height: 120px;
overflow-y: auto; overflow-y: auto;
cursor: text;
} }
.chunk-empty { .chunk-empty {

View file

@ -119,6 +119,11 @@ const messages: Messages = {
'chunking.chunks': 'chunks', 'chunking.chunks': 'chunks',
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.', 'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.', 'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
'chunking.edit': 'Modifier',
'chunking.save': 'Enregistrer',
'chunking.saving': 'Enregistrement...',
'chunking.cancel': 'Annuler',
'chunking.modified': 'modifié',
'chunking.batchNotice': '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.', '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.chunks': 'chunks',
'chunking.noChunks': 'Run chunking to prepare segments.', 'chunking.noChunks': 'Run chunking to prepare segments.',
'chunking.noChunksOnPage': 'No chunks on this page.', '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.batchNotice':
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.', '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 sourcePage: number | null
tokenCount: number tokenCount: number
bboxes: ChunkBbox[] bboxes: ChunkBbox[]
modified?: boolean
} }
export interface PageElement { export interface PageElement {