docs: spec design chunker Phase 1 (AST-based pipeline)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
# Design — Markdown Chunker Phase 1
|
||||
|
||||
**Data:** 2026-06-08
|
||||
**Scope:** Riscrittura completa di `chunks/` seguendo il blueprint. Solo Fase 1 (core strutturale). Immagini/asset registry rinviati a Fase 2.
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Sostituire il parser regex attuale con una pipeline modulare basata su AST reale (`markdown-it-py`). Output: `chunks.json` con schema blueprint, `meta.json`, `report.json`.
|
||||
|
||||
---
|
||||
|
||||
## Struttura file
|
||||
|
||||
```
|
||||
chunks/
|
||||
models.py # dataclass: Block, Chunk, ChunkingResult
|
||||
config.py # ChunkerConfig dataclass con valori default
|
||||
parser.py # Markdown → token stream + source map
|
||||
segmenter.py # token stream → Section tree → Block[]
|
||||
packer.py # Block[] → Chunk[] (packing min/target/max)
|
||||
validator.py # Chunk[] → invarianti + diagnostics
|
||||
chunker.py # CLI + orchestrazione pipeline
|
||||
```
|
||||
|
||||
`verify_chunks.py` eliminato — validazione integrata in `validator.py`.
|
||||
|
||||
---
|
||||
|
||||
## Dipendenze
|
||||
|
||||
```
|
||||
markdown-it-py[linkify]>=3.0
|
||||
mdit-py-plugins>=0.4 # per math (dollarmath), footnotes
|
||||
```
|
||||
|
||||
Nessun tokenizer esterno (tiktoken non disponibile con Ollama). I limiti sono in caratteri; il campo `chars` nei modelli è il proxy per token.
|
||||
|
||||
---
|
||||
|
||||
## Modelli (`models.py`)
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Block:
|
||||
id: str # "blk_0001"
|
||||
kind: str # paragraph|heading|table|code|list|math|blockquote|html|thematic_break
|
||||
content: str # Markdown originale
|
||||
plain_text: str # testo pulito (senza sintassi MD) per embedding
|
||||
atomic: bool
|
||||
start_line: int
|
||||
end_line: int
|
||||
header_path: list[dict] # [{"level": 1, "text": "Titolo"}, ...]
|
||||
chars: int
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
chunk_id: str # "chk_000001"
|
||||
chunk_index: int
|
||||
content_original: str
|
||||
content_for_embedding: str # "H1 > H2 > H3\n\n" + content_original
|
||||
content_type: str # section_fragment | atomic_block | overflow
|
||||
chars: int
|
||||
start_line: int
|
||||
end_line: int
|
||||
header_path: list[dict]
|
||||
block_ids: list[str]
|
||||
flags: dict # has_code, has_table, has_math, is_overflow, is_sparse
|
||||
neighbors: dict # previous_chunk_id, next_chunk_id (popolato post-packing)
|
||||
assets: list # vuoto in Fase 1, pronto per Fase 2
|
||||
|
||||
@dataclass
|
||||
class ChunkingResult:
|
||||
stem: str
|
||||
source_path: str
|
||||
chunks: list[Chunk]
|
||||
diagnostics: Diagnostics
|
||||
|
||||
@dataclass
|
||||
class Diagnostics:
|
||||
errors: list[str]
|
||||
warnings: list[str]
|
||||
metrics: dict
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config (`config.py`)
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ChunkerConfig:
|
||||
# Dimensioni
|
||||
max_chars: int = 1200
|
||||
min_chars: int = 80
|
||||
target_chars: int = 800
|
||||
|
||||
# Heading context
|
||||
context_depth: int = 3 # 1-3 livelli nel prefisso embedding
|
||||
|
||||
# Sezioni da saltare
|
||||
skip_headings: set[str] = field(default_factory=lambda: {
|
||||
"indice", "sommario", "bibliografia", "ringraziamenti", "abbreviazioni"
|
||||
})
|
||||
skip_pre_heading: bool = True
|
||||
|
||||
# Merge
|
||||
merge_short: bool = True # fonde paragrafi < min_chars consecutivi
|
||||
|
||||
# Atomicità
|
||||
atomic_types: set[str] = field(default_factory=lambda: {
|
||||
"table", "code", "list", "math", "html"
|
||||
})
|
||||
|
||||
# Validazione
|
||||
fail_on_broken_fence: bool = True
|
||||
fail_on_content_loss: bool = False # warning invece di errore
|
||||
```
|
||||
|
||||
Default caricabili da `chunks/config.yaml` se presente, altrimenti hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Parser (`parser.py`)
|
||||
|
||||
**Responsabilità:** Markdown string → lista di token `markdown-it-py` con `map` (line ranges).
|
||||
|
||||
```python
|
||||
def parse(source: str) -> tuple[list[Token], list[str]]:
|
||||
"""Ritorna (tokens, lines) con source map popolata."""
|
||||
```
|
||||
|
||||
- Configura `markdown-it` con plugin: `table`, `dollarmath` (math opzionale), `front_matter`.
|
||||
- Ogni token ha `token.map = [start_line, end_line]`.
|
||||
- Restituisce anche le righe sorgente per ricostruzione testo esatto.
|
||||
|
||||
---
|
||||
|
||||
## Segmenter (`segmenter.py`)
|
||||
|
||||
**Responsabilità:** token stream → `Block[]` con heading stack e atomicità marcata.
|
||||
|
||||
```python
|
||||
def segment(tokens: list[Token], lines: list[str], config: ChunkerConfig) -> list[Block]:
|
||||
```
|
||||
|
||||
Algoritmo:
|
||||
1. Mantieni heading stack `[H1, H2, H3, H4, H5, H6]`.
|
||||
2. Per ogni token di apertura (`is_opening`):
|
||||
- `heading_open` → aggiorna stack, non emette Block.
|
||||
- `fence` / `code_block` → Block atomico `kind=code`.
|
||||
- `table_open` → consuma fino a `table_close`, Block atomico `kind=table`.
|
||||
- `bullet_list_open` / `ordered_list_open` → consuma fino a close, Block atomico `kind=list`.
|
||||
- `math_block` → Block atomico `kind=math`.
|
||||
- `html_block` → Block atomico `kind=html`.
|
||||
- `hr` (thematic break) → Block `kind=thematic_break`.
|
||||
- `paragraph_open` → Block `kind=paragraph`.
|
||||
- `blockquote_open` → Block `kind=blockquote`.
|
||||
3. Applica `skip_headings` e `skip_pre_heading`.
|
||||
4. Calcola `header_path` per ogni Block dallo stack corrente.
|
||||
5. `plain_text`: strip link syntax, immagini, codice inline, bold/italic.
|
||||
|
||||
---
|
||||
|
||||
## Packer (`packer.py`)
|
||||
|
||||
**Responsabilità:** `Block[]` → `Chunk[]` rispettando `min/target/max_chars`.
|
||||
|
||||
```python
|
||||
def pack(blocks: list[Block], config: ChunkerConfig, stem: str) -> list[Chunk]:
|
||||
```
|
||||
|
||||
Algoritmo:
|
||||
1. Raggruppa Block per `header_path` (cambio header_path = confine obbligatorio).
|
||||
2. All'interno del gruppo, packing greedy:
|
||||
- Accumula Block finché `chars < target_chars`.
|
||||
- Se aggiungere il prossimo Block supera `max_chars`:
|
||||
- Se il Block è atomico → flush chunk corrente, Block diventa chunk dedicato (marcato `atomic_block` o `overflow` se > max_chars).
|
||||
- Se il Block è `paragraph` → split a confine di frase (regex `(?<=[.!?»])\s+(?=[A-ZÀÈÉÌÒÙ\"])`); ogni frammento ≥ min_chars diventa sotto-Block.
|
||||
- Se il chunk corrente non raggiunge `min_chars` e c'è un Block successivo con stesso header_path → merge.
|
||||
3. Heading orfani (solo heading senza body): uniti al chunk successivo o marcati `is_sparse=true`.
|
||||
4. Popola `content_for_embedding = header_path_prefix + "\n\n" + content_original`.
|
||||
5. Popola `neighbors` in un secondo passaggio.
|
||||
|
||||
**header_path_prefix:** `"H1 > H2 > H3\n\n"` con `context_depth` livelli.
|
||||
|
||||
---
|
||||
|
||||
## Validator (`validator.py`)
|
||||
|
||||
**Responsabilità:** controlla invarianti, produce `Diagnostics`.
|
||||
|
||||
```python
|
||||
def validate(result: ChunkingResult, source: str, config: ChunkerConfig) -> Diagnostics:
|
||||
```
|
||||
|
||||
Invarianti controllati:
|
||||
- Nessun code fence aperto/chiuso male nel `content_original`.
|
||||
- Nessun chunk con solo heading (heading orfano).
|
||||
- Tutti i chunk rispettano `max_chars` (salvo `is_overflow=true`).
|
||||
- Copertura: le righe sorgente significative sono coperte da almeno un chunk (warning se non `fail_on_content_loss`).
|
||||
- Nessun `chunk_id` duplicato.
|
||||
|
||||
Metriche emesse:
|
||||
- `total_chunks`, `total_chars`, `avg_chars`, `min_chars_actual`, `max_chars_actual`
|
||||
- `overflow_count`, `sparse_count`, `atomic_count`
|
||||
- `size_compliance`: % chunk entro `[min_chars, max_chars]`
|
||||
|
||||
---
|
||||
|
||||
## CLI (`chunker.py`)
|
||||
|
||||
```
|
||||
python chunks/chunker.py --stem <stem> # singolo documento
|
||||
python chunks/chunker.py # tutti gli stem in sources/
|
||||
python chunks/chunker.py --stem <stem> --force # rigenera anche se presente
|
||||
```
|
||||
|
||||
Ricerca sorgente in ordine:
|
||||
1. `sources/<stem>_output/auto/<stem>.md`
|
||||
2. `sources/<stem>.md`
|
||||
|
||||
Output:
|
||||
```
|
||||
chunks/<stem>/chunks.json # lista Chunk serializzata
|
||||
chunks/<stem>/meta.json # stem, source_path, total_chunks, created_at, config snapshot
|
||||
chunks/<stem>/report.json # Diagnostics (errors, warnings, metrics)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output schema — `chunks.json` (array di oggetti)
|
||||
|
||||
```json
|
||||
{
|
||||
"chunk_id": "chk_000001",
|
||||
"chunk_index": 1,
|
||||
"content_original": "...",
|
||||
"content_for_embedding": "H1 > H2\n\nIl refresh token...",
|
||||
"content_type": "section_fragment",
|
||||
"chars": 742,
|
||||
"start_line": 12,
|
||||
"end_line": 31,
|
||||
"header_path": [
|
||||
{"level": 1, "text": "Titolo documento"},
|
||||
{"level": 2, "text": "Sezione"}
|
||||
],
|
||||
"block_ids": ["blk_0010", "blk_0011"],
|
||||
"flags": {
|
||||
"has_code": false,
|
||||
"has_table": false,
|
||||
"has_math": false,
|
||||
"is_overflow": false,
|
||||
"is_sparse": false
|
||||
},
|
||||
"neighbors": {
|
||||
"previous_chunk_id": null,
|
||||
"next_chunk_id": "chk_000002"
|
||||
},
|
||||
"assets": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cosa NON è in scope (Fase 2)
|
||||
|
||||
- Asset registry immagini (campo `assets` presente ma vuoto).
|
||||
- Fetch remoto.
|
||||
- Token counting con tiktoken.
|
||||
- Table splitting con header ripetuto (tabelle rimangono atomiche).
|
||||
- Tree-sitter per code splitting.
|
||||
- Metriche retrieval (Recall@k, MRR).
|
||||
Reference in New Issue
Block a user