docs(#256): flesh out design doc with chunk service + 9 routes plan
This commit is contained in:
parent
7fe9a8a823
commit
75a2db087b
1 changed files with 319 additions and 50 deletions
|
|
@ -32,9 +32,9 @@ the linked issue. Everything else is on the author.
|
|||
- **Date:** 2026-05-07
|
||||
- **Status:** Draft
|
||||
- **Target milestone:** 0.6.0 — Doc-centric ingest
|
||||
- **Impacted layers:** <backend: domain | api | services | persistence | infra> · <frontend: features/<name> | shared | app> · <e2e> · <infra/CI>
|
||||
- **Audit dimensions likely touched:** <pick from: Hexagonal Architecture · DDD · Clean Code · KISS · DRY · SOLID · Decoupling · Security · Tests · CI/Build · Documentation · Performance>
|
||||
- **ADR spawned?:** <no> *(write an ADR when choosing a library, moving a boundary, or deciding **not** to do something — see `docs/architecture/adr-guide.md`)*
|
||||
- **Impacted layers:** backend: api · services · (persistence read-only — déjà en place) · frontend: features/chunks (zéro changement attendu, déjà aligné sur le contrat) · e2e: nouveau scénario Karate
|
||||
- **Audit dimensions likely touched:** Hexagonal Architecture · DDD · Decoupling · Tests · Documentation
|
||||
- **ADR spawned?:** no — le modèle canonical `Chunk` (port + repository) existe déjà depuis #205, on ne fait que combler une couche service + API absente.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -47,9 +47,13 @@ Two or three short paragraphs is usually enough. If you can't state the
|
|||
problem in plain language, you are not ready to design a solution.
|
||||
-->
|
||||
|
||||
Dans l'onglet **Doc**, l'activation du mode **chunk** déclenche une réponse **HTTP 404** côté front. Le mode chunk est donc inutilisable.
|
||||
Dans l'onglet **Doc** (`DocWorkspacePage` → `DocChunksTab`), l'activation du mode **chunk** déclenche un **HTTP 404**.
|
||||
|
||||
TODO: ajouter logs / capture / endpoint exact qui 404.
|
||||
L'investigation a montré que ce n'est pas un endpoint isolé qui manque, mais **9 routes complètes côté `/api/documents/{id}/...`** qui sont appelées depuis le front mais n'existent pas côté backend. La couche frontend a été développée sur la cible 0.6.0 (doc-centric) avant que la couche API correspondante ne soit posée.
|
||||
|
||||
Le domain et la persistence du modèle canonical `Chunk` (entité de première classe, audit log via `ChunkEdit`, snapshots `ChunkPush`) existent **déjà** depuis l'issue #205 (`document-parser/domain/models.py`, `domain/ports.py`, `persistence/chunk_repo.py`, `persistence/chunk_edit_repo.py`). Ce qui manque, c'est la couche **service** et la couche **API** qui les exposent au front.
|
||||
|
||||
Note importante: le mode **OCR Debug** de `StudioPage` (`/api/analyses/*`) doit rester intact. Il opère sur des `AnalysisJob` éphémères avec pipeline options variables, ce qui est conceptuellement différent des chunks canonical d'un document. Les deux flux partagent le service de chunking sous-jacent (port `DocumentChunker`) mais exposent des ressources distinctes — pas de duplication d'endpoints.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
|
|
@ -59,9 +63,13 @@ checkboxes here; the design is "done" when all are satisfied. Keep the list
|
|||
small — five or fewer goals is a good smell.
|
||||
-->
|
||||
|
||||
- [ ] TODO: étapes précises de reproduction (doc utilisé, séquence UI)
|
||||
- [ ] TODO: identifier l'endpoint backend qui 404
|
||||
- [ ] TODO: correctif + non-régression
|
||||
- [ ] Mode chunk de l'onglet Doc renvoie la liste des chunks canonical sans 404 (endpoint `GET /api/documents/{id}/chunks` opérationnel).
|
||||
- [ ] Toutes les actions d'édition fonctionnent: add, edit, delete, split, merge, rechunk (endpoints `POST/PATCH/DELETE` correspondants).
|
||||
- [ ] Le push vers un store et le diff par store fonctionnent (`POST /push`, `GET /diff?store=…`).
|
||||
- [ ] L'arbre structurel s'affiche sur l'onglet Inspect (`GET /tree`).
|
||||
- [ ] Le mode OCR Debug (`StudioPage` → `/api/analyses/*`) reste totalement fonctionnel — non-régression.
|
||||
- [ ] Promotion automatique: à la première analyse réussie d'un document, ses chunks deviennent canonical (peuplent `chunks` table via `ChunkRepository`).
|
||||
- [ ] Tests: unit Vitest sur les nouveaux appels, pytest sur le service + router, e2e Karate sur le flow `Doc tab → mode chunk`.
|
||||
|
||||
## 3. Non-goals
|
||||
|
||||
|
|
@ -73,41 +81,75 @@ prevents scope creep. If you leave this empty, reviewers will fill it in
|
|||
for you, badly.
|
||||
-->
|
||||
|
||||
- ...
|
||||
- ...
|
||||
- **Pas de refonte du modèle de données.** Le domain `Chunk` / `ChunkEdit` / `ChunkPush` est déjà posé (#205). On ne touche ni aux dataclasses ni au schéma SQLite.
|
||||
- **Pas de refonte de StudioPage / OCR Debug.** Le flux `/api/analyses/*` reste tel quel; aucun port ni service de l'AnalysisService n'est modifié au-delà du hook de promotion canonical.
|
||||
- **Pas de fusion `features/chunking/` ↔ `features/chunks/` côté front.** Ce sont deux features sémantiquement distinctes (acte de chunking debug vs état canonical persistant).
|
||||
- **Pas de refactor des fichiers > 300 lignes** identifiés dans l'audit (StudioPage, GraphView, ChunksEditor) — sera traité dans des issues séparées du milestone 0.7.0.
|
||||
- **Pas d'ajout de nouvelle table** ni de nouvelles colonnes — tout est déjà présent.
|
||||
- **Pas de versioning d'API** — première exposition de ces routes, pas de breaking change.
|
||||
|
||||
## 4. Context & constraints
|
||||
|
||||
<!--
|
||||
The surrounding reality the design has to live in.
|
||||
### Cartographie des 9 routes manquantes
|
||||
|
||||
### Existing code surface
|
||||
List the modules / files / stores this change touches. Prefer concrete paths
|
||||
over prose:
|
||||
- Backend: document-parser/<layer>/<file>.py
|
||||
- Frontend: frontend/src/features/<name>/{store,api,ui}.ts|.vue
|
||||
- Persistence: document-parser/persistence/<repo>.py + schema in database.py
|
||||
- E2E: e2e/<feature>.feature
|
||||
Côté front (déjà écrit, fonctionne dès que le back répond):
|
||||
|
||||
### Hexagonal Architecture constraints (backend)
|
||||
The domain layer has zero imports from api / persistence / infra, and
|
||||
defines ports (abstract protocols) that `infra/` adapters implement.
|
||||
Persistence imports only from domain. API never imports persistence
|
||||
directly — it goes through services. Call out any change that crosses
|
||||
these lines or adds / moves a port.
|
||||
| Méthode | URL | Front caller |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/documents/{id}/chunks` | `features/chunks/api.ts:5` |
|
||||
| `POST` | `/api/documents/{id}/chunks` | `features/chunks/api.ts:42` |
|
||||
| `PATCH` | `/api/documents/{id}/chunks/{chunkId}` | `features/chunks/api.ts:13` |
|
||||
| `DELETE` | `/api/documents/{id}/chunks/{chunkId}` | `features/chunks/api.ts:38` |
|
||||
| `POST` | `/api/documents/{id}/chunks/{chunkId}/split` | `features/chunks/api.ts:31` |
|
||||
| `POST` | `/api/documents/{id}/chunks/merge` | `features/chunks/api.ts:20` |
|
||||
| `POST` | `/api/documents/{id}/rechunk` | `features/document/api.ts:31` |
|
||||
| `GET` | `/api/documents/{id}/diff?store={name}` | `features/chunks/api.ts:49` |
|
||||
| `POST` | `/api/documents/{id}/chunks/push` | `features/chunks/api.ts:56` |
|
||||
| `POST` | `/api/documents/{id}/push` | `features/document/api.ts:35` (alias par doc — voir §5) |
|
||||
| `GET` | `/api/documents/{id}/tree` | `features/document/api.ts:42` |
|
||||
|
||||
### Surface de code touchée
|
||||
|
||||
- **Backend (à créer)**:
|
||||
- `document-parser/services/chunk_service.py` (nouveau)
|
||||
- `document-parser/api/document_chunks.py` (nouveau router)
|
||||
- `document-parser/api/schemas.py` (DTOs camelCase)
|
||||
- `document-parser/main.py` (registration du router + injection du service)
|
||||
- `document-parser/services/analysis_service.py` (hook de promotion canonical)
|
||||
- `document-parser/tests/services/test_chunk_service.py` (nouveau)
|
||||
- `document-parser/tests/api/test_document_chunks.py` (nouveau)
|
||||
|
||||
- **Backend (existant, lecture seule)**:
|
||||
- `document-parser/domain/models.py` — `Chunk`, `ChunkEdit`, `ChunkPush` ✅
|
||||
- `document-parser/domain/ports.py` — `ChunkRepository`, `ChunkEditRepository`, `ChunkPushRepository`, `DocumentChunker` ✅
|
||||
- `document-parser/persistence/chunk_repo.py`, `chunk_edit_repo.py` ✅
|
||||
- `document-parser/services/ingestion_service.py` (réutilisé pour push)
|
||||
- `document-parser/services/store_service.py` (réutilisé pour diff par store)
|
||||
|
||||
- **Frontend (zéro changement attendu)**:
|
||||
- `frontend/src/features/chunks/api.ts` — déjà aligné sur le contrat
|
||||
- `frontend/src/features/document/api.ts` — déjà aligné
|
||||
|
||||
- **E2E (à créer)**:
|
||||
- `e2e/ui/src/test/resources/documents/doc-tab-chunk-mode.feature`
|
||||
|
||||
### Hexagonal Architecture
|
||||
|
||||
- Le domain ne change pas. Aucun nouveau port n'est ajouté: tous les besoins sont déjà couverts (`ChunkRepository`, `ChunkEditRepository`, `DocumentChunker`).
|
||||
- Le nouveau `ChunkService` orchestre des ports existants et délègue à `AnalysisService.rechunk` pour le rechunk du doc canonical (DRY: la mécanique chunk-via-Docling est une seule implémentation).
|
||||
- L'API ne franchit pas de couche: le router `document_chunks.py` parle uniquement aux services, jamais à `chunk_repo` directement.
|
||||
- Conséquence: zéro nouvelle dépendance entre couches.
|
||||
|
||||
### Deployment modes
|
||||
Docling Studio ships two images (`latest-local`, `latest-remote`) driven by
|
||||
`CONVERSION_ENGINE` — and a HF Space deployment on top of `latest-remote`.
|
||||
State which modes this design supports, which it does not, and how the
|
||||
frontend's feature flags (`chunking`, `disclaimer`) are affected.
|
||||
|
||||
- Supporté: `latest-local` et `latest-remote` à l'identique. Aucun comportement spécifique au `CONVERSION_ENGINE` — `ChunkService` ne touche pas au converter, seulement au chunker (port `DocumentChunker` qui a une seule implémentation).
|
||||
- HF Space: identique à `latest-remote`. Pas de feature flag à ajouter dans `/api/health` — les chunks canonical font partie du flow doc-centric standard.
|
||||
|
||||
### Hard constraints
|
||||
Compatibility (SQLite schema, API contract, Pydantic DTOs), deadlines
|
||||
(milestone due date), deployment target (Docker Compose, HF Space),
|
||||
performance budget (matters for Performance audit), license / privacy
|
||||
(matters for Security audit).
|
||||
-->
|
||||
|
||||
- Pas de migration SQLite (tables existent déjà).
|
||||
- Pas de breaking change API (premières routes exposées).
|
||||
- Performance: les opérations chunk lisent/écrivent ≤ N chunks pour un doc (typiquement < 200). Aucune requête non-bornée. Le rechunk lance Docling chunker en thread (`asyncio.to_thread`), comme `analysis_service.rechunk` actuel.
|
||||
|
||||
## 5. Proposed design
|
||||
|
||||
|
|
@ -162,18 +204,118 @@ valuable than pseudocode.
|
|||
|
||||
### 5.1 Domain
|
||||
|
||||
**Aucun changement.** Tout est en place depuis #205:
|
||||
|
||||
- `Chunk(id, document_id, sequence, text, headings, source_page, bboxes, doc_items, token_count, created_at, updated_at, deleted_at)`
|
||||
- `ChunkEdit(id, document_id, chunk_id, action ∈ INSERT|UPDATE|DELETE|MERGE|SPLIT, actor, at, before, after, parents, children, reason)`
|
||||
- `ChunkPush(id, document_id, store_id, chunkset_hash, chunk_ids, …)`
|
||||
- Ports: `ChunkRepository`, `ChunkEditRepository`, `ChunkPushRepository`, `DocumentChunker`.
|
||||
|
||||
### 5.2 Persistence
|
||||
|
||||
**Aucun changement.** `chunk_repo.py` et `chunk_edit_repo.py` couvrent CRUD + audit. Le schéma SQLite existe déjà (vérifier `database.py`). Si une colonne ou un index manque pour les nouvelles requêtes (par ex. lookup chunks d'un doc ordonnés par `sequence`), ajouter dans la PR via un script idempotent.
|
||||
|
||||
### 5.3 Infra adapters
|
||||
|
||||
**Aucun changement.** `DocumentChunker` est déjà implémenté (le même qui sert `analysis_service.rechunk`).
|
||||
|
||||
### 5.4 Services
|
||||
|
||||
#### Nouveau: `ChunkService` — `document-parser/services/chunk_service.py`
|
||||
|
||||
Orchestre toutes les opérations sur le chunkset canonical d'un document. Délègue aux ports existants. Écrit les `ChunkEdit` en transaction avec chaque mutation.
|
||||
|
||||
```python
|
||||
class ChunkService:
|
||||
def __init__(
|
||||
self,
|
||||
chunk_repo: ChunkRepository,
|
||||
chunk_edit_repo: ChunkEditRepository,
|
||||
chunker: DocumentChunker,
|
||||
document_repo: DocumentRepository,
|
||||
analysis_service: AnalysisService, # pour le rechunk
|
||||
): ...
|
||||
|
||||
async def list_chunks(self, doc_id: str) -> list[Chunk]: ...
|
||||
async def add_chunk(self, doc_id: str, text: str, after_id: str | None) -> Chunk: ...
|
||||
async def update_chunk(self, doc_id: str, chunk_id: str, *, text: str | None, headings: list[str] | None) -> Chunk: ...
|
||||
async def delete_chunk(self, doc_id: str, chunk_id: str) -> None: ...
|
||||
async def split_chunk(self, doc_id: str, chunk_id: str, cursor_offset: int) -> list[Chunk]: ...
|
||||
async def merge_chunks(self, doc_id: str, ids: list[str]) -> Chunk: ...
|
||||
async def rechunk_document(self, doc_id: str, opts: ChunkingOptions | None) -> list[Chunk]: ...
|
||||
async def get_tree(self, doc_id: str) -> list[DocTreeNode]: ...
|
||||
async def diff_against_store(self, doc_id: str, store: str) -> list[ChunkDiff]: ...
|
||||
async def push_to_store(self, doc_id: str, store: str) -> ChunkPush: ...
|
||||
```
|
||||
|
||||
Règles d'invariants:
|
||||
- `sequence` des chunks reste dense ascendant. `add_chunk` insère et incrémente les suivants. `delete_chunk` est soft (marque `deleted_at`) pour préserver l'audit; les listings filtrent.
|
||||
- Toute mutation produit **une ligne `ChunkEdit`** atomiquement.
|
||||
- `rechunk_document`: appelle `analysis_service.rechunk_for_document(doc_id, opts)` (helper public à exposer dans `AnalysisService` si pas déjà), récupère la liste de chunks, **remplace** le chunkset canonical (delete soft de l'ancien + insert nouveau), enregistre un `ChunkEdit` `RESET`.
|
||||
- `get_tree`: dérive de la dernière `AnalysisJob` réussie (lecture de `pages_json` / `document_json`). Pas de calcul nouveau.
|
||||
- `push_to_store` et `diff_against_store`: délèguent à `ingestion_service` / `store_service` existants. Aucune logique dupliquée; le service expose juste l'interface "doc-centric".
|
||||
|
||||
#### Hook de promotion canonical — `AnalysisService.create`
|
||||
|
||||
À la fin d'une analyse réussie, si le document n'a pas encore de chunkset canonical (`chunk_repo.count_for_document(doc_id) == 0`), peupler depuis `job.chunks_json`:
|
||||
|
||||
```python
|
||||
async def create(self, ...) -> AnalysisJob:
|
||||
job = await self._run_analysis(...)
|
||||
if job.status == AnalysisStatus.COMPLETED and job.chunks_json:
|
||||
if await self._chunk_repo.count_for_document(job.document_id) == 0:
|
||||
await self._promote_to_canonical(job) # nouveau private method
|
||||
return job
|
||||
```
|
||||
|
||||
Promotion = parse `chunks_json` → instancie `Chunk(document_id=…)` → `chunk_repo.bulk_insert(chunks)` → `chunk_edit_repo.insert(ChunkEdit(action=INSERT, actor='system:initial-analysis'))`.
|
||||
|
||||
C'est le **seul** point qui croise les deux contextes (analyse → doc canonical). Reste local à `AnalysisService`.
|
||||
|
||||
### 5.5 API
|
||||
|
||||
#### Nouveau router: `document-parser/api/document_chunks.py`
|
||||
|
||||
Préfixe `/api/documents/{doc_id}`, tag `documents`. Toutes les routes injectent `ChunkService` via `Depends`.
|
||||
|
||||
```python
|
||||
@router.get("/{doc_id}/chunks", response_model=list[ChunkResponse])
|
||||
@router.post("/{doc_id}/chunks", response_model=ChunkResponse) # body: AddChunkRequest
|
||||
@router.patch("/{doc_id}/chunks/{chunk_id}", response_model=ChunkResponse) # body: UpdateChunkRequest
|
||||
@router.delete("/{doc_id}/chunks/{chunk_id}", status_code=204)
|
||||
@router.post("/{doc_id}/chunks/{chunk_id}/split", response_model=list[ChunkResponse]) # body: SplitChunkRequest { cursorOffset }
|
||||
@router.post("/{doc_id}/chunks/merge", response_model=ChunkResponse) # body: MergeChunksRequest { ids }
|
||||
@router.post("/{doc_id}/rechunk", response_model=list[ChunkResponse]) # body: RechunkRequest (optionnel)
|
||||
@router.get("/{doc_id}/tree", response_model=list[DocTreeNodeResponse])
|
||||
@router.get("/{doc_id}/diff", response_model=list[ChunkDiffResponse]) # query: store
|
||||
@router.post("/{doc_id}/chunks/push", response_model=PushSummaryResponse) # body: PushRequest { store }
|
||||
```
|
||||
|
||||
Note: deux URLs présentes côté front pour push (`/{id}/push` dans `document/api.ts` et `/{id}/chunks/push` dans `chunks/api.ts`). Une seule route doit exister côté backend pour respecter la directive "no duplicate". **Choix**: garder `/{id}/chunks/push` (sémantiquement plus juste — c'est bien des chunks qu'on pousse). Mettre à jour `features/document/api.ts:34-39` pour cibler la même URL et virer la duplication.
|
||||
|
||||
DTOs Pydantic: tous camelCase (alias_generator), `chunkId` au lieu de `chunk_id` côté wire, `sourcePage` etc.
|
||||
|
||||
Erreurs:
|
||||
- `404` si doc inexistant ou chunk inexistant
|
||||
- `409` si `merge_chunks` reçoit des ids non contigus (invariant `sequence`)
|
||||
- `400` si `split` reçoit un `cursorOffset` hors range
|
||||
|
||||
### 5.6 Frontend — feature module
|
||||
|
||||
**Zéro changement attendu.** `features/chunks/api.ts` et `features/document/api.ts` sont déjà alignés.
|
||||
|
||||
Une seule correction à apporter pour respecter "no duplicate endpoints": modifier `features/document/api.ts:35` pour pointer sur `/api/documents/${id}/chunks/push` au lieu de `/api/documents/${id}/push`. Ensuite cette fonction `pushDocumentToStore` peut être supprimée et le caller (`document/store.ts`) bascule sur `pushChunksToStore` de `features/chunks/api.ts`.
|
||||
|
||||
Bug latents identifiés par l'audit, à corriger dans la même PR (scope #256):
|
||||
- `ChunksEditor.vue:235` — `saveTimer` non clearé sur `onUnmounted`. Ajouter `onBeforeUnmount(() => clearTimeout(saveTimer))`.
|
||||
- `ChunksEditor.vue:215` — remplacer `alert(...)` natif par le pattern toast partagé.
|
||||
|
||||
### 5.7 Cross-cutting (feature flags, i18n, shared types)
|
||||
|
||||
- **Feature flags**: aucun. Le mode chunk est un flow standard du doc-centric, pas optionnel.
|
||||
- **i18n**: aucune nouvelle clé requise; les strings existent déjà côté `chunks.*`.
|
||||
- **Shared types**: `DocChunk`, `ChunkDiff`, `PushSummary`, `DocTreeNode` existent déjà dans `frontend/src/shared/types.ts`. Vérifier la correspondance camelCase avec les nouveaux DTOs Pydantic; aucun champ nouveau attendu.
|
||||
|
||||
## 6. Alternatives considered
|
||||
|
||||
<!--
|
||||
|
|
@ -189,18 +331,56 @@ If one of the alternatives represents a significant architectural fork
|
|||
local decision, the ADR captures the cross-cutting one.
|
||||
-->
|
||||
|
||||
### Alternative A — <name>
|
||||
### Alternative A — Recâbler le front sur `/api/analyses/*`
|
||||
|
||||
- **Summary:**
|
||||
- **Why not:**
|
||||
- **Summary:** Côté front, résoudre `docId → latest analysis jobId` dans `DocWorkspacePage`, puis appeler les endpoints `/api/analyses/{jobId}/...` existants (rechunk, chunks/{idx}, etc.). Aucun nouveau endpoint backend.
|
||||
- **Why not:** Conceptuellement faux. Une analyse est une run éphémère avec ses propres pipeline options; un document a un état chunk canonical persistant édité dans le temps. Identifier les deux casserait le modèle 0.6.0 doc-centric et empêcherait d'avoir plusieurs analyses parallèles d'un doc (ce que StudioPage permet) sans muter l'état canonical. Casse aussi l'audit log par chunk (`ChunkEdit`) qui est par doc, pas par job.
|
||||
|
||||
### Alternative B — <name>
|
||||
### Alternative B — Fusion `Analysis` ↔ `Document chunks`
|
||||
|
||||
- **Summary:**
|
||||
- **Why not:**
|
||||
- **Summary:** Supprimer `analysis_jobs.chunks_json`, faire que toute analyse écrive directement dans la table `chunks` du doc. StudioPage devient une UI de re-chunking d'un doc.
|
||||
- **Why not:** Casse l'usage debug de StudioPage (plusieurs runs avec pipeline options ≠ ne peuvent pas coexister). Refactor majeur, hors scope d'un bug fix. Pourrait être envisagé en 0.7.0+ si l'usage de StudioPage évolue.
|
||||
|
||||
## 7. API & data contract
|
||||
|
||||
### Endpoints (tous nouveaux, additifs)
|
||||
|
||||
| Method | Path | Request | Response | Breaking? |
|
||||
|--------|------|---------|----------|-----------|
|
||||
| GET | `/api/documents/{id}/chunks` | — | `ChunkResponse[]` | additive |
|
||||
| POST | `/api/documents/{id}/chunks` | `AddChunkRequest` `{text, afterId?}` | `ChunkResponse` | additive |
|
||||
| PATCH | `/api/documents/{id}/chunks/{chunkId}` | `UpdateChunkRequest` `{text?, headings?}` | `ChunkResponse` | additive |
|
||||
| DELETE | `/api/documents/{id}/chunks/{chunkId}` | — | 204 | additive |
|
||||
| POST | `/api/documents/{id}/chunks/{chunkId}/split` | `SplitChunkRequest` `{cursorOffset}` | `ChunkResponse[]` | additive |
|
||||
| POST | `/api/documents/{id}/chunks/merge` | `MergeChunksRequest` `{ids}` | `ChunkResponse` | additive |
|
||||
| POST | `/api/documents/{id}/rechunk` | `RechunkRequest` `{chunkingOptions?}` | `ChunkResponse[]` | additive |
|
||||
| GET | `/api/documents/{id}/tree` | — | `DocTreeNodeResponse[]` | additive |
|
||||
| GET | `/api/documents/{id}/diff?store={name}` | — | `ChunkDiffResponse[]` | additive |
|
||||
| POST | `/api/documents/{id}/chunks/push` | `PushRequest` `{store}` | `PushSummaryResponse` | additive |
|
||||
|
||||
Sérialisation **camelCase** côté wire (Pydantic `alias_generator`), snake_case interne. `pages_json` reste l'exception documentée si réutilisé.
|
||||
|
||||
### Persistence schema
|
||||
|
||||
```sql
|
||||
-- Aucune migration. Tables existantes (issue #205):
|
||||
-- chunks(id, document_id, sequence, text, headings_json, source_page, bboxes_json,
|
||||
-- doc_items_json, token_count, created_at, updated_at, deleted_at)
|
||||
-- chunk_edits(id, document_id, chunk_id, action, actor, at, before_json, after_json,
|
||||
-- parents_json, children_json, reason)
|
||||
-- chunk_pushes(id, document_id, store_id, chunkset_hash, chunk_ids_json, ...)
|
||||
```
|
||||
|
||||
### Env vars / config
|
||||
|
||||
| Name | Default | Allowed | Notes |
|
||||
|------|---------|---------|-------|
|
||||
| _(aucune)_ | | | Le `ChunkService` ne lit aucune nouvelle config. |
|
||||
|
||||
### Breaking changes
|
||||
|
||||
**Aucun.** Toutes les routes sont additives, premier passage live. Côté front, suppression de l'alias `pushDocumentToStore` (URL `/push`) au profit de `pushChunksToStore` (URL `/chunks/push`) — pas exposé externement.
|
||||
|
||||
<!--
|
||||
Make the wire contract explicit — this is what the frontend, e2e tests,
|
||||
and any external consumer will code against.
|
||||
|
|
@ -260,10 +440,72 @@ carefully.
|
|||
|
||||
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|
||||
|------|-----------------|------------|--------|---------------|------------------------|
|
||||
| | | | | | |
|
||||
| Promotion canonical s'exécute deux fois (race) sur deux analyses simultanées d'un doc neuf, dupliquant le chunkset | DDD / Decoupling | basse | élevée (corruption canonical) | tests d'intégration concurrent + count_for_document inattendu en prod | Garde idempotente dans `_promote_to_canonical` (vérif count + insert sous transaction unique) |
|
||||
| Régression OCR Debug si `analysis_service.create` modifié maladroitement | Tests / Decoupling | moyenne | élevée (StudioPage cassé) | non-régression e2e Karate `full-happy-path.feature` | Hook de promotion isolé en méthode privée, tests pytest qui couvrent les deux cas (analyse 1ère, analyse N) |
|
||||
| Endpoint `/diff` lent si beaucoup de chunks × store comparé | Performance | basse | moyenne | logs de durée > 1s | Borne dure (max 500 chunks par doc), pagination si dépassé en 0.7 |
|
||||
| `ChunkEdit` audit log non écrit en cas d'erreur partielle (ex: split crash après insert chunk 1/2) | Hexagonal Architecture / DDD | moyenne | moyenne (audit incohérent) | tests intégration + assert chunk_edit count après chaque mutation | Toutes les mutations en transaction aiosqlite unique chunk + edit |
|
||||
| `pushDocumentToStore` (URL legacy `/push`) encore appelé quelque part en prod | Decoupling | basse | basse (404 silencieux) | grep + e2e | Suppression complète de la fonction TS, pas d'alias |
|
||||
| Test e2e Karate flaky si l'analyse initiale n'a pas eu le temps de promouvoir | Tests | moyenne | basse | flaky en CI | E2E poll sur `lifecycle_state == 'parsed'` avant de cliquer sur l'onglet chunk |
|
||||
|
||||
## 9. Testing strategy
|
||||
|
||||
### Backend — pytest (`document-parser/tests/`)
|
||||
|
||||
- **Unit `tests/services/test_chunk_service.py`**:
|
||||
- `list_chunks` filtre les `deleted_at` non null
|
||||
- `add_chunk` insert + audit `INSERT` + recale les `sequence` suivants
|
||||
- `update_chunk` modifie + audit `UPDATE` avec `before/after` correctement remplis
|
||||
- `delete_chunk` soft-delete + audit `DELETE`
|
||||
- `split_chunk` produit 2 chunks + 2 audit `SPLIT` avec `parents = [source_id]`
|
||||
- `merge_chunks` produit 1 chunk + 1 audit `MERGE` avec `parents = [ids]`
|
||||
- `merge_chunks` rejette ids non contigus (409)
|
||||
- `rechunk_document` remplace canonical et écrit un audit `RESET`
|
||||
- `push_to_store` délègue à `ingestion_service` (mock)
|
||||
- `diff_against_store` délègue à `store_service` (mock)
|
||||
- **Unit `tests/services/test_analysis_service.py` (existant, à étendre)**:
|
||||
- `create()` peuple chunks canonical à la 1ère analyse réussie
|
||||
- `create()` ne touche PAS au canonical aux analyses suivantes (idempotence)
|
||||
- `create()` ne crée rien si l'analyse échoue
|
||||
- **Integration `tests/api/test_document_chunks.py`**:
|
||||
- Chaque route → 200 happy path
|
||||
- Chaque route → 404 si doc inexistant
|
||||
- Chaque route mutante → 404 si chunk inexistant
|
||||
- `merge` → 409 si non contigus
|
||||
- `split` → 400 si offset hors range
|
||||
- DTOs camelCase respectés sur input ET output
|
||||
- **Architecture tests**: vérifier que `api/document_chunks.py` n'importe pas `persistence/*` directement.
|
||||
|
||||
### Frontend — Vitest
|
||||
|
||||
- **`features/chunks/api.test.ts` (existant, à étendre)**:
|
||||
- Tests d'erreur HTTP (404, 500) sur `fetchChunks`, `updateChunk`, `mergeChunks`, `splitChunk`, `dropChunk`, `addChunk`, `fetchChunkDiff`, `pushChunksToStore`
|
||||
- **`features/chunks/store.test.ts` (existant, à étendre)**: `$reset` à chaque changement de `docId` (corrige le bug latent identifié dans l'audit).
|
||||
- **`features/document/api.test.ts`**: ajouter tests pour `fetchDocumentTree`, `rechunkDocument`. Supprimer `pushDocumentToStore` (réécriture vers `pushChunksToStore`).
|
||||
- **`features/document/store.test.ts`**: idem.
|
||||
|
||||
### E2E — Karate UI (`e2e/`)
|
||||
|
||||
- **Nouveau `e2e/ui/src/test/resources/documents/doc-tab-chunk-mode.feature`**:
|
||||
- Setup via API: upload doc → wait `lifecycle_state == 'parsed'`
|
||||
- UI: ouvrir `/docs/{id}` → cliquer onglet **Doc** → activer mode `chunk`
|
||||
- Assert: liste de chunks visible, **pas** de bannière 404, count > 0
|
||||
- Edit un chunk → assert persistence après reload
|
||||
- Cleanup via API: delete doc
|
||||
- Tags: `@critical @ui`
|
||||
- **Non-régression sur OCR Debug**: vérifier que `e2e/ui/src/test/resources/full-happy-path.feature` (StudioPage) reste vert après les modifications.
|
||||
|
||||
### Manual QA
|
||||
|
||||
1. `docker-compose -f docker-compose.dev.yml up`
|
||||
2. Upload un PDF via `/docs/new`, attendre status `parsed`
|
||||
3. Ouvrir `/docs/{id}` → onglet **Doc** → bouton **chunk** → vérifier liste affichée
|
||||
4. Ouvrir `/studio` → upload + run analyse OCR → vérifier que c'est inchangé
|
||||
5. Push doc vers un store → vérifier `/api/documents/{id}/diff?store=…`
|
||||
|
||||
### Performance / load
|
||||
|
||||
Pas d'objectif chiffré particulier. Surveiller: `rechunk_document` doit rester sous le budget `MAX_CONCURRENT_ANALYSES` (réutilise le même mécanisme que `analysis_service.rechunk`).
|
||||
|
||||
<!--
|
||||
How this design will be verified. Be specific — name files / suites.
|
||||
|
||||
|
|
@ -295,6 +537,39 @@ or touches the conversion hot path.
|
|||
|
||||
## 10. Rollout & observability
|
||||
|
||||
### Release branch
|
||||
|
||||
`release/0.6.0` — c'est l'objet du milestone "Doc-centric ingest". Branche de travail: `fix/256-doc-tab-chunk-mode-404`.
|
||||
|
||||
### Feature flag / staged rollout
|
||||
|
||||
**Aucun flag.** Le mode chunk fait partie du flow standard 0.6.0; pas de fallback derrière un toggle. La promotion canonical des chunks à la 1ère analyse est inconditionnelle.
|
||||
|
||||
### Observability
|
||||
|
||||
- Logs structurés à ajouter dans `ChunkService`:
|
||||
- `chunk.add docId=… chunkId=… sequence=…`
|
||||
- `chunk.update docId=… chunkId=…`
|
||||
- `chunk.delete docId=… chunkId=…`
|
||||
- `chunk.split docId=… sourceId=… newIds=…`
|
||||
- `chunk.merge docId=… sourceIds=… newId=…`
|
||||
- `chunk.rechunk docId=… count=… durationMs=…`
|
||||
- `chunk.push docId=… store=… count=…`
|
||||
- `chunk.promote docId=… count=… (initial-analysis)`
|
||||
- Pas de nouvelle métrique Prometheus.
|
||||
- Erreurs: les `HTTPException` 4xx ne loggent pas (normal); les 5xx loggent avec stack via `logger.exception` (pattern existant dans `documents.py:131`).
|
||||
|
||||
### Rollback plan
|
||||
|
||||
- Le déploiement est entièrement additif (nouvelles routes + nouveau service). Rollback = revert du commit / image précédente. Aucun changement de schéma à défaire.
|
||||
- Si un bug critique apparaît côté promotion canonical: désactiver le hook dans `analysis_service.create` via revert ciblé; les analyses continuent de fonctionner sans canonical (l'onglet chunk redeviendrait 404, état antérieur). Pas de perte de données.
|
||||
- Si un bug critique apparaît côté API chunks: revert du router suffit; l'onglet chunk redevient 404, le reste de l'app reste fonctionnel.
|
||||
|
||||
Liens:
|
||||
- Deployment: `docs/release/*` (`/release:deploy`)
|
||||
- Rollback: `/release:rollback`
|
||||
- Incident: `docs/operations/*` (`/ops:incident`)
|
||||
|
||||
<!--
|
||||
How this change gets to production safely.
|
||||
|
||||
|
|
@ -326,15 +601,9 @@ Link to the existing release / ops playbooks:
|
|||
|
||||
## 11. Open questions
|
||||
|
||||
<!--
|
||||
Things the author explicitly does not know yet, phrased as questions the
|
||||
reviewer can answer or redirect. Empty is allowed once the design is
|
||||
Accepted — during Draft / In review, this section is where the honest
|
||||
uncertainty lives. Resolve or delete each entry before shipping.
|
||||
-->
|
||||
|
||||
- ...
|
||||
- ...
|
||||
- Le port `DocumentChunker` accepte-t-il `cursor_offset` pour `split`, ou faut-il l'implémenter manuellement dans `ChunkService.split_chunk` (split textuel basique sans repasser dans Docling) ? À vérifier au moment de l'implémentation.
|
||||
- Faut-il enregistrer un `ChunkEdit` `RESET` lors du `rechunk_document`, ou un `DELETE` par chunk + `INSERT` par nouveau chunk ? Décision: une seule entrée `RESET` (lisibilité audit), avec `parents = [old_ids]`, `children = [new_ids]`.
|
||||
- `get_tree`: lit-on `pages_json` ou `document_json` ? À confirmer en lisant `analysis_service.find_by_id` — probablement `document_json` qui contient la structure docling complète.
|
||||
|
||||
## 12. References
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue