Merge pull request #151 from scub-france/feature/delete-chunk

feat(chunking): soft-delete chunk with confirmation dialog
This commit is contained in:
Pier-Jean Malandrino 2026-04-10 20:30:45 +02:00 committed by GitHub
commit da6ebec6dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 383 additions and 5 deletions

View file

@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- 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
- Soft-delete chunks: delete button with confirmation dialog, chunks hidden from UI but preserved in data
### Fixed

View file

@ -128,6 +128,28 @@ async def update_chunk_text(
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),
deleted=c.get("deleted", False),
)
for c in chunks
]
@router.delete("/{job_id}/chunks/{chunk_index}", response_model=list[ChunkResponse])
async def delete_chunk(job_id: str, chunk_index: int, service: ServiceDep) -> list[ChunkResponse]:
"""Soft-delete a chunk by index (marks it as deleted)."""
try:
chunks = await service.delete_chunk(job_id, chunk_index)
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),
deleted=c.get("deleted", False),
)
for c in chunks
]

View file

@ -159,6 +159,7 @@ class ChunkResponse(_CamelModel):
token_count: int = 0
bboxes: list[ChunkBboxResponse] = []
modified: bool = False
deleted: bool = False
class UpdateChunkTextRequest(BaseModel):

View file

@ -182,6 +182,27 @@ class AnalysisService:
return chunks
async def delete_chunk(self, job_id: str, chunk_index: int) -> list[dict]:
"""Soft-delete a 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]["deleted"] = 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

@ -594,3 +594,64 @@ class TestUpdateChunkText:
with pytest.raises(ValueError, match="No chunks available"):
await service.update_chunk_text("j1", 0, "text")
class TestDeleteChunk:
"""Tests for AnalysisService.delete_chunk."""
@pytest.mark.asyncio
async def test_delete_chunk_success(self):
chunks = [
{"text": "chunk1", "headings": [], "sourcePage": 1, "tokenCount": 5, "bboxes": []},
{"text": "chunk2", "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.delete_chunk("j1", 0)
assert result[0]["deleted"] is True
assert result[1].get("deleted", False) is False
repo.update_chunks.assert_called_once()
@pytest.mark.asyncio
async def test_delete_chunk_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.delete_chunk("missing", 0)
@pytest.mark.asyncio
async def test_delete_chunk_index_out_of_range(self):
chunks = [{"text": "only", "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.delete_chunk("j1", 5)
@pytest.mark.asyncio
async def test_delete_chunk_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.delete_chunk("j1", 0)

View file

@ -342,6 +342,50 @@ class TestUpdateChunkTextEndpoint:
assert resp.status_code == 400
class TestDeleteChunkEndpoint:
def test_delete_chunk_success(self, client, mock_analysis_service):
updated_chunks = [
{
"text": "chunk1",
"headings": [],
"sourcePage": 1,
"tokenCount": 10,
"bboxes": [],
"deleted": True,
},
{
"text": "chunk2",
"headings": [],
"sourcePage": 2,
"tokenCount": 20,
"bboxes": [],
"deleted": False,
},
]
mock_analysis_service.delete_chunk = AsyncMock(return_value=updated_chunks)
resp = client.delete("/api/analyses/j1/chunks/0")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["deleted"] is True
assert data[1]["deleted"] is False
def test_delete_chunk_invalid_index(self, client, mock_analysis_service):
mock_analysis_service.delete_chunk = AsyncMock(
side_effect=ValueError("Chunk index out of range: 99"),
)
resp = client.delete("/api/analyses/j1/chunks/99")
assert resp.status_code == 400
def test_delete_chunk_not_completed(self, client, mock_analysis_service):
mock_analysis_service.delete_chunk = AsyncMock(
side_effect=ValueError("Analysis is not completed: j1"),
)
resp = client.delete("/api/analyses/j1/chunks/0")
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, updateChunkText } from './api'
import { rechunkAnalysis, updateChunkText, deleteChunk } from './api'
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
@ -40,4 +40,18 @@ describe('chunking API', () => {
})
expect(result).toEqual(chunks)
})
it('deleteChunk sends DELETE to chunk endpoint', async () => {
const chunks = [
{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10, bboxes: [], deleted: true },
]
apiFetch.mockResolvedValue(chunks)
const result = await deleteChunk('job-1', 0)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/job-1/chunks/0', {
method: 'DELETE',
})
expect(result).toEqual(chunks)
})
})

View file

@ -14,3 +14,9 @@ export function updateChunkText(jobId: string, chunkIndex: number, text: string)
body: JSON.stringify({ text }),
})
}
export function deleteChunk(jobId: string, chunkIndex: number): Promise<Chunk[]> {
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/chunks/${chunkIndex}`, {
method: 'DELETE',
})
}

View file

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

View file

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

View file

@ -84,7 +84,7 @@
<!-- Chunks list -->
<div class="chunk-results" data-e2e="chunk-results" v-if="pageChunks.length">
<div class="chunk-summary" data-e2e="chunk-summary">
{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}
{{ activeChunks.length }} {{ t('chunking.chunks') }}
</div>
<div class="chunk-list">
<div
@ -118,6 +118,20 @@
/>
</svg>
</button>
<button
class="chunk-delete-icon"
data-e2e="chunk-delete-btn"
:title="t('chunking.delete')"
@click.stop="confirmDelete(globalIndex(localIdx))"
>
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</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>
@ -164,6 +178,30 @@
</div>
</div>
<!-- Delete confirmation dialog -->
<div v-if="deleteConfirmIdx !== -1" class="chunk-confirm-overlay" data-e2e="chunk-confirm">
<div class="chunk-confirm-dialog">
<p class="chunk-confirm-text">{{ t('chunking.deleteConfirm') }}</p>
<div class="chunk-confirm-actions">
<button
class="chunk-confirm-btn danger"
data-e2e="chunk-confirm-yes"
:disabled="chunkingStore.deleting"
@click="doDelete"
>
{{ chunkingStore.deleting ? t('chunking.deleting') : t('chunking.delete') }}
</button>
<button
class="chunk-confirm-btn cancel"
data-e2e="chunk-confirm-no"
@click="deleteConfirmIdx = -1"
>
{{ t('chunking.cancel') }}
</button>
</div>
</div>
</div>
<div class="chunk-empty" v-else-if="!chunkingStore.rechunking">
<p>
{{ chunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks') }}
@ -223,11 +261,29 @@ const isBatchedAnalysis = computed(() => {
return props.analysisStatus === 'COMPLETED' && !props.hasDocumentJson
})
const pageChunks = computed(() => props.chunks.filter((c) => c.sourcePage === props.currentPage))
const activeChunks = computed(() => props.chunks.filter((c) => !c.deleted))
const pageChunks = computed(() =>
activeChunks.value.filter((c) => c.sourcePage === props.currentPage),
)
const pagination = usePagination(pageChunks, { pageSize: 20 })
function globalIndex(localIdx: number): number {
return (pagination.page.value - 1) * pagination.pageSize.value + localIdx
const pageLocalIdx = (pagination.page.value - 1) * pagination.pageSize.value + localIdx
const pageChunk = pageChunks.value[pageLocalIdx]
return props.chunks.indexOf(pageChunk)
}
const deleteConfirmIdx = ref(-1)
function confirmDelete(chunkIndex: number) {
deleteConfirmIdx.value = chunkIndex
}
async function doDelete() {
if (!props.analysisId || deleteConfirmIdx.value === -1) return
await chunkingStore.deleteChunk(props.analysisId, deleteConfirmIdx.value)
deleteConfirmIdx.value = -1
emit('rechunked')
}
const editingIdx = ref(-1)
@ -282,6 +338,7 @@ async function doRechunk() {
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
}
.chunk-config {
@ -529,6 +586,19 @@ async function doRechunk() {
transition: opacity 0.15s;
}
.chunk-delete-icon {
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;
}
@ -538,6 +608,15 @@ async function doRechunk() {
background: var(--bg-tertiary);
}
.chunk-card:hover .chunk-delete-icon {
opacity: 1;
}
.chunk-delete-icon:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.chunk-edit {
display: flex;
flex-direction: column;
@ -598,6 +677,67 @@ async function doRechunk() {
color: var(--text);
}
.chunk-confirm-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.chunk-confirm-dialog {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
max-width: 300px;
width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.chunk-confirm-text {
font-size: 13px;
color: var(--text);
margin: 0 0 16px;
line-height: 1.5;
}
.chunk-confirm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.chunk-confirm-btn {
padding: 6px 14px;
border: none;
border-radius: var(--radius-sm, 4px);
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.chunk-confirm-btn.danger {
background: #ef4444;
color: white;
}
.chunk-confirm-btn.danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chunk-confirm-btn.cancel {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.chunk-confirm-btn.cancel:hover {
color: var(--text);
}
.chunk-text {
font-size: 12px;
color: var(--text);

View file

@ -124,6 +124,10 @@ const messages: Messages = {
'chunking.saving': 'Enregistrement...',
'chunking.cancel': 'Annuler',
'chunking.modified': 'modifié',
'chunking.delete': 'Supprimer',
'chunking.deleting': 'Suppression...',
'chunking.deleteConfirm':
'Supprimer ce chunk ? Il sera marqué comme supprimé jusqu\u2019à la prochaine synchronisation.',
'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.',
@ -255,6 +259,10 @@ const messages: Messages = {
'chunking.saving': 'Saving...',
'chunking.cancel': 'Cancel',
'chunking.modified': 'modified',
'chunking.delete': 'Delete',
'chunking.deleting': 'Deleting...',
'chunking.deleteConfirm':
'Delete this chunk? It will be marked as deleted until the next sync.',
'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

@ -59,6 +59,7 @@ export interface Chunk {
tokenCount: number
bboxes: ChunkBbox[]
modified?: boolean
deleted?: boolean
}
export interface PageElement {