13 Commits

Author SHA1 Message Date
davide 6f8785d90a docs(CLAUDE.md): semplifica istruzioni, rimuovi path step-X hardcoded 2026-04-20 11:05:20 +02:00
davide c8167d4f01 fix: aggiorna path step-4/ → conversione/ e riferimenti step-X
- chunker.py: input da conversione/<stem>/ (era step-4/, non esistente)
- verify_chunks.py: messaggi errore aggiornati a conversione/
- config.py: commenti step-8 → ingest.py
2026-04-19 00:03:43 +02:00
davide e4dc0856bb refactor: pulizia files 2026-04-17 18:52:37 +02:00
davide af9ffc0559 docs(README): riscrittura per struttura reale del progetto
Sostituisce la struttura step-0…step-10 con la pipeline
effettiva: conversione/, revisione /prepare-md, chunking,
verifica, ollama/, vettorizzazione, interrogazione
2026-04-17 18:51:15 +02:00
davide e02e3496a3 chore(requirements): rimuovi commenti step-X obsoleti 2026-04-17 18:50:53 +02:00
davide 12effa1a51 refactor: elimina step-7 e step-9, consolida script alla root
- step-9/: config.py, rag.py, retrieve.py → root;
  test_ollama.py → ollama/
- step-7/: eliminata, già coperta da ollama/
- sys.path aggiornati in rag.py, retrieve.py, ingest.py,
  check_env.py (step-7 e ollama)
- Riferimenti step-9/config.py → config.py in tutti i file
2026-04-17 18:50:36 +02:00
davide fc457e8525 feat(ollama): aggiungi step 7 — verifica ambiente Ollama
Script check_env.py e README per controllare prerequisiti prima
della vettorizzazione: ollama nel PATH, modelli embedding e LLM
disponibili, chromadb importabile
2026-04-17 18:16:40 +02:00
davide 610d4db348 feat(conversione): pipeline unificata PDF → Markdown, sostituisce step-0..4
Consolida in un unico modulo (conversione/pipeline.py) l'intera catena di
conversione che prima era distribuita tra step-0 (validazione PDF), step-1
(ispezione qualità), step-2 (conversione pymupdf4llm), step-3 (rilevamento
struttura) e step-4 (revisione euristica).

Miglioramenti principali rispetto agli step separati:
- libreria di conversione XY-Cut++ (ordine di lettura più preciso)
- 30+ trasformazioni euristiche vs le 7 originali di step-4
- validazione, struttura e profilo chunking prodotti in un solo passaggio
- validate.py con scoring orientato a chunking/vettorizzazione
2026-04-17 16:05:20 +02:00
davide 82f205faa2 chore: rimuovi cartelle step-0..step-4 ora obsolete
La logica è consolidata in conversione/pipeline.py.
2026-04-17 16:04:59 +02:00
davide 368530bc25 refactor(docs): skill prepare-md sostituisce step4-review, CLAUDE.md senza step-X 2026-04-17 13:44:41 +02:00
davide cdb2d4cab9 fix(conversione): PUA Symbol, garbage headers, merge+bib guard, math EN 2026-04-17 13:44:30 +02:00
davide ef8f56fdba fix(conversione): 5 fix robustezza e precisione transform
- _t_remove_footnotes: rimuove marcatori superscript inline e righe
  corpo-nota (¹ testo, [N] testo) — nuovo transform in posizione early
- _t_numbered_sections: esclude voci bibliografiche (anno, pp., vol.,
  DOI, ISBN) dalla promozione a ### header
- _t_remove_toc: intercetta voci con numero pagina finale nel contesto
  TOC — rimosso _t_remove_toc_page_list standalone
- _t_remove_frontmatter: limitata alle prime ~20% sezioni del documento
- _t_remove_recurring_lines: soglia 3->5, Counter spostato a top-level
2026-04-17 12:06:25 +02:00
davide 0a8d98279c feat(conversione): robustezza e 7 nuovi transform
- check_pdf: file < 1KB, campione esteso 15pp, MemoryError
- convert_pdf: validazione output ≥ 100 char
- analyze: rilevamento gerarchia invertita h3 > h2
- _detect_language: supporto FR/DE/ES
- 7 nuovi transform: fix_math_symbols, remove_recurring_lines,
  normalize_numbered_headings, remove_toc_page_list,
  restore_poetry_lines, demote_verse_headers, remove_watermarks
- bug fix: tabelle MD, garbage headers lowercase, empty headers
- run(): MemoryError / UnicodeDecodeError / PermissionError
2026-04-17 11:53:42 +02:00
24 changed files with 1175 additions and 2250 deletions
+199
View File
@@ -0,0 +1,199 @@
---
description: Legge un file Markdown, individua tutti i problemi che compromettono il chunking (artefatti, sillabazione, header malformati, paragrafi spezzati, gerarchia incoerente, sezioni vuote) e applica le correzioni direttamente sul file senza chiedere conferma per i casi chiari.
allowed-tools: Read Bash Grep Edit
argument-hint: <path/to/clean.md oppure stem>
---
Risolvi il percorso del file da preparare:
!`python3 -c "
import sys, json, re
from pathlib import Path
arg = '$ARGUMENTS'.strip()
root = Path('.')
candidates = [
Path(arg),
root / arg,
root / 'conversione' / arg / 'clean.md',
root / 'step-4' / arg / 'clean.md',
]
md_path = None
for p in candidates:
if p.exists() and p.suffix == '.md':
md_path = p
break
if not md_path:
print('ERRORE: file non trovato per:', arg)
sys.exit(1)
print('MD_PATH=' + str(md_path))
# Cerca profilo strutturale (report.json o structure_profile.json)
stem = md_path.parent.name
profile_candidates = [
md_path.parent / 'report.json',
md_path.parent / 'structure_profile.json',
root / 'step-4' / stem / 'structure_profile.json',
root / 'conversione' / stem / 'report.json',
]
for sp in profile_candidates:
if sp.exists():
try:
d = json.load(open(sp))
st = d.get('structure', d)
print(f'STRATEGIA={st.get(\"strategia_chunking\",\"?\")}')
print(f'LINGUA={st.get(\"lingua_rilevata\",\"?\")}')
print(f'H1={st.get(\"n_h1\",0)} H2={st.get(\"n_h2\",0)} H3={st.get(\"n_h3\",0)}')
for a in st.get('avvertenze', []):
print(f'AVVISO: {a}')
except Exception:
pass
break
# Statistiche file
text = md_path.read_text(encoding='utf-8')
lines = text.split('\n')
pua = len(re.findall(r'[\ue000-\uf8ff]', text))
print(f'RIGHE={len(lines)} CHARS={len(text)}')
if pua:
print(f'PUA_RESIDUI={pua}')
" 2>/dev/null`
Se l'output contiene `ERRORE`, comunica il percorso non trovato e fermati.
---
Leggi il file completo identificato da `MD_PATH` nell'output sopra. Poi esegui **tutti** i controlli e applica le correzioni nell'ordine indicato.
I parametri di riferimento per il chunking sono: **MIN_CHARS=200, MAX_CHARS=800**.
---
## Controllo 1 — Sillabazione residua
Cerca blocchi di testo (non header) dove una riga termina con `-` e la successiva inizia con lettera minuscola: è un'interruzione di parola non risolta da PDF.
Esempio da correggere:
```
...il meccanismo di decen-
tralizzazione permette...
```
`...il meccanismo di decentralizzazione permette...`
**Applica** ogni fusione con Edit. Se la parola ricomposta sembra errata, segnala invece di correggere.
---
## Controllo 2 — Artefatti di pagina
Righe standalone che sono esclusivamente:
- Un numero intero isolato (numero di pagina)
- Titolo del libro / nome autore che si ripete identico 3+ volte nel documento
- Intestazioni di capitolo che si ripetono (es. `## 3. Termodinamica` appare sia come header legittimo che come riga di testo duplicata)
**Applica** la rimozione con Edit per le ripetizioni chiaramente decorative. Segnala i casi ambigui.
---
## Controllo 3 — Numeri di pagina in header
Header che terminano con ` | N` o ` N` dove N è un numero isolato (residuo di indice non rimosso):
- `### 16. Link vari | 109``### 16. Link vari`
- `## Capitolo 3 42``## Capitolo 3`
**Applica** con Edit.
---
## Controllo 4 — Header malformati
Per ogni header (`#`, `##`, `###`):
**a) ALL-CAPS non convertito:**
`## TERMODINAMICA DEI PROCESSI``## Termodinamica dei processi`
Usa sentence case (prima lettera maiuscola, resto minuscolo salvo nomi propri evidenti).
**Applica**.
**b) Livello h4/h5/h6:**
`#### Sottosezione``### Sottosezione`
**Applica**.
**c) Testo troppo lungo (> 120 char):**
Probabilmente non è un header ma testo estratto erroneamente. Rimuovi i `#` iniziali lasciando il testo come paragrafo normale.
**Applica** se chiaramente non è un titolo. Segnala se ambiguo.
**d) Header duplicati:**
Se lo stesso header appare due volte, rimuovi la seconda occorrenza (o la prima se è quella fuori contesto).
**Applica**.
---
## Controllo 5 — Paragrafi spezzati
Blocchi di testo (non header, non liste) che terminano senza punteggiatura finale (`.?!»)`).
Se il blocco successivo non inizia con lettera maiuscola e non è un header/lista, i due blocchi sono parte della stessa frase spezzata da un salto pagina PDF.
**Applica** la fusione solo quando sei certo (la congiunzione è evidente: inizia con congiunzione, continua la frase in modo inequivocabile). Segnala i casi dubbi invece di correggere.
---
## Controllo 6 — Sezioni quasi-vuote o vuote
Sezione (header + corpo) con corpo < 100 caratteri:
- Se il contenuto è evidentemente una sottosezione o introduzione di ciò che segue (e non ha senso da solo), rimuovi l'header e unisci il testo alla sezione precedente o successiva.
- Se è un header di capitolo che introduce legittime sottosezioni (`##` seguito da `###`), lascia invariato.
**Applica** le fusioni sicure. Segnala quelle ambigue.
---
## Controllo 7 — Gerarchia heading
Verifica che la gerarchia sia coerente. Problemi da correggere:
- Più di un `# ` (h1) nel documento → il secondo e successivi diventano `## ` salvo che siano chiaramente titoli di parti distinte
- `### ` prima del primo `## ` → abbassa il `###` a `## ` o aggiungi un `## ` genitore appropriato
- `## ` prima del primo `# ` in documenti con h1 → lascia invariato (alcuni documenti non hanno h1)
**Applica** solo le correzioni di livello sicure. Segnala le ristrutturazioni che richiedono giudizio.
---
## Controllo 8 — Sezioni troppo lunghe senza struttura
Sezione (## o ###) con corpo > 3000 caratteri e nessun header figlio al suo interno: il chunker la spezzerà su frasi in modo meccanico, perdendo coerenza semantica.
Se il testo contiene chiari cambio-argomento (paragrafi separati da riga vuota, con transizioni come "Inoltre...", "In secondo luogo...", "Un altro aspetto..."), considera di aggiungere un `### ` per suddividere semanticamente.
**Non aggiungere header inventati.** Segnala le sezioni candidate e proponi i titoli: applica solo su risposta affermativa.
---
## Report finale
Dopo aver applicato tutte le correzioni automatiche, mostra:
```
File: <path>
Correzioni applicate: N totali
Sillabazione risolta: N
Artefatti pagina rimossi: N
Numeri pagina in header: N
Header normalizzati: N (ALL-CAPS, livello, lunghezza, duplicati)
Paragrafi fusi: N
Sezioni quasi-vuote risolte:N
Gerarchia corretta: N
Problemi aperti (richiedono giudizio manuale):
[riga N] <descrizione precisa>
...
```
Se non ci sono problemi aperti: **"Markdown pronto per il chunking."**
Se ci sono problemi aperti: elencali e chiedi quali applicare.
-115
View File
@@ -1,115 +0,0 @@
---
description: Revisione qualitativa del clean.md dopo il pre-processing automatico (step 4). Trova artefatti residui, paragrafi spezzati e header errati, poi propone le correzioni.
allowed-tools: Read Bash Grep Edit
argument-hint: <stem>
---
Esegui la revisione qualitativa di `step-4/$ARGUMENTS/clean.md`.
**Cosa è già stato fatto automaticamente (revision_log):**
!`grep -A 12 "^## $ARGUMENTS" step-4/revision_log.md 2>/dev/null || echo "(nessun log trovato per questo stem)"`
**Profilo strutturale attuale:**
!`python3 -c "
import json, sys
try:
d = json.load(open('step-4/$ARGUMENTS/structure_profile.json'))
print(f'Livello: {d[\"livello_struttura\"]} Strategia: {d[\"strategia_chunking\"]}')
print(f'h1={d[\"n_h1\"]} h2={d[\"n_h2\"]} h3={d[\"n_h3\"]} paragrafi={d[\"n_paragrafi\"]}')
print(f'Lunghezza media sezione: {d[\"lunghezza_media_sezione\"]} char')
for a in d.get('avvertenze', []): print(f' ⚠️ {a}')
except Exception as e: print(f'ERRORE: {e}')
" 2>/dev/null`
---
Analizza `step-4/$ARGUMENTS/clean.md` eseguendo i grep seguenti e ragionando sui risultati. Per ogni check: esegui il grep, conta i risultati, riporta i casi concreti (max 5 esempi con numero di riga).
## Check 1 — Sillabazione residua
Righe che terminano con trattino seguito da testo nella riga successiva (artefatto PDF non risolto):
```bash
grep -n "\-$" step-4/$ARGUMENTS/clean.md | head -20
```
Segnala se presenti: numero di riga, testo della riga e della riga successiva.
## Check 2 — Righe orfane (artefatti PDF)
Righe standalone (non header `#`, non vuote) di meno di 60 caratteri che sembrano artefatti:
```bash
grep -n "^[^#\-\*\|].\{1,59\}$" step-4/$ARGUMENTS/clean.md | grep -v "^\s*$" | head -30
```
Valuta ogni riga: è testo normale breve (legittimo) o artefatto (numero di pagina, nome autore isolato, riga di intestazione ripetuta)?
## Check 3 — Paragrafi con frase spezzata
Blocchi di testo che terminano senza punteggiatura di fine frase (`.?!»)`):
```bash
grep -n "[^.!?»)\]\'\"]$" step-4/$ARGUMENTS/clean.md | grep -v "^[0-9]*:#" | grep -v "^[0-9]*:\s*$" | grep -v "^\s*[-\*]" | head -20
```
Riporta i casi più sospetti (righe brevi che finiscono a metà concetto).
## Check 4 — Header sospetti
```bash
grep -n "^##\? " step-4/$ARGUMENTS/clean.md | head -40
```
Verifica:
- `##` o `###` con contenuto interamente MAIUSCOLO non convertito → segnala
- Header duplicati (stesso testo che appare due volte) → segnala
- `##` con testo > 80 caratteri (probabile testo che non è un header) → segnala
- Salti di livello anomali (es. `###` senza un `##` padre) → segnala
## Check 5 — Sezioni quasi vuote
```bash
python3 -c "
import re, sys
text = open('step-4/$ARGUMENTS/clean.md').read()
sections = re.split(r'^(#{1,3} .+)$', text, flags=re.MULTILINE)
for i in range(1, len(sections)-1, 2):
header = sections[i].strip()
body = sections[i+1].strip() if i+1 < len(sections) else ''
if len(body) < 80 and body:
print(f'{header!r} → {len(body)} char: {body[:60]!r}')
elif not body:
print(f'{header!r} → VUOTA')
" 2>/dev/null | head -20
```
Sezioni con body < 80 char o vuote compromettono il chunking. Segnala quelle che non hanno senso come sezione autonoma.
## Check 6 — Gerarchia strutturale
```bash
grep -n "^#\{1,3\} " step-4/$ARGUMENTS/clean.md | head -50
```
Verifica che la gerarchia sia coerente: `# → ## → ###`. Segnala se ci sono `###` prima del primo `##`, o `##` prima del primo `#`, o `#` multipli (più di un h1).
---
## Report finale
```
🔴 BLOCCANTI (compromettono il chunking o il retrieval)
[riga N] descrizione precisa del problema
...
🟡 MINORI (artefatti visibili, non bloccanti)
[riga N] descrizione
...
🟢 OK — nessun problema rilevato in questa categoria
```
Poi chiedi: **"Applico le correzioni per i 🔴? E per i 🟡?"**
Applica solo ciò che viene esplicitamente approvato. Usa Edit per ogni modifica — mai riscrivere l'intero file.
+20 -60
View File
@@ -3,84 +3,44 @@
## Regole invarianti ## Regole invarianti
- **Lingua:** Rispondi sempre in italiano. - **Lingua:** Rispondi sempre in italiano.
- **Venv obbligatorio:** Usa `.venv/bin/python` o attiva con `source .venv/bin/activate`. Mai `pip`/`python` di sistema. - **Venv:** Usa `.venv/bin/python` o `source .venv/bin/activate`. Mai `pip`/`python` di sistema.
- **Non modificare `raw.md`:** `step-2/<stem>/raw.md` è immutabile. La copia di lavoro è `step-4/<stem>/clean.md`. - **`raw.md` immutabile:** La copia di lavoro è sempre `clean.md`.
--- ---
## Pipeline (ordine obbligatorio) ## Pipeline
``` ```
PDF (sources/) → step-0 → step-1 → step-2 → step-3 PDF → conversione → chunking → verifica → vettorizzazione → retrieval
→ step-4 (CRITICO: revisione manuale clean.md)
→ step-5 → step-6 → step-7 (Ollama) → step-8 → step-9
``` ```
Il parametro `--stem` identifica il documento (nome PDF senza `.pdf`). Lo stem è anche il nome della collection ChromaDB. `--stem` = nome PDF senza estensione = nome collection ChromaDB.
Comandi tipici: Per i path degli script e degli output usa `git ls-files` o esplora la root: la struttura è in evoluzione verso un programma unico.
```bash
source .venv/bin/activate
python step-4/revise.py --stem <stem>
python step-5/chunker.py --stem <stem>
python step-6/verify_chunks.py --stem <stem>
python step-8/ingest.py --stem <stem>
python step-9/rag.py --stem <stem>
```
--- ---
## File critici ## Configurazione
| File | Ruolo | `config.py` è la fonte di verità: `EMBED_MODEL`, `OLLAMA_MODEL`, `TOP_K`, `TEMPERATURE`, `SYSTEM_PROMPT`.
|---|---|
| `step-9/config.py` | Fonte di verità: `EMBED_MODEL`, `OLLAMA_MODEL`, `TOP_K`, `TEMPERATURE`, `SYSTEM_PROMPT` | **Se cambi `EMBED_MODEL`:** riesegui ingest con `--force` — embedding incoerenti non producono errori ma risposte insensate.
| `step-5/chunker.py` | Chunking adattivo — `MIN_CHARS=200`, `MAX_CHARS=800`, `OVERLAP_S=2` |
| `step-6/verify_chunks.py` | Verifica chunk — stesse soglie di `chunker.py` | **Se cambi `MIN_CHARS` / `MAX_CHARS`:** cerca tutte le occorrenze nel repo e sincronizza.
| `step-6/fix_chunks.py` | Fix automatici su chunk anomali |
| `step-4/revise.py` | Pre-processing MD automatico (8 trasformazioni euristiche) |
| `step-8/ingest.py` | Vettorizzazione ChromaDB — legge `EMBED_MODEL` da `config.py` |
| `step-9/rag.py` | Pipeline RAG interattiva |
--- ---
## Regole di assistenza ## Workflow consigliato
**Modifica `EMBED_MODEL` in `step-9/config.py`:** 1. Converti il PDF con lo script di conversione
Avvisa sempre che serve rieseguire la vettorizzazione: 2. `/prepare-md conversione/<stem>/clean.md`
```bash 3. Chunking
python step-8/ingest.py --stem <stem> --force 4. Vettorizza con `--stem <stem>`
``` 6. `python rag.py --stem <stem>`
`ingest.py` importa `EMBED_MODEL` direttamente da `config.py` — la coerenza è critica: se violata non produce errori ma restituisce risultati insensati.
**Modifica soglie chunking (`MIN_CHARS`, `MAX_CHARS`, `OVERLAP_S`):**
I valori compaiono in tre file che vanno sincronizzati manualmente:
1. `step-5/chunker.py`
2. `step-6/verify_chunks.py`
3. `step-6/fix_chunks.py`
**Step 4 — revisione clean.md:**
`revise.py` applica trasformazioni automatiche, ma il risultato va sempre revisionato a mano. La qualità del RAG dipende da `clean.md` più di qualsiasi parametro tecnico. Suggerisci sempre `/step4-review <stem>` dopo `revise.py`.
**Step 6 — verifica chunk:**
Dopo `verify_chunks.py`, usa `/step6-fix <stem>` prima di passare a step-8.
--- ---
## Skills custom ## Skills custom
- `/step4-review <stem>` — Revisione qualitativa `clean.md`: artefatti, paragrafi spezzati, header errati. - `/prepare-md <path|stem>` — corregge `clean.md`: sillabazione, artefatti, header, paragrafi spezzati, gerarchia.
- `/step6-fix <stem>`Dry-run e applicazione fix chunk tramite `fix_chunks.py`. - `/step6-fix <stem>`verifica chunk, dry-run e applicazione fix via `fix_chunks.py`.
---
## Struttura directory per stem
```
step-2/<stem>/raw.md ← immutabile
step-4/<stem>/clean.md ← copia di lavoro
step-4/<stem>/structure_profile.json
step-5/<stem>/chunks.json
step-6/<stem>/report.json
chroma_db/<stem>/ ← collection ChromaDB
```
+201 -518
View File
@@ -3,7 +3,7 @@
Sistema RAG (Retrieval-Augmented Generation) costruito da zero, senza framework di alto livello. Sistema RAG (Retrieval-Augmented Generation) costruito da zero, senza framework di alto livello.
Funziona su qualsiasi PDF digitale. Gira interamente in locale, senza GPU, senza cloud. Funziona su qualsiasi PDF digitale. Gira interamente in locale, senza GPU, senza cloud.
**Stack:** Python · Ollama · nomic-embed-text · Qwen3.5 · ChromaDB **Stack:** Python · Ollama · ChromaDB · Qwen3-embedding · Qwen3.5
**Compatibile con:** Linux · macOS · Windows (WSL2) · CPU Only · ~8 GB RAM libera **Compatibile con:** Linux · macOS · Windows (WSL2) · CPU Only · ~8 GB RAM libera
--- ---
@@ -12,18 +12,14 @@ Funziona su qualsiasi PDF digitale. Gira interamente in locale, senza GPU, senza
- [Panoramica](#panoramica) - [Panoramica](#panoramica)
- [Struttura del progetto](#struttura-del-progetto) - [Struttura del progetto](#struttura-del-progetto)
- [Gli step](#gli-step) - [Pipeline](#pipeline)
- [Step 0 — Scegli il PDF](#step-0--scegli-il-pdf) - [Conversione](#conversione)
- [Step 1 — Ispezione automatica](#step-1--ispezione-automatica) - [Revisione Markdown](#revisione-markdown)
- [Step 2 — Conversione in Markdown grezzo](#step-2--conversione-in-markdown-grezzo) - [Chunking](#chunking)
- [Step 3 — Rilevamento struttura](#step-3--rilevamento-struttura) - [Verifica chunk](#verifica-chunk)
- [Step 4 — Revisione manuale](#step-4--revisione-manuale) - [Ambiente Ollama](#ambiente-ollama)
- [Step 5 — Chunking adattivo](#step-5--chunking-adattivo) - [Vettorizzazione](#vettorizzazione)
- [Step 6 — Verifica chunk](#step-6--verifica-chunk) - [Interrogazione](#interrogazione)
- [Step 7 — Installazione ambiente](#step-7--installazione-ambiente)
- [Step 8 — Vettorizzazione](#step-8--vettorizzazione)
- [Step 9 — Pipeline RAG](#step-9--pipeline-rag)
- [Step 10 — Test automatici](#step-10--test-automatici)
- [Principi di progettazione](#principi-di-progettazione) - [Principi di progettazione](#principi-di-progettazione)
--- ---
@@ -31,36 +27,35 @@ Funziona su qualsiasi PDF digitale. Gira interamente in locale, senza GPU, senza
## Panoramica ## Panoramica
``` ```
PDF PDF (sources/)
└─► STEP 1 Ispezione automatica
└─► STEP 2 Conversione in Markdown grezzo ▼ conversione/pipeline.py
└─► STEP 3 Rilevamento struttura clean.md ← revisiona con /prepare-md
└─► STEP 4 Revisione manuale ← step più importante
└─► STEP 5 Chunking adattivo ▼ step-5/chunker.py
└─► STEP 6 Verifica chunk chunks.json
└─► STEP 8 Vettorizzazione
└─► STEP 9 Pipeline RAG ▼ step-6/verify_chunks.py + fix_chunks.py
└─► STEP 10 Test automatici chunks.json verificato
STEP 0 Prerequisito iniziale (PDF adatto) ▼ step-8/ingest.py
STEP 7 Prerequisito tecnico (ambiente locale) ChromaDB
▼ rag.py
risposta
``` ```
### Dove si concentra il rischio ### Dove si concentra il rischio
| Step | Rischio | Motivo | | Fase | Rischio | Motivo |
|---|---|---| |---|---|---|
| Step 0 | 🔴 Alto | Un PDF inadatto invalida tutto il lavoro successivo | | Conversione | 🟡 Medio | Automatica, ma il PDF deve essere digitale e non protetto |
| Step 1 | 🟢 Basso | Automatico, solo osservazione | | Revisione Markdown | 🔴 Alto | Manuale — la qualità del MD determina la qualità del RAG |
| Step 2 | 🟢 Basso | Automatico, tool maturo | | Chunking | 🟡 Medio | Logica adattiva, dipende dalla qualità del MD |
| Step 3 | 🟢 Basso | Automatico, solo analisi | | Verifica chunk | 🟢 Basso | Automatica, solo verifica |
| Step 4 | 🔴 Alto | Manuale — la qualità del MD determina la qualità del RAG | | Ambiente Ollama | 🟢 Basso | Installazione standard |
| Step 5 | 🟡 Medio | Logica adattiva, dipende dalla qualità del MD | | Vettorizzazione | 🟢 Basso | Meccanica, lenta ma affidabile |
| Step 6 | 🟢 Basso | Automatico, solo verifica | | Interrogazione | 🟡 Medio | Qualità del prompt e dei parametri in `config.py` |
| Step 7 | 🟢 Basso | Installazione standard |
| Step 8 | 🟢 Basso | Meccanico, lento ma affidabile |
| Step 9 | 🟡 Medio | Qualità del prompt |
| Step 10 | 🟢 Basso | Test automatici |
--- ---
@@ -72,54 +67,38 @@ rag-from-scratch/
├── sources/ # PDF originali — non modificare mai ├── sources/ # PDF originali — non modificare mai
│ └── documento.pdf │ └── documento.pdf
├── step-0/ ├── conversione/ # PDF → Markdown strutturato
── check_pdf.py # Verifica requisiti del PDF ── pipeline.py # Conversione PDF → clean.md
├── validate.py # Validazione batch di tutti gli stem
├── step-1/
│ └── inspect_pdf.py # Ispezione automatica del PDF
├── step-2/
│ ├── convert_pdf.py # Conversione PDF → Markdown grezzo
│ └── <stem>/ │ └── <stem>/
── raw.md # MD grezzo (non toccare) ── raw.md # MD grezzo (non toccare)
│ ├── clean.md # MD pulito — copia di lavoro
│ └── report.json # Metriche qualità conversione
├── step-3/ ├── step-5/ # Chunking adattivo
│ ├── detect_structure.py # Rilevamento struttura MD │ ├── chunker.py
│ └── <stem>/ │ └── <stem>/
│ └── structure_profile.json # Profilo struttura │ └── chunks.json
├── step-4/ ├── step-6/ # Verifica e fix chunk
│ ├── revise.py # Pre-processing automatico MD │ ├── verify_chunks.py
│ ├── revision_log.md # Log modifiche manuali │ ├── fix_chunks.py
│ └── <stem>/
│ ├── clean.md # MD revisionato
│ └── structure_profile.json # Profilo aggiornato
├── step-5/
│ ├── chunker.py # Chunking adattivo
│ └── <stem>/
│ └── chunks.json # Chunk pronti per la vettorizzazione
├── step-6/
│ ├── verify_chunks.py # Verifica chunk
│ ├── fix_chunks.py # Fix chunk problematici
│ └── <stem>/ │ └── <stem>/
│ └── chunks.json # Chunk verificati │ └── chunks.json # Chunk verificati
├── step-7/ ├── step-8/ # Vettorizzazione → ChromaDB
│ ├── check_env.py # Verifica ambiente locale │ ├── ingest.py
│ └── README.md # Guida installazione Ollama e dipendenze
├── step-8/
│ └── ingest.py # Vettorizzazione → ChromaDB
├── step-9/
│ ├── config.py # Configurazione pipeline RAG ← modifica qui
│ ├── rag.py # Pipeline RAG interattiva
│ ├── test_ollama.py # Test chat Ollama senza RAG
│ └── README.md │ └── README.md
├── chroma_db/ # Vector store — generato da step-8 ├── ollama/ # Ambiente Ollama
│ ├── check_env.py # Verifica prerequisiti
│ ├── test_ollama.py # Test chat senza RAG
│ └── README.md
├── chroma_db/ # Vector store — generato da ingest.py
├── config.py # Configurazione pipeline RAG ← modifica qui
├── rag.py # Pipeline RAG interattiva
├── retrieve.py # Retrieval puro (senza LLM)
├── requirements.txt ├── requirements.txt
├── .gitignore ├── .gitignore
└── README.md └── README.md
@@ -127,305 +106,67 @@ rag-from-scratch/
--- ---
## Gli step ## Pipeline
--- ---
### Step 0 — Scegli il PDF ### Conversione
**Tipo:** prerequisito manuale
**Input:** nessuno
**Output:** un PDF adatto al sistema
Il PDF deve soddisfare requisiti minimi prima di qualsiasi elaborazione.
Un PDF inadatto rende tutto il lavoro successivo inutile.
**Criteri obbligatori:**
- Il testo è selezionabile nel PDF reader — se non riesci a copiare una parola,
pdfplumber non la leggerà
- Non è protetto da password
- È generato digitalmente, non scansionato — una foto di un libro non è un PDF di testo
- Il contenuto importante è nel testo, non nelle immagini
**Criteri desiderabili:**
- Ha una struttura logica riconoscibile: capitoli, sezioni, paragrafi
- Le sezioni hanno titoli espliciti
- Non ha layout a colonne multiple
- È in una lingua sola o prevalentemente una
**Come verificarlo:**
Apri il PDF nel tuo reader, seleziona del testo da pagine diverse e copialo.
Se il testo copiato è leggibile e nell'ordine giusto, il PDF è adatto.
Se ottieni caratteri strani o testo nell'ordine sbagliato, il PDF ha problemi.
---
### Step 1 — Ispezione automatica
**Tipo:** automatico **Tipo:** automatico
**Input:** tutti i PDF in `sources/` **Input:** `sources/<stem>.pdf`
**Output:** `step-1/<stem>_step1_report.txt` **Output:** `conversione/<stem>/clean.md` + `report.json`
**Script:** `step-1/inspect_pdf.py` **Script:** `conversione/pipeline.py`
```bash ```bash
python step-1/inspect_pdf.py # Singolo documento
python conversione/pipeline.py --stem <nome>
# Tutti i PDF in sources/
python conversione/pipeline.py
# Forza riesecuzione (sovrascrive output esistente)
python conversione/pipeline.py --stem <nome> --force
``` ```
Lo script scansiona automaticamente tutti i PDF in `sources/`, analizza ogni documento pagina per pagina e produce un report. Converte il PDF in Markdown strutturato in quattro fasi automatiche: validazione, estrazione testo (algoritmo XY-Cut++ per layout multi-colonna), pulizia strutturale e analisi della struttura del documento.
Serve per capire la qualità del documento e mappare i problemi
prima di affrontare la revisione manuale.
**Cosa rileva:** Produce tre file in `conversione/<stem>/`:
- Testo non estraibile (pagine con sole immagini) | File | Descrizione |
- Sillabazioni a fine riga
- Layout a colonne (righe molto corte e numerose)
- Intestazioni e piè di pagina ripetitivi
- Caratteri Unicode anomali
- Pagine vuote
**Output del report:**
```
Score: 87/100
Pagine totali: 243
Pagine con problemi: 12
Pagina 14: sillabazione rilevata (3 occorrenze)
Pagina 67: possibile layout a colonne
Pagina 201: caratteri Unicode anomali
PROSSIMI PASSI:
→ conversione con marker funzionerà bene
→ attenzione alle pagine 14 e 67 nella revisione manuale
```
**Decisione:**
| Score | Azione |
|---|---| |---|---|
| ≥ 70 | Procedi allo step 2 | | `raw.md` | Markdown grezzo estratto dal PDF — **non modificare mai** |
| 4070 | Procedi con cautela, revisione estesa necessaria | | `clean.md` | Markdown pulito e strutturato — input per il chunker |
| < 40 | Valuta una fonte PDF migliore | | `report.json` | Metriche qualità, anomalie, strategia di chunking suggerita |
--- **Requisiti aggiuntivi:** Java 11+ nel PATH (`opendataloader-pdf` lo richiede).
### Step 2 — Conversione in Markdown grezzo **Validazione batch:**
**Tipo:** automatico
**Input:** tutti i PDF in `sources/` (o uno solo con `--pdf`)
**Output:** `step-2/<stem>/raw.md` + `step-2/<stem>/clean.md`
**Script:** `step-2/convert_pdf.py`
```bash ```bash
python step-2/convert_pdf.py # tutti i PDF in sources/ python conversione/validate.py
python step-2/convert_pdf.py --pdf sources/doc.pdf # un solo PDF
``` ```
Converte il PDF in Markdown usando `pymupdf4llm`. Il risultato non è perfetto — è la base Mostra una tabella di stato per tutti gli stem convertiti. Vedi [`conversione/README.md`](conversione/README.md) per dettagli completi.
su cui lavorerai nello step 4.
Lo script crea due file: **PDF supportati:** digitali con testo selezionabile. Non supportati: scansionati (solo immagini) e protetti da password.
- `raw.md` — conversione grezza, **non modificare mai**. È il punto di partenza di riferimento.
- `clean.md` — copia di lavoro che verrà modificata negli step successivi.
**Cosa produce la conversione:**
- Titoli riconosciuti e convertiti in `#` `##` `###`
- Paragrafi separati da righe vuote
- Sillabazione parzialmente risolta
**Cosa non produce:**
- Rimozione intestazioni e piè di pagina
- Correzione completa del layout a colonne
- Descrizione del contenuto delle immagini
--- ---
### Step 3 — Rilevamento struttura ### Revisione Markdown
**Tipo:** automatico **Tipo:** semi-automatico
**Input:** `step-2/<stem>/` **Input:** `conversione/<stem>/clean.md`
**Output:** `step-3/<stem>/structure_profile.json` **Output:** `conversione/<stem>/clean.md` corretto in-place
**Script:** `step-3/detect_structure.py`
```bash > Questo è il passaggio più importante dell'intera pipeline.
python step-3/detect_structure.py # tutti i documenti in step-2/ > La qualità del RAG dipende da questo passaggio più di qualsiasi
python step-3/detect_structure.py --stem <nome> # un solo documento
python step-3/detect_structure.py --force # riesegui anche se già presente
```
Copia `raw.md` e `clean.md` da `step-2/<stem>/` e analizza la struttura del Markdown senza modificarla.
Il profilo prodotto guida sia la revisione manuale che il chunker.
**I quattro livelli strutturali:**
```
Livello 3 — struttura ricca
Il documento ha ### con regolarità.
Ogni ### è un'unità semantica chiara.
Esempi: opere filosofiche, manuali tecnici, leggi.
Strategia chunking: boundary su ###
Livello 2 — struttura parziale
Il documento ha ## ma pochi o nessun ###.
Le sezioni sono i capitoli, non le sottosezioni.
Esempi: articoli scientifici, report, saggi.
Strategia chunking: boundary su ##, split interno su paragrafi
Livello 1 — solo paragrafi
Il documento non ha titoli significativi.
La struttura è data dalle righe vuote.
Esempi: testi narrativi, lettere, trascrizioni.
Strategia chunking: boundary su paragrafo
Livello 0 — testo piatto
Un blocco continuo senza struttura riconoscibile.
Esempi: PDF mal convertiti, testi antichi.
Strategia chunking: sliding window su frasi
```
**Profilo prodotto:**
```json
{
"livello_struttura": 3,
"n_h1": 1,
"n_h2": 9,
"n_h3": 296,
"n_paragrafi": 312,
"boundary_primario": "h3",
"lingua_rilevata": "it",
"lunghezza_media_sezione": 420,
"strategia_chunking": "h3_aware",
"avvertenze": [
"14 sezioni sotto i 200 caratteri — verranno accorpate",
"8 sezioni sopra i 800 caratteri — verranno divise"
]
}
```
---
### Step 4 — Revisione manuale
**Tipo:** manuale (con pre-processing automatico)
**Input:** `step-3/<stem>/clean.md` + `step-3/<stem>/structure_profile.json`
**Output:** `step-4/<stem>/clean.md` — MD revisionato
**Script:** `step-4/revise.py`
> Questo è lo step più importante dell'intera pipeline.
> La qualità del RAG dipende da questo step più di qualsiasi
> parametro tecnico o scelta di modello. > parametro tecnico o scelta di modello.
#### Pre-processing automatico ```
/prepare-md conversione/<stem>/clean.md
Prima di qualsiasi revisione manuale, esegui lo script di revisione automatica:
```bash
python step-4/revise.py --stem documento
``` ```
Lo script applica le seguenti trasformazioni euristiche, valide per qualsiasi documento: La skill analizza il `clean.md` e corregge automaticamente i problemi che compromettono il chunking: sillabazione, artefatti, header malformati, paragrafi spezzati, gerarchia incoerente, sezioni vuote.
| Trasformazione | Descrizione |
|---|---|
| Rimozione TOC | Righe che iniziano con `INDICE`, `INDEX`, `CONTENTS`, ecc. |
| ALL-CAPS → `##` | Righe standalone in maiuscolo convertite in header section-case |
| `N. testo``### N.` | Sezioni numerate (con 1+ spazio dopo il punto) convertite in h3 |
| Unione paragrafi | Blocchi spezzati da salti pagina PDF uniti automaticamente |
| Whitespace | Spazi multipli normalizzati, righe vuote ridotte |
Il profilo strutturale aggiornato viene salvato in `step-4/<stem>/structure_profile.json`.
#### Revisione assistita da Claude Code
Dopo il pre-processing, usa la skill integrata per una revisione qualitativa:
```
/step4-review documento
```
La skill analizza `step-4/<stem>/clean.md` e produce un report strutturato:
```
🔴 BLOCCANTI — problemi che compromettono il chunking
🟡 MINORI — artefatti visibili ma non bloccanti
🟢 OK — categorie senza problemi
```
Poi propone le correzioni e le applica solo su tua approvazione.
#### Revisione manuale (senza Claude Code)
Se non usi Claude Code, esegui questi 6 check dal terminale.
In tutti i comandi sostituisci `<stem>` con il nome reale del documento.
**Check 1 — Sillabazione residua**
Parole spezzate a fine riga con trattino (artefatto da PDF non risolto):
```bash
grep -n "\-$" step-4/<stem>/clean.md | head -20
```
Se trovi risultati: unisci la riga con la successiva eliminando il trattino
e il ritorno a capo.
**Check 2 — Righe orfane**
Righe brevi (<60 char) isolate che sembrano numeri di pagina, autori, intestazioni:
```bash
grep -n "^[^#\-\*\|].\{1,59\}$" step-4/<stem>/clean.md | grep -v "^\s*$" | head -30
```
Per ogni riga: valuta se è testo legittimo (frase breve) o artefatto
(numero di pagina, nome autore ripetuto, intestazione PDF). Gli artefatti vanno eliminati.
**Check 3 — Frasi spezzate**
Paragrafi che terminano senza punteggiatura di fine frase:
```bash
grep -n "[^.!?»)\]\'\"]$" step-4/<stem>/clean.md \
| grep -v "^[0-9]*:#" \
| grep -v "^[0-9]*:\s*$" \
| grep -v "^\s*[-\*]" \
| head -20
```
Segnala le righe brevi che finiscono a metà concetto. Uniscile alla riga successiva.
**Check 4 — Header sospetti**
```bash
grep -n "^##\? " step-4/<stem>/clean.md | head -40
```
Verifica:
- Header con testo >80 caratteri → probabilmente è testo normale, non un header
- Header in MAIUSCOLO non convertito → cambia in formato sentence-case
- Header duplicati (stesso testo due volte) → valuta se unire o rinominare
- `###` senza un `##` padre → salto di gerarchia anomalo
**Check 5 — Sezioni quasi vuote**
```bash
python3 -c "
import re
text = open('step-4/<stem>/clean.md').read()
sections = re.split(r'^(#{1,3} .+)$', text, flags=re.MULTILINE)
for i in range(1, len(sections)-1, 2):
header = sections[i].strip()
body = sections[i+1].strip() if i+1 < len(sections) else ''
if not body:
print(f'VUOTA: {header!r}')
elif len(body) < 80:
print(f'CORTA ({len(body)} char): {header!r} → {body[:60]!r}')
"
```
Le sezioni vuote generano chunk inutili. Eliminale o accorpale alla sezione precedente.
**Check 6 — Gerarchia strutturale**
```bash
grep -n "^#\{1,3\} " step-4/<stem>/clean.md | head -50
```
Deve esserci un solo `# h1` all'inizio. Poi `## h2` e opzionalmente `### h3`.
Segnala `###` prima del primo `##`, o più di un `#`.
---
**Struttura target dopo la revisione:** **Struttura target dopo la revisione:**
@@ -441,36 +182,31 @@ Ogni paragrafo è semanticamente autonomo.
Una riga vuota separa le sezioni. Una riga vuota separa le sezioni.
``` ```
**Criterio di qualità:** **Criterio di qualità:** leggi ogni sezione ad alta voce. Se suona naturale è corretta. Se si interrompe c'è una riga spezzata. Se suona ripetitiva c'è un artefatto.
Leggi ogni sezione ad alta voce. Se suona naturale è corretta.
Se si interrompe c'è una riga spezzata. Se suona ripetitiva c'è un artefatto.
--- ---
### Step 5 — Chunking adattivo ### Chunking
**Tipo:** automatico **Tipo:** automatico
**Input:** `step-4/<stem>/clean.md` + `step-4/<stem>/structure_profile.json` **Input:** `conversione/<stem>/clean.md`
**Output:** `step-5/<stem>/chunks.json` **Output:** `step-5/<stem>/chunks.json`
**Script:** `step-5/chunker.py` **Script:** `step-5/chunker.py`
```bash ```bash
python step-5/chunker.py --stem documento python step-5/chunker.py --stem <stem>
``` ```
Divide il Markdown pulito in chunk. Usa il profilo strutturale Divide il Markdown pulito in chunk. Usa il profilo strutturale da `report.json` per scegliere la strategia giusta. Non sa nulla del contenuto — si basa solo sulla struttura.
per scegliere la strategia giusta. Non sa nulla del contenuto —
si basa solo sulla struttura.
**Regole invarianti per qualsiasi documento:** **Regole invarianti per qualsiasi documento:**
- Un chunk non attraversa mai il confine tra due sezioni diverse - Un chunk non attraversa mai il confine tra due sezioni diverse
- Un chunk non spezza mai una frase a metà - Un chunk non spezza mai una frase a metà
- Ogni chunk porta il suo contesto nel prefisso - Ogni chunk porta il suo contesto nel prefisso
- L'overlap tra chunk avviene solo su frasi intere, - L'overlap tra chunk avviene solo su frasi intere, mai tra sezioni diverse
mai tra sezioni diverse
**Parametri:** **Parametri (in `step-5/chunker.py`):**
| Parametro | Default | Significato | | Parametro | Default | Significato |
|---|---|---| |---|---|---|
@@ -491,95 +227,54 @@ si basa solo sulla struttura.
} }
``` ```
Il prefisso `[Sezione > Titolo]` è fondamentale: permette all'embedding Il prefisso `[Sezione > Titolo]` è fondamentale: permette all'embedding di catturare il contesto topico del chunk anche quando il testo da solo sarebbe ambiguo.
di catturare il contesto topico del chunk anche quando il testo
da solo sarebbe ambiguo.
--- ---
### Step 6 — Verifica e fix chunk ### Verifica chunk
**Tipo:** automatico **Tipo:** automatico
**Input:** `step-5/<stem>/chunks.json` **Input:** `step-5/<stem>/chunks.json`
**Output:** `step-6/<stem>/chunks.json` verificato + `report.json` **Output:** `step-6/<stem>/chunks.json` verificato + `report.json`
**Script:** `step-6/verify_chunks.py`, `step-6/fix_chunks.py` **Script:** `step-6/verify_chunks.py`, `step-6/fix_chunks.py`
Questo step si articola in un ciclo: verifica → fix automatico → ri-verifica. Non si va allo step 8 finché non ci sono 🔴. Questo passaggio si articola in un ciclo: verifica → fix automatico → ri-verifica. Non si procede alla vettorizzazione finché non ci sono 🔴.
**Workflow completo:** **Workflow:**
``` ```
1. Verifica 1. Verifica
python step-6/verify_chunks.py --stem documento python step-6/verify_chunks.py --stem <stem>
2a. Se ✅ OK o solo 🟡 → vai allo step 8 2a. Se ✅ OK o solo 🟡 → vai alla vettorizzazione
2b. Se ci sono 🔴 → prova il fix automatico: 2b. Se ci sono 🔴 → prova il fix automatico:
python step-6/fix_chunks.py --stem documento --dry-run # anteprima python step-6/fix_chunks.py --stem <stem> --dry-run # anteprima
python step-6/fix_chunks.py --stem documento # applica python step-6/fix_chunks.py --stem <stem> # applica
3. Ri-verifica dopo il fix: 3. Ri-verifica dopo il fix:
python step-6/verify_chunks.py --stem documento python step-6/verify_chunks.py --stem <stem>
4. Se rimangono 🔴 → torna allo step 4 e correggi clean.md, 4. Se rimangono 🔴 → torna alla revisione Markdown e correggi clean.md,
poi riesegui dall'inizio: poi riesegui dall'inizio:
python step-5/chunker.py --stem documento --force python step-5/chunker.py --stem <stem> --force
python step-6/verify_chunks.py --stem documento python step-6/verify_chunks.py --stem <stem>
``` ```
> **Shortcut con Claude:** usa `/step6-fix <stem>` — esegue dry-run, spiega le operazioni, chiede conferma e ri-verifica automaticamente. > **Shortcut con Claude:** usa `/step6-fix <stem>` — esegue dry-run, spiega le operazioni, chiede conferma e ri-verifica automaticamente.
#### Senza Claude Code — come leggere l'output e decidere **Output di `verify_chunks.py` — tre condizioni finali:**
**1. Leggi l'output di `verify_chunks.py`**
L'output termina con una delle tre condizioni:
| Condizione | Significato | Cosa fare | | Condizione | Significato | Cosa fare |
|---|---|---| |---|---|---|
| `✅ N/N documenti senza problemi` | Nessun problema | Vai allo step 8 | | `✅ N/N documenti senza problemi` | Nessun problema | Procedi |
| `🟡 Solo avvisi minori` | Chunk corti o lunghi, non bloccanti | Puoi andare allo step 8 oppure ottimizzare con `fix_chunks.py` | | `🟡 Solo avvisi minori` | Chunk corti o lunghi, non bloccanti | Puoi procedere o ottimizzare con `fix_chunks.py` |
| `⚠️ 0/N documenti senza problemi` + 🔴 | Frasi spezzate o chunk vuoti | Esegui `fix_chunks.py`, poi ri-verifica | | `⚠️ 0/N documenti senza problemi` + 🔴 | Frasi spezzate o chunk vuoti | Esegui `fix_chunks.py`, poi ri-verifica |
**2. Prima di applicare il fix: leggi il dry-run** **Cosa verifica:**
```bash - Nessun chunk sotto `MIN_CHARS` 🟡
python step-6/fix_chunks.py --stem <stem> --dry-run - Nessun chunk sopra `MAX_CHARS × 1.5` 🟡
```
L'output elenca le operazioni pianificate. Significato:
| Operazione | Cosa fa | Sicurezza |
|---|---|---|
| `fondi N chunk incompleti` | Unisce il chunk troncato col successivo | Sempre sicura |
| `fondi N chunk troppo corti` | Unisce chunk <200 char col successivo | Sicura se il risultato non supera MAX×1.5 |
| `spezza N chunk troppo lunghi` | Divide chunk >1200 char su frasi | Sicura solo se esistono frasi naturali dove spezzare |
| `rimuovi N chunk vuoti` | Elimina chunk senza testo | Sempre sicura |
**3. Se i 🔴 persistono dopo il fix**
`fix_chunks.py` non riesce ad autocorreggersi quando il problema
è nella struttura del testo sorgente. I casi tipici e la soluzione in `clean.md`:
| Sintomo nel report | Causa in `clean.md` | Correzione |
|---|---|---|
| Chunk finisce con `:` | Intro di un elenco separata dall'elenco da una riga vuota | Rimuovi la riga vuota tra l'intro e la lista |
| Chunk finisce a metà parola | Salto di pagina PDF con numero di pagina nel mezzo | Trova e rimuovi il numero di pagina, unisci le righe |
| Chunk con testo artefatto (URL, watermark) | Artefatto non rimosso allo step 4 | Elimina la sezione in `clean.md` |
| Chunk con frase enorme non spezzabile | Singolo paragrafo >MAX_CHARS senza frasi intermedie | Spezza manualmente il paragrafo su frasi logiche |
Dopo ogni correzione in `clean.md` riesegui dall'inizio dello step 5:
```bash
python step-5/chunker.py --stem <stem> --force
rm -f step-6/<stem>/chunks.json # forza la rilettura da step-5
python step-6/verify_chunks.py --stem <stem>
```
**Cosa verifica `verify_chunks.py`:**
- Nessun chunk è sotto `MIN_CHARS` 🟡
- Nessun chunk è sopra `MAX_CHARS × 1.5` 🟡
- Ogni chunk finisce con punteggiatura di fine frase 🔴 - Ogni chunk finisce con punteggiatura di fine frase 🔴
**Cosa corregge `fix_chunks.py`:** **Cosa corregge `fix_chunks.py`:**
@@ -591,52 +286,39 @@ python step-6/verify_chunks.py --stem <stem>
| Fondi chunk troppo corto col successivo | Chunk sotto `MIN_CHARS` | | Fondi chunk troppo corto col successivo | Chunk sotto `MIN_CHARS` |
| Spezza chunk troppo lungo | Chunk sopra `MAX_CHARS × 1.5` | | Spezza chunk troppo lungo | Chunk sopra `MAX_CHARS × 1.5` |
**Tabella diagnosi problemi non risolvibili con fix_chunks:** **Se i 🔴 persistono dopo il fix** — i casi tipici e la soluzione in `clean.md`:
| Sintomo | Causa probabile | Soluzione | | Sintomo nel report | Causa in `clean.md` | Correzione |
|---|---|---| |---|---|---|
| Molti chunk corti dopo il fix | `MIN_CHARS` troppo alto o testo frammentato nel MD | Abbassa `MIN_CHARS` o correggi step 4 | | Chunk finisce con `:` | Intro di un elenco separata dall'elenco da una riga vuota | Rimuovi la riga vuota tra l'intro e la lista |
| Chunk spezzato creato dal fix stesso | Frase singola > `MAX_CHARS` non spezzabile | Spezza manualmente il paragrafo in step 4 | | Chunk finisce a metà parola | Numero di pagina nel mezzo del testo | Trova e rimuovi il numero di pagina, unisci le righe |
| Chunk che finisce a metà frase non risolvibile | Salto di pagina PDF non sanato nel MD | Correggi la riga spezzata in `clean.md` | | Chunk con testo artefatto | Artefatto non rimosso nella revisione | Elimina la sezione in `clean.md` |
| Chunk con frase enorme non spezzabile | Paragrafo >MAX_CHARS senza frasi intermedie | Spezza manualmente il paragrafo |
**Output se tutto ok:**
```
Totale chunk: 301
✅ OK: 301
Distribuzione lunghezze:
Min: 187 char
Max: 923 char
Media: 401 char
✅ 1/1 documenti senza problemi
```
--- ---
### Step 7 — Installazione ambiente ### Ambiente Ollama
**Tipo:** manuale (una volta sola) **Tipo:** manuale (una volta sola)
**Input:** nessuno **Input:** nessuno
**Output:** ambiente locale funzionante **Output:** ambiente locale funzionante
**Script:** `step-7/check_env.py` **Script:** `ollama/check_env.py`
Installa Ollama, scarica i modelli e verifica l'ambiente. Si esegue una volta sola. Installa Ollama, scarica i modelli e verifica l'ambiente. Si esegue una volta sola prima della vettorizzazione.
Vedi [`step-7/README.md`](step-7/README.md) per istruzioni dettagliate e scelta dei modelli. Vedi [`ollama/README.md`](ollama/README.md) per istruzioni dettagliate e scelta dei modelli.
```bash ```bash
python step-7/check_env.py python ollama/check_env.py
``` ```
--- ---
### Step 8 — Vettorizzazione ### Vettorizzazione
**Tipo:** automatico (lento) **Tipo:** automatico (lento)
**Input:** `step-6/<stem>/chunks.json` **Input:** `step-6/<stem>/chunks.json`
**Output:** `chroma_db/` popolato **Output:** `chroma_db/<stem>` popolato
**Script:** `step-8/ingest.py` **Script:** `step-8/ingest.py`
```bash ```bash
@@ -653,125 +335,126 @@ Per 900 chunk aspetta circa 15 minuti.
| Argomento | Descrizione | | Argomento | Descrizione |
|---|---| |---|---|
| `--stem <nome>` | Processa un singolo documento. Senza questo argomento processa tutti gli stem trovati in `step-6/` | | `--stem <nome>` | Processa un singolo documento. Senza questo argomento processa tutti gli stem trovati in `step-6/` |
| `--force` | Cancella e ricrea la collection se esiste già. Senza `--force`, se la collection è presente lo step viene saltato | | `--force` | Cancella e ricrea la collection se esiste già |
**Quando usare `--force`:** **Quando usare `--force`:**
Se hai modificato i chunk (es. hai rieseguito step-6 dopo correzioni), la collection in ChromaDB Se hai modificato i chunk o cambiato `EMBED_MODEL` in `config.py`, la collection in ChromaDB contiene i vecchi vettori. `--force` la cancella e ricrea da zero.
contiene ancora i vecchi vettori. `--force` la cancella e la ricrea da zero con i chunk aggiornati.
**Cosa succede per ogni chunk:** **Cosa succede per ogni chunk:**
``` ```
testo del chunk testo del chunk
▼ Ollama (nomic-embed-text) ▼ Ollama (EMBED_MODEL)
vettore di 768 numeri vettore N-dim
[0.23, -0.41, 0.87, 0.12, ...]
▼ ChromaDB ▼ ChromaDB
salva: testo + vettore + metadati (sezione, titolo, sub_index) salva: testo + vettore + metadati (sezione, titolo, sub_index)
``` ```
**Perché 768 numeri:** Vedi [`step-8/README.md`](step-8/README.md) per la scelta del modello di embedding e le regole di coerenza con la fase di interrogazione.
Ogni numero rappresenta una dimensione semantica.
Testi con significato simile producono vettori simili —
i loro numeri sono vicini nello spazio a 768 dimensioni.
Questo è ciò che permette il retrieval semantico.
**Output durante l'esecuzione:**
```
✅ Ollama OK — nomic-embed-text disponibile
📦 872 chunk da ingestire
[ 1/872] ✓ sezione_1__sotto_1__s0 ETA: 870s
[ 2/872] ✓ sezione_1__sotto_2__s0 ETA: 867s
...
[872/872] ✓ sezione_9__sotto_42__s0 ETA: 0s
✅ Ingestione completata in 718s — 872/872 chunk salvati
Collection 'nietzsche' in chroma_db/
```
`chroma_db/` contiene ora tutti i vettori su disco.
Non è necessario ripetere questo step a meno che il documento cambi.
--- ---
### Step 9 — Pipeline RAG ### Interrogazione
**Tipo:** interattivo **Tipo:** interattivo
**Input:** `chroma_db/` + domanda dell'utente **Input:** `chroma_db/<stem>` + domanda dell'utente
**Output:** risposta basata sul documento **Output:** risposta basata sul documento
**Script:** `step-9/rag.py`
Due modalità:
| Script | Modalità | Quando usarlo |
|---|---|---|
| `rag.py` | Retrieval + generazione LLM | Risposta in linguaggio naturale |
| `retrieve.py` | Solo retrieval (no LLM) | Debug, verifica chunk, ricerca semantica |
#### rag.py — Risposta in linguaggio naturale
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
python step-9/rag.py --stem <nome> python rag.py --stem <nome>
``` ```
Loop interattivo che risponde a domande sul documento. Configura i parametri in `step-9/config.py` prima di avviare. | Sintassi | Comportamento |
|---|---|
| `<testo>` | Risposta basata sul documento |
| `<testo> -v` | Risposta + chunk recuperati con score di similarità |
| `exit` | Esce dal programma |
Vedi [`step-9/README.md`](step-9/README.md) per la configurazione completa. Flusso interno:
--- ```
domanda
▼ embed (EMBED_MODEL, Ollama)
vettore N-dim
▼ query ChromaDB — similarità coseno, top-K
chunk rilevanti
▼ build_prompt (SYSTEM_PROMPT + contesti + domanda)
▼ generate (OLLAMA_MODEL, Ollama)
risposta
```
### Step 10 — Test automatici #### retrieve.py — Retrieval puro (senza LLM)
**Tipo:** automatico
**Input:** sistema completo
**Output:** tutti i test verdi
**Script:** `step-10/test_pipeline.py` *(da implementare)*
```bash ```bash
python step-10/test_pipeline.py --stem <nome> source .venv/bin/activate
python retrieve.py --stem <nome>
``` ```
Verifica ogni componente in isolamento e poi nel sistema completo. Vettorizza la query e restituisce i chunk più simili con score di similarità — senza chiamare Ollama per la generation. Utile per verificare la qualità del retrieval e diagnosticare risposte sbagliate.
I test non dipendono dal contenuto del documento — usano dati
fittizi creati e distrutti in memoria.
**Struttura dei test:** | Sintassi | Comportamento |
|---|---|
| `<testo>` | Chunk più simili con score (testo troncato a 200 car.) |
| `<testo> -f` | Chunk più simili con testo completo |
| `exit` | Esce dal programma |
Accetta `--top-k N` per sovrascrivere il valore di `config.py` per quella sessione.
#### Configurazione (`config.py`)
| Parametro | Default | Descrizione |
|---|---|---|
| `TOP_K` | `6` | Chunk recuperati per ogni domanda. Valori consigliati: `3``10` |
| `TEMPERATURE` | `0.0` | Deterministico a `0.0`, creativo verso `1.0`. Per RAG consigliato `0.0` |
| `NO_THINK` | `True` | Disabilita il chain-of-thought interno dei modelli Qwen3/Qwen3.5. `True` = risposta diretta, più veloce |
| `EMBED_MODEL` | `"nomic-embed-text"` | Deve corrispondere al modello usato in `ingest.py`. Se cambiato, rieseguire con `--force` |
| `OLLAMA_URL` | `"http://localhost:11434"` | Modifica solo se Ollama gira su porta o host diversi |
| `OLLAMA_MODEL` | `"qwen3.5:0.8b"` | Modello LLM. Vedi [`ollama/README.md`](ollama/README.md) per la scelta |
| `SYSTEM_PROMPT` | *(vedi file)* | Istruzioni di comportamento inviate al LLM. Modifica per cambiare tono, lingua o condizione di fallback |
#### Test senza RAG
Per verificare che Ollama risponda correttamente prima di interrogare il documento:
```bash
python ollama/test_ollama.py
``` ```
Test unitari — ogni componente isolato
✓ split_sentences non spezza le frasi
✓ parse_markdown rileva la struttura corretta
✓ chunk_sezione rispetta i boundary
✓ il prefisso è sempre presente in ogni chunk
Test integrazione — i componenti parlano tra loro Chat diretta con il modello, senza ChromaDB. Usa gli stessi parametri di `config.py`.
✓ Ollama è raggiungibile
✓ i modelli sono disponibili
✓ l'embedding produce 768 dimensioni
✓ testi diversi producono vettori diversi
✓ ChromaDB scrive e legge correttamente
Test qualità — il sistema si comporta bene
✓ il retrieval trova il chunk pertinente
✓ il retrieval non trova il chunk non pertinente
✓ il LLM usa il contesto fornito
✓ il LLM ammette quando la risposta non è nel contesto
```
--- ---
## Principi di progettazione ## Principi di progettazione
**Atomico** **Atomico**
Ogni step fa una cosa sola. Il chunker non sa niente di Ollama. Ogni fase fa una cosa sola. Il chunker non sa niente di Ollama.
L'ingestione non sa niente del MD originale. L'ingestione non sa niente del MD originale.
Se un pezzo si rompe, sai esattamente dove. Se un pezzo si rompe, sai esattamente dove.
**Verificabile** **Verificabile**
Ogni step ha un criterio di completamento oggettivo. Ogni fase ha un criterio di completamento oggettivo.
Non si passa allo step successivo finché il precedente non è verificato. Non si passa alla fase successiva finché la precedente non è verificata.
**Reversibile** **Reversibile**
Puoi tornare indietro senza perdere il lavoro degli altri step. Puoi tornare indietro senza perdere il lavoro delle altre fasi.
Cambi il MD? Riesegui solo step 5 e 8. Cambi il MD? Riesegui solo chunking e vettorizzazione.
Cambi i parametri del chunker? Riesegui solo step 5 e 8. Cambi i parametri del chunker? Riesegui solo chunking e vettorizzazione.
Non si riparte mai da zero. Non si riparte mai da zero.
**Senza assunzioni** **Senza assunzioni**
+4 -4
View File
@@ -1,9 +1,9 @@
# ─── Step 9 — Configurazione RAG ───────────────────────────────────────────── # ─── Configurazione RAG ───────────────────────────────────────────────────────
# #
# Modifica questo file per cambiare i parametri della pipeline. # Modifica questo file per cambiare i parametri della pipeline.
# #
# Uso: # Uso:
# python step-9/rag.py --stem nietzsche # python rag.py --stem nietzsche
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# ── Retrieval ───────────────────────────────────────────────────────────────── # ── Retrieval ─────────────────────────────────────────────────────────────────
@@ -28,8 +28,8 @@ NO_THINK = True
# ── Embedding ───────────────────────────────────────────────────────────────── # ── Embedding ─────────────────────────────────────────────────────────────────
# Modello di embedding usato da Ollama. # Modello di embedding usato da Ollama.
# Deve corrispondere al modello usato durante la vettorizzazione (step-8). # Deve corrispondere al modello usato durante la vettorizzazione (ingest.py).
# Se cambi questo, devi rieseguire step-8 con --force. # Se cambi questo, devi rieseguire ingest.py con --force.
EMBED_MODEL = "nomic-embed-text" EMBED_MODEL = "nomic-embed-text"
# ── Ollama ──────────────────────────────────────────────────────────────────── # ── Ollama ────────────────────────────────────────────────────────────────────
+506 -35
View File
@@ -30,6 +30,7 @@ import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from collections import Counter
from datetime import datetime from datetime import datetime
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
@@ -69,8 +70,11 @@ def check_pdf(pdf_path: Path) -> tuple[bool, str]:
return False, f"File non trovato: {pdf_path}" return False, f"File non trovato: {pdf_path}"
if pdf_path.suffix.lower() != ".pdf": if pdf_path.suffix.lower() != ".pdf":
return False, f"Non è un PDF: {pdf_path.name}" return False, f"Non è un PDF: {pdf_path.name}"
if pdf_path.stat().st_size == 0: size = pdf_path.stat().st_size
if size == 0:
return False, "File vuoto" return False, "File vuoto"
if size < 1024:
return False, f"File troppo piccolo ({size} byte) — probabilmente corrotto"
try: try:
import pdfplumber import pdfplumber
@@ -84,11 +88,26 @@ def check_pdf(pdf_path: Path) -> tuple[bool, str]:
if len((pdf.pages[i].extract_text() or "").strip()) > 50 if len((pdf.pages[i].extract_text() or "").strip()) > 50
) )
if pages_with_text == 0: if pages_with_text == 0:
# Estende il campione: copertine immagine o pagine bianche iniziali
extended = min(15, n_pages)
if extended > sample:
ext_with_text = sum(
1 for i in range(sample, extended)
if len((pdf.pages[i].extract_text() or "").strip()) > 50
)
if ext_with_text > 0:
return True, (
f"{n_pages} pagine — prime {sample} vuote, "
f"testo trovato in pagine successive "
f"(possibile copertina immagine)"
)
return False, ( return False, (
f"Nessun testo nelle prime {sample} pagine " f"Nessun testo nelle prime {extended} pagine "
f"— probabilmente scansionato (usa modalità hybrid)" f"— probabilmente scansionato (OCR non supportato)"
) )
return True, f"{n_pages} pagine, testo digitale confermato" return True, f"{n_pages} pagine, testo digitale confermato"
except MemoryError:
return False, "Memoria esaurita durante l'apertura del PDF"
except Exception as e: except Exception as e:
msg = str(e).lower() msg = str(e).lower()
if "password" in msg or "encrypted" in msg: if "password" in msg or "encrypted" in msg:
@@ -131,6 +150,13 @@ def convert_pdf(pdf_path: Path, out_dir: Path) -> Path:
raise RuntimeError(f"Nessun file .md prodotto in {out_dir}") raise RuntimeError(f"Nessun file .md prodotto in {out_dir}")
md_file = candidates[0] md_file = candidates[0]
content = md_file.read_text(encoding="utf-8", errors="replace").strip()
if len(content) < 100:
raise RuntimeError(
f"opendataloader ha prodotto un file .md quasi vuoto ({len(content)} char) "
f"— il PDF potrebbe essere corrotto o non supportato"
)
return md_file return md_file
@@ -139,6 +165,9 @@ def convert_pdf(pdf_path: Path, out_dir: Path) -> Path:
_TOC_KEYWORDS = frozenset([ _TOC_KEYWORDS = frozenset([
"indice", "index", "contents", "table of contents", "indice", "index", "contents", "table of contents",
"sommario", "inhaltsverzeichnis", "inhalt", "sommario", "inhaltsverzeichnis", "inhalt",
"indice generale", "indice analitico", "indice dei contenuti",
"elenco dei capitoli", "argomenti", "table des matières",
"tabla de contenidos", "содержание",
]) ])
_ORDINALS_IT = { _ORDINALS_IT = {
@@ -166,6 +195,7 @@ def _is_allcaps_line(line: str) -> bool:
len(letters) >= 3 len(letters) >= 3
and all(c.isupper() for c in letters) and all(c.isupper() for c in letters)
and not stripped.startswith("#") and not stripped.startswith("#")
and not stripped.startswith("|") # esclude righe tabella Markdown
) )
@@ -208,8 +238,9 @@ def _extract_math_environments(text: str) -> tuple[str, int]:
Deve girare PRIMA del merge paragrafi (step 5) per sfruttare i blocchi intatti. Deve girare PRIMA del merge paragrafi (step 5) per sfruttare i blocchi intatti.
""" """
_ENVS = ( _ENVS = (
r"Definizione|Teorema|Lemma|Proposizione|" r"Definizione|Definition|Teorema|Theorem|Lemma|"
r"Corollario|Osservazione|Nota|Esempio" r"Proposizione|Proposition|Corollario|Corollary|"
r"Osservazione|Remark|Nota|Note|Esempio|Example"
) )
count = 0 count = 0
blocks = text.split("\n\n") blocks = text.split("\n\n")
@@ -343,12 +374,158 @@ def _extract_article_headers(text: str) -> tuple[str, int]:
# ─── [3a] Funzioni di trasformazione ───────────────────────────────────────── # ─── [3a] Funzioni di trasformazione ─────────────────────────────────────────
# Mapping PUA Unicode (U+F020-U+F0FF) → simboli corretti per font Symbol/Wingdings.
# Il font Symbol di Windows codifica lettere greche e operatori matematici nel
# range Private Use Area invece dei codepoint Unicode standard.
_SYMBOL_PUA_MAP: dict[str, str] = {
"\uf020": " ", # space
"\uf028": "(",
"\uf029": ")",
"\uf02b": "+",
"\uf02d": "\u2212", # minus
"\uf02e": ".",
"\uf02f": "/",
"\uf030": "0", "\uf031": "1", "\uf032": "2", "\uf033": "3", "\uf034": "4",
"\uf035": "5", "\uf036": "6", "\uf037": "7", "\uf038": "8", "\uf039": "9",
"\uf03a": ":", "\uf03b": ";", "\uf03c": "<", "\uf03d": "=", "\uf03e": ">",
"\uf040": "\u2245", # congruent
"\uf041": "\u0391", # Alpha
"\uf042": "\u0392", # Beta
"\uf043": "\u03a7", # Chi
"\uf044": "\u0394", # Delta
"\uf045": "\u0395", # Epsilon
"\uf046": "\u03a6", # Phi
"\uf047": "\u0393", # Gamma
"\uf048": "\u0397", # Eta
"\uf049": "\u0399", # Iota
"\uf04a": "\u03d1", # theta variant
"\uf04b": "\u039a", # Kappa
"\uf04c": "\u039b", # Lambda
"\uf04d": "\u039c", # Mu
"\uf04e": "\u039d", # Nu
"\uf04f": "\u039f", # Omicron
"\uf050": "\u03a0", # Pi
"\uf051": "\u0398", # Theta
"\uf052": "\u03a1", # Rho
"\uf053": "\u03a3", # Sigma
"\uf054": "\u03a4", # Tau
"\uf055": "\u03a5", # Upsilon
"\uf056": "\u03c2", # sigma final
"\uf057": "\u03a9", # Omega
"\uf058": "\u039e", # Xi
"\uf059": "\u03a8", # Psi
"\uf05a": "\u0396", # Zeta
"\uf05b": "[",
"\uf05c": "\u2234", # therefore
"\uf05d": "]",
"\uf05e": "\u22a5", # perpendicular
"\uf061": "\u03b1", # alpha
"\uf062": "\u03b2", # beta
"\uf063": "\u03c7", # chi
"\uf064": "\u03b4", # delta
"\uf065": "\u03b5", # epsilon
"\uf066": "\u03c6", # phi
"\uf067": "\u03b3", # gamma
"\uf068": "\u03b7", # eta
"\uf069": "\u03b9", # iota
"\uf06a": "\u03d5", # phi variant
"\uf06b": "\u03ba", # kappa
"\uf06c": "\u03bb", # lambda
"\uf06d": "\u03bc", # mu
"\uf06e": "\u03bd", # nu
"\uf06f": "\u03bf", # omicron
"\uf070": "\u03c0", # pi
"\uf071": "\u03b8", # theta
"\uf072": "\u03c1", # rho
"\uf073": "\u03c3", # sigma
"\uf074": "\u03c4", # tau
"\uf075": "\u03c5", # upsilon
"\uf076": "\u03d6", # pi symbol
"\uf077": "\u03c9", # omega
"\uf078": "\u03be", # xi
"\uf079": "\u03c8", # psi
"\uf07a": "\u03b6", # zeta
"\uf07b": "{",
"\uf07c": "|",
"\uf07d": "}",
"\uf07e": "~",
"\uf0b1": "\u00b1", # plus-minus
"\uf0b7": "\u2022", # bullet
"\uf0ba": "\u221a", # square root
"\uf0bc": "\u2264", # less or equal
"\uf0bd": "\u2265", # greater or equal
"\uf0be": "\u221d", # proportional
"\uf0d7": "\u00d7", # multiplication
"\uf0f7": "\u00f7", # division
"\uf0b4": "\u00d7", # alternate multiply
"\uf0bb": "\u2260", # not equal
"\uf0b9": "\u2260", # not equal alternate
"\uf0b3": "\u2265", # greater or equal alternate
"\uf0b2": "\u2032", # prime
"\uf02a": "*",
"\uf02c": ",",
"\uf0a3": "\u2264", # less or equal (Symbol 0xA3)
"\uf0a7": "\u2022", # bullet (Wingdings 0xA7)
"\uf0a8": "\u2022", # bullet variant
"\uf0ae": "\u2192", # right arrow (Symbol 0xAE)
"\uf0b8": "\u00f7", # division / range separator
"\uf0eb": "", # Wingdings decorative icon (rimosso)
"\uf0f0": "\u2192", # right arrow variant
"\uf0db": "", # bracket extension piece (non ricostruibile)
"\uf0dc": "", # bracket extension piece
"\uf0dd": "", # bracket extension piece
"\uf0de": "", # brace middle piece (non ricostruibile)
"\uf0df": "", # brace extension piece
}
_SYMBOL_PUA_RE = re.compile(
"[" + "".join(re.escape(k) for k in _SYMBOL_PUA_MAP) + "]"
)
def _t_fix_symbol_font(text: str) -> tuple[str, int]:
"""Rimappa caratteri PUA font Symbol (U+F020-U+F0FF) in simboli Unicode corretti."""
count = [0]
def _repl(m: re.Match) -> str:
count[0] += 1
return _SYMBOL_PUA_MAP[m.group(0)]
result = _SYMBOL_PUA_RE.sub(_repl, text)
return result, count[0]
def _t_remove_images(text: str) -> tuple[str, int]: def _t_remove_images(text: str) -> tuple[str, int]:
n = len(re.findall(r"!\[[^\]]*\]\([^)]*\)", text)) n = len(re.findall(r"!\[[^\]]*\]\([^)]*\)", text))
text = re.sub(r"!\[[^\]]*\]\([^)]*\)\s*", "", text) text = re.sub(r"!\[[^\]]*\]\([^)]*\)\s*", "", text)
return text, n return text, n
# Superscript Unicode: ¹²³⁴⁵⁶⁷⁸⁹⁰
_SUPERSCRIPT_RE = re.compile(r'[\u00b9\u00b2\u00b3\u2070\u2074-\u2079]+')
# Riga corpo-nota: inizia con superscript o [N]
_FOOTNOTE_BODY_RE = re.compile(
r'^([\u00b9\u00b2\u00b3\u2070\u2074-\u2079]+\s+|\[\d{1,3}\]\s+)'
)
def _t_remove_footnotes(text: str) -> tuple[str, int]:
"""Rimuovi marcatori footnote superscript inline e righe corpo-nota."""
lines = text.split("\n")
result, count = [], 0
for line in lines:
stripped = line.strip()
# Corpo nota: riga breve che inizia con ¹ o [N]
if stripped and _FOOTNOTE_BODY_RE.match(stripped) and len(stripped) < 300:
count += 1
continue
cleaned = _SUPERSCRIPT_RE.sub("", line)
if cleaned != line:
count += 1
result.append(cleaned)
return "\n".join(result), count
def _t_fix_br(text: str) -> tuple[str, int]: def _t_fix_br(text: str) -> tuple[str, int]:
n = len(re.findall(r"<br>", text, re.IGNORECASE)) n = len(re.findall(r"<br>", text, re.IGNORECASE))
text = re.sub(r"<br>\s*", " ", text, flags=re.IGNORECASE) text = re.sub(r"<br>\s*", " ", text, flags=re.IGNORECASE)
@@ -457,8 +634,50 @@ def _t_extract_capitolo(text: str) -> tuple[str, int]:
return text, 0 return text, 0
_NUMBERED_HDR_RE = re.compile(
r"^(#{1,6})\s+(\d+(?:\.\d+)*)\.\s+(.+)$",
re.MULTILINE,
)
def _t_normalize_numbered_headings(text: str) -> tuple[str, int]:
"""Corregge livelli header per documenti con numerazione decimale.
Assegna livello heading in base alla profondità numerica usando come base
il livello corrente degli header di profondità minima.
Attivo solo se il documento ha almeno 2 profondità di numerazione.
"""
all_matches = list(_NUMBERED_HDR_RE.finditer(text))
if not all_matches:
return text, 0
pairs = [
(m.group(2).count(".") + 1, len(m.group(1)))
for m in all_matches
]
depths = [d for d, _ in pairs]
min_depth, max_depth = min(depths), max(depths)
if max_depth == min_depth:
return text, 0
base_level = min(lv for d, lv in pairs if d == min_depth)
count = 0
def _repl(m: re.Match) -> str:
nonlocal count
hashes, num, title = m.group(1), m.group(2), m.group(3)
depth = num.count(".") + 1
new_level = min(base_level + (depth - min_depth), 6)
if new_level == len(hashes):
return m.group(0)
count += 1
return f"{'#' * new_level} {num}. {title}"
return _NUMBERED_HDR_RE.sub(_repl, text), count
def _t_normalize_header_levels(text: str) -> tuple[str, int]: def _t_normalize_header_levels(text: str) -> tuple[str, int]:
"""Normalizza h4+ → h3; rimuove header vuoti.""" """Normalizza h4+ → h3; rimuove header vuoti; rimuove numero pagina '| N' finale."""
text = re.sub(r"^#{3,6}\s*$", "", text, flags=re.MULTILINE) text = re.sub(r"^#{3,6}\s*$", "", text, flags=re.MULTILINE)
text = re.sub( text = re.sub(
r"^(#{3,6})\s+(\d{1,3})\s+(.+)$", r"^(#{3,6})\s+(\d{1,3})\s+(.+)$",
@@ -514,11 +733,20 @@ def _t_remove_toc(text: str) -> tuple[str, int]:
if _in_toc: if _in_toc:
if re.match(r"^\s*$", line) or re.match(r"^\s*[-*+]\s+\d", line): if re.match(r"^\s*$", line) or re.match(r"^\s*[-*+]\s+\d", line):
continue continue
# Voce TOC con numero pagina finale (sicuro: siamo gia in contesto TOC)
if re.match(r"^\s*[-*+]\s+.{2,70}\s+\d{1,3}\s*$", line):
continue
# Riga di testo lungo = probabilmente abstract o corpo, non voce di indice
if len(line.strip()) > 200:
_in_toc = False
new_lines.append(line)
continue
_in_toc = False _in_toc = False
new_lines.append(line) new_lines.append(line)
return "\n".join(new_lines), 1 if removed else 0 return "\n".join(new_lines), 1 if removed else 0
def _t_allcaps_to_headers(text: str) -> tuple[str, int]: def _t_allcaps_to_headers(text: str) -> tuple[str, int]:
"""Converti righe ALL-CAPS standalone → ## header.""" """Converti righe ALL-CAPS standalone → ## header."""
count = 0 count = 0
@@ -542,6 +770,13 @@ def _t_allcaps_to_headers(text: str) -> tuple[str, int]:
return "\n\n".join(new_blocks), count return "\n\n".join(new_blocks), count
_BIB_MARKERS_RE = re.compile(
r'\b(pp?\.|vol\.|n\.\s*\d|ed\.|edn\.|ISBN|DOI|arXiv)\b'
r'|\b(19|20)\d{2}\b',
re.IGNORECASE,
)
def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, int]: def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, int]:
"""Converti sezioni numerate 'N. testo' / '- N. testo' / '- N testo' → ### header.""" """Converti sezioni numerate 'N. testo' / '- N. testo' / '- N testo' → ### header."""
count = 0 count = 0
@@ -551,6 +786,8 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
content = m.group(2).strip() content = m.group(2).strip()
if content.endswith(".") and len(content) > 40: if content.endswith(".") and len(content) > 40:
return m.group(0) return m.group(0)
if _BIB_MARKERS_RE.search(content):
return m.group(0)
count += 1 count += 1
return f"### {m.group(1)}.\n\n{content}" return f"### {m.group(1)}.\n\n{content}"
@@ -568,8 +805,11 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
if not has_exercises: if not has_exercises:
def _aphorism_repl(m: re.Match) -> str: def _aphorism_repl(m: re.Match) -> str:
nonlocal count nonlocal count
content = m.group(2).strip()
if _BIB_MARKERS_RE.search(content):
return m.group(0)
count += 1 count += 1
return f"\n\n### {m.group(1)}.\n\n{m.group(2).strip()}" return f"\n\n### {m.group(1)}.\n\n{content}"
text = re.sub( text = re.sub(
r"^-\s+(\d{1,3})\.\s+(.{10,})$", r"^-\s+(\d{1,3})\.\s+(.{10,})$",
@@ -582,6 +822,8 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
nonlocal count nonlocal count
num = m.group(1) num = m.group(1)
content = m.group(2).strip() content = m.group(2).strip()
if _BIB_MARKERS_RE.search(content):
return m.group(0)
count += 1 count += 1
split = re.search(r"(?<=[a-zàèéìíòóùú])\s+(?=[A-ZÀÈÉÌÍÒÓÙÚ])", content) split = re.search(r"(?<=[a-zàèéìíòóùú])\s+(?=[A-ZÀÈÉÌÍÒÓÙÚ])", content)
if split and split.start() >= 3: if split and split.start() >= 3:
@@ -619,10 +861,11 @@ def _t_merge_paragraphs(text: str) -> tuple[str, int]:
i + 1 < len(blocks) i + 1 < len(blocks)
and stripped and stripped
and not stripped.startswith("#") and not stripped.startswith("#")
and not stripped.startswith("|") # non unire righe tabella in avanti
and stripped[-1] not in _SENTENCE_END and stripped[-1] not in _SENTENCE_END
): ):
nxt = blocks[i + 1].strip() nxt = blocks[i + 1].strip()
if not nxt or nxt.startswith("#") or re.match(r"^\d+\.", nxt): if not nxt or nxt.startswith("#") or nxt.startswith("|") or re.match(r"^\d+\.", nxt) or re.match(r"^[-*+]\s", nxt):
break break
b = stripped + " " + nxt b = stripped + " " + nxt
stripped = b.strip() stripped = b.strip()
@@ -651,6 +894,97 @@ def _t_collapse_blank_lines(text: str) -> tuple[str, int]:
return re.sub(r"\n{3,}", "\n\n", text), 0 return re.sub(r"\n{3,}", "\n\n", text), 0
def _t_demote_verse_headers(text: str) -> tuple[str, int]:
"""Demoti header che sono in realtà terzine/versi.
opendataloader promuove a ## le iscrizioni e i testi in evidenza nel PDF
(corpo maggiore, centrato). Si riconoscono perché:
- terminano con un numero nudo (numero di verso: 3, 6, 9, …)
- contengono punteggiatura interna di fine verso (', ' o '. ')
Esempio: '## «per me si va ne la città dolente, ... gente. 3'
→ paragrafo normale senza il numero finale.
"""
count = 0
def _demote(m: re.Match) -> str:
nonlocal count
hashes, content = m.group(1), m.group(2).strip()
# Deve terminare con numero nudo (numero di verso ≤ 9999)
if not re.search(r"\s\d{1,4}\s*$", content):
return m.group(0)
# Deve contenere punteggiatura interna (è un blocco di più versi)
inner = re.sub(r"\s\d{1,4}\s*$", "", content)
if not re.search(r"[,;:.!?»\"\']\s+[A-Za-zÀ-ÿ«\"]", inner):
return m.group(0)
count += 1
# Rimuovi il numero di verso finale e restituisci come testo normale
clean = re.sub(r"\s\d{1,4}\s*$", "", content)
return clean
text = re.sub(
r"^(#{1,6})\s+(.{20,})$",
_demote,
text,
flags=re.MULTILINE,
)
return text, count
def _t_restore_poetry_lines(text: str) -> tuple[str, int]:
"""Ripristina line break di poesia distrutti da keep_line_breaks=False.
Quando il PDF è poesia (terzine dantesche, sonetti, ecc.) opendataloader
con keep_line_breaks=False produce un unico paragrafo con i numeri di verso
(3, 6, 9 … oppure 1, 2, 3 …) incorporati inline:
'smarrita. 3 Ahi quanto a dir qual era è cosa dura … paura! 6 Tant'è …'
Il transform rileva blocchi con numeri di verso in progressione aritmetica
e li separa in righe, con riga vuota ogni 3 versi (terzina).
"""
count = 0
blocks = text.split("\n\n")
result = []
# Pattern: numero isolato preceduto da punteggiatura-fine-verso e seguito
# da lettera maiuscola (inizio verso successivo).
_VERSE_NUM_RE = re.compile(
r'([.!?»\'\"]\s+)(\d+)(\s+)(?=[A-ZÀ-Ùa-zà-ù«"‟])'
)
for block in blocks:
stripped = block.strip()
if not stripped or stripped.startswith("#"):
result.append(block)
continue
matches = list(_VERSE_NUM_RE.finditer(stripped))
if len(matches) < 2:
result.append(block)
continue
nums = [int(m.group(2)) for m in matches]
diffs = [nums[i + 1] - nums[i] for i in range(len(nums) - 1)]
# Accetta progressioni con passo costante 15 (terzine: 3, endecasillabi: 1)
if not diffs or len(set(diffs)) > 2 or not (1 <= diffs[0] <= 5):
result.append(block)
continue
step = diffs[0]
def _replace_verse_num(m: re.Match) -> str:
n = int(m.group(2))
# Ogni 'step' versi → riga vuota (inizio nuova terzina/strofa)
sep = "\n\n" if n % (step * 3) == 0 else "\n"
return m.group(1).rstrip() + sep
new_block = _VERSE_NUM_RE.sub(_replace_verse_num, stripped)
if new_block != stripped:
count += len(matches)
result.append(new_block)
return "\n\n".join(result), count
def _t_remove_urls(text: str) -> tuple[str, int]: def _t_remove_urls(text: str) -> tuple[str, int]:
"""Rimuovi righe che sono solo URL (watermark, footer di piattaforme).""" """Rimuovi righe che sono solo URL (watermark, footer di piattaforme)."""
return re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text), 0 return re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text), 0
@@ -664,7 +998,14 @@ def _t_remove_empty_headers(text: str) -> tuple[str, int]:
stripped = block.strip() stripped = block.strip()
if re.match(r"^#{1,6} ", stripped) and "\n" not in stripped: if re.match(r"^#{1,6} ", stripped) and "\n" not in stripped:
next_stripped = blocks[i + 1].strip() if i + 1 < len(blocks) else "" next_stripped = blocks[i + 1].strip() if i + 1 < len(blocks) else ""
if not next_stripped or re.match(r"^#{1,6} ", next_stripped): # Non rimuovere un header breve se il successivo è un header molto lungo
# (> 80 char): quasi certamente è testo PDF mal classificato come heading.
next_is_long_header = (
re.match(r"^#{1,6} ", next_stripped) and len(next_stripped) > 80
)
if not next_stripped or (
re.match(r"^#{1,6} ", next_stripped) and not next_is_long_header
):
continue continue
cleaned.append(block) cleaned.append(block)
return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), 0 return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), 0
@@ -680,12 +1021,22 @@ def _t_remove_garbage_headers(text: str) -> tuple[str, int]:
def _is_garbage_header(content: str) -> bool: def _is_garbage_header(content: str) -> bool:
if content.lstrip().startswith("..."): if content.lstrip().startswith("..."):
return True return True
if not re.search(r"[A-Za-zÀ-ÿ]{2,}", content): if not re.search(r"[A-Za-zÀ-ÿ\u0391-\u03c9]{2,}", content):
return True return True
if re.fullmatch(r"\(?\s*[A-Za-z]{1,4}\s*\)?", content.strip()): if re.fullmatch(r"\(?\s*[A-Za-z]{1,4}\s*\)?", content.strip()):
return True return True
if len(content) > 60 and re.search(r"[!%#]\w|\w[!%#]|\b\w+-\s*\w", content): if len(content) > 60 and re.search(r"[!%#]\w|\w[!%#]|\b\w+-\s*\w", content):
return True return True
# Frammento di frase: inizia con minuscola ed e abbastanza lungo
first_alpha = next((c for c in content if c.isalpha()), None)
if first_alpha and first_alpha.islower() and len(content) > 40:
return True
# Formula matematica: variabile singola (o breve) seguita da = o operatore
if re.match(r"^[A-Za-z\u0391-\u03c9_]{1,3}\s*[=<>≤≥]", content.strip()):
return True
# Didascalia figura/tabella: "Figura N..." o "Figure N..." o "Tabella N..."
if re.match(r"^(Figura|Figure|Fig\.|Tabella|Table|Tab\.)\s+\d", content.strip(), re.IGNORECASE):
return True
return False return False
count = 0 count = 0
@@ -713,8 +1064,14 @@ def _t_remove_frontmatter(text: str) -> tuple[str, int]:
blocks = re.split(r"\n{2,}", text) blocks = re.split(r"\n{2,}", text)
cleaned = [] cleaned = []
count = 0 count = 0
total = len(blocks)
cutoff = max(5, min(15, int(total * 0.20)))
for i, block in enumerate(blocks): for i, block in enumerate(blocks):
stripped = block.strip() stripped = block.strip()
# Frontmatter compare solo nelle prime sezioni del documento
if i >= cutoff:
cleaned.append(block)
continue
if not re.match(r"^### ", stripped) or re.match(r"^### \d", stripped): if not re.match(r"^### ", stripped) or re.match(r"^### \d", stripped):
cleaned.append(block) cleaned.append(block)
continue continue
@@ -728,6 +1085,59 @@ def _t_remove_frontmatter(text: str) -> tuple[str, int]:
return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), count return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), count
_WATERMARK_RE = re.compile(
r"^(BOZZA|DRAFT|CONFIDENTIAL|RISERVATO|PROVVISORIO|SAMPLE|SPECIMEN"
r"|DO NOT DISTRIBUTE|NON DISTRIBUIRE|COPY|COPIA)\s*$",
re.IGNORECASE | re.MULTILINE,
)
def _t_remove_watermarks(text: str) -> tuple[str, int]:
"""Rimuovi righe standalone con testo watermark comune."""
lines = text.split("\n")
result, count = [], 0
for line in lines:
if _WATERMARK_RE.match(line):
count += 1
else:
result.append(line)
return "\n".join(result), count
def _t_fix_math_symbols(text: str) -> tuple[str, int]:
"""Rimuovi righe composte solo da simboli box/placeholder (font non estratti)."""
lines = text.split("\n")
result, count = [], 0
for line in lines:
if line.strip() and re.match(r"^[\s□■▪▫◆◇●○•\u25a0-\u25ff]+$", line):
count += 1
else:
result.append(line)
return "\n".join(result), count
def _t_remove_recurring_lines(text: str) -> tuple[str, int]:
"""Rimuovi righe corte che si ripetono ≥5 volte (header/footer di pagina)."""
lines = text.split("\n")
short_lines = [
ln.strip() for ln in lines
if 3 < len(ln.strip()) < 80
and not ln.strip().startswith("#")
and not ln.strip().startswith("|")
]
freq = Counter(short_lines)
recurring = {ln for ln, c in freq.items() if c >= 5}
if not recurring:
return text, 0
result, count = [], 0
for line in lines:
if line.strip() in recurring:
count += 1
else:
result.append(line)
return "\n".join(result), count
# ─── [3b] Pipeline delle trasformazioni ────────────────────────────────────── # ─── [3b] Pipeline delle trasformazioni ──────────────────────────────────────
def apply_transforms(text: str) -> tuple[str, dict]: def apply_transforms(text: str) -> tuple[str, dict]:
@@ -737,19 +1147,24 @@ def apply_transforms(text: str) -> tuple[str, dict]:
""" """
# Flag calcolato prima del loop: disabilita il transform 4b nei documenti # Flag calcolato prima del loop: disabilita il transform 4b nei documenti
# con sezioni "Esercizi" (i "- N. testo" sarebbero numerazioni, non header). # con sezioni "Esercizi" (i "- N. testo" sarebbero numerazioni, non header).
_has_ex = bool(re.search(r"\bEsercizi\b", text, re.IGNORECASE)) _has_ex = bool(re.search(r"\b(Esercizi|Exercises|Problems|Homework)\b", text, re.IGNORECASE))
_transforms: list[tuple[str | None, object]] = [ _transforms: list[tuple[str | None, object]] = [
("n_simboli_pua_corretti", _t_fix_symbol_font),
("n_immagini_rimosse", _t_remove_images), ("n_immagini_rimosse", _t_remove_images),
("n_br_rimossi", _t_fix_br), ("n_br_rimossi", _t_fix_br),
("n_tabsep_rimossi", _t_fix_tabsep), ("n_tabsep_rimossi", _t_fix_tabsep),
("n_note_rimosse", _t_remove_footnotes),
("n_accenti_corretti", _t_fix_accents), ("n_accenti_corretti", _t_fix_accents),
("n_moltiplicazioni_corrette", _t_fix_multiplication), ("n_moltiplicazioni_corrette", _t_fix_multiplication),
("n_micro_corretti", _t_fix_micro), ("n_micro_corretti", _t_fix_micro),
("n_simboli_math_rimossi", _t_fix_math_symbols),
("n_formule_rimossi", _t_remove_formula_labels), ("n_formule_rimossi", _t_remove_formula_labels),
("n_dotleader_rimossi", _t_remove_dotleaders), ("n_dotleader_rimossi", _t_remove_dotleaders),
("n_righe_ricorrenti_rimosse", _t_remove_recurring_lines),
("n_header_concat_fixati", _t_fix_header_concat), ("n_header_concat_fixati", _t_fix_header_concat),
(None, _t_extract_capitolo), (None, _t_extract_capitolo),
("n_header_numerati_normalizzati", _t_normalize_numbered_headings),
(None, _t_normalize_header_levels), (None, _t_normalize_header_levels),
("n_articoli_estratti", _t_extract_articles), ("n_articoli_estratti", _t_extract_articles),
(None, _t_remove_header_bold), (None, _t_remove_header_bold),
@@ -761,11 +1176,15 @@ def apply_transforms(text: str) -> tuple[str, dict]:
("n_paragrafi_uniti", _t_merge_paragraphs), ("n_paragrafi_uniti", _t_merge_paragraphs),
(None, _t_normalize_whitespace), (None, _t_normalize_whitespace),
(None, _t_collapse_blank_lines), (None, _t_collapse_blank_lines),
("n_versi_ripristinati", _t_restore_poetry_lines),
("n_header_verso_demotati", _t_demote_verse_headers),
(None, _t_remove_urls), (None, _t_remove_urls),
(None, _t_remove_empty_headers), (None, _t_remove_empty_headers),
("n_titoli_uniti", _t_merge_title_headers), ("n_titoli_uniti", _t_merge_title_headers),
(None, lambda t: (re.sub(r"(?m)^(#{1,6}.+?)\s*\|\s*\d{1,3}\s*$", r"\1", t), 0)),
("n_garbage_headers_rimossi", _t_remove_garbage_headers), ("n_garbage_headers_rimossi", _t_remove_garbage_headers),
("n_frontmatter_rimossi", _t_remove_frontmatter), ("n_frontmatter_rimossi", _t_remove_frontmatter),
("n_watermark_rimossi", _t_remove_watermarks),
] ]
stats: dict = {} stats: dict = {}
@@ -792,16 +1211,35 @@ _EN_WORDS = frozenset([
"from", "or", "an", "but", "not", "by", "he", "she", "we", "you", "from", "or", "an", "but", "not", "by", "he", "she", "we", "you",
"which", "their", "been", "has", "would", "there", "when", "will", "which", "their", "been", "has", "would", "there", "when", "will",
]) ])
_FR_WORDS = frozenset([
"le", "les", "de", "du", "des", "et", "un", "une", "est", "que",
"pour", "dans", "sur", "avec", "qui", "par", "pas", "plus", "au",
"ce", "se", "ou", "mais", "comme", "aussi",
])
_DE_WORDS = frozenset([
"der", "die", "das", "und", "in", "von", "zu", "den", "mit", "ist",
"auf", "eine", "als", "dem", "des", "sich", "nicht", "auch", "werden",
"bei", "nach", "oder", "wenn", "wird", "war",
])
_ES_WORDS = frozenset([
"el", "los", "las", "de", "en", "un", "una", "es", "que", "por",
"con", "del", "para", "como", "pero", "sus", "son", "los", "hay",
"todo", "esta", "este", "ser", "más", "ya",
])
def _detect_language(text: str) -> str: def _detect_language(text: str) -> str:
words = re.findall(r"\b[a-zA-Z]{2,}\b", text.lower()) words = re.findall(r"\b[a-zA-Z]{2,}\b", text.lower())
sample = words[:2000] sample = words[:2000]
it = sum(1 for w in sample if w in _IT_WORDS) scores = {
en = sum(1 for w in sample if w in _EN_WORDS) "it": sum(1 for w in sample if w in _IT_WORDS),
if it == 0 and en == 0: "en": sum(1 for w in sample if w in _EN_WORDS),
return "unknown" "fr": sum(1 for w in sample if w in _FR_WORDS),
return "it" if it >= en else "en" "de": sum(1 for w in sample if w in _DE_WORDS),
"es": sum(1 for w in sample if w in _ES_WORDS),
}
best = max(scores, key=scores.get)
return best if scores[best] > 0 else "unknown"
def _count_headers(text: str, level: int) -> int: def _count_headers(text: str, level: int) -> int:
@@ -850,6 +1288,17 @@ def analyze(md_path: Path) -> dict:
if n_h3 >= 5: if n_h3 >= 5:
livello, boundary, strategia = 3, "h3", "h3_aware" livello, boundary, strategia = 3, "h3", "h3_aware"
section_bodies = _split_sections(text, 3) section_bodies = _split_sections(text, 3)
# Gerarchia invertita: h3 sono capitoli enormi, h2 sono sottosezioni più brevi.
# Succede quando opendataloader classifica titoli capitolo come h6 (→ normalizzati
# a h3) e le sottosezioni ALL-CAPS diventano ## (h2). In questo caso h2 è
# il boundary corretto per il chunking.
if n_h2 >= 3:
h2_bodies = _split_sections(text, 2)
avg_h3 = sum(len(b) for b in section_bodies) / len(section_bodies) if section_bodies else 0
avg_h2 = sum(len(b) for b in h2_bodies) / len(h2_bodies) if h2_bodies else 0
if avg_h3 > 5000 and avg_h2 < avg_h3 * 0.7:
livello, boundary, strategia = 2, "h2", "h2_paragraph_split"
section_bodies = h2_bodies
elif n_h2 >= 3: elif n_h2 >= 3:
livello, boundary, strategia = 2, "h2", "h2_paragraph_split" livello, boundary, strategia = 2, "h2", "h2_paragraph_split"
section_bodies = _split_sections(text, 2) section_bodies = _split_sections(text, 2)
@@ -955,13 +1404,15 @@ def build_report(
return hits return hits
residui = { residui = {
"backtick": _scan(r"`"), "backtick": _scan(r"`"),
"dotleader": _scan(r"(?:\. ){3,}"), "dotleader": _scan(r"(?:\. ){3,}"),
"url": _scan(r"^(https?://|www\.)\S+"), "url": _scan(r"^(https?://|www\.)\S+"),
"immagini": _scan(r"!\[[^\]]*\]\([^)]*\)"), "immagini": _scan(r"!\[[^\]]*\]\([^)]*\)"),
"br_inline": _scan(r"<br>"), "br_inline": _scan(r"<br>"),
"simboli_encoding":_scan(r'(?<=[0-9A-Za-z])[!"](?=[0-9A-Za-z])'), "simboli_encoding": _scan(r'(?<=[0-9A-Za-z])[!"](?=[0-9A-Za-z])'),
"formule_inline": _scan(r"\[\d+\.\d+\]"), "formule_inline": _scan(r"\[\d+\.\d+\]"),
"footnote_markers": _scan(r'[\u00b9\u00b2\u00b3\u2070\u2074-\u2079]'),
"pua_markers": _scan(r'[\ue000-\uf8ff]'),
} }
# ── Composizione report ─────────────────────────────────────────────── # ── Composizione report ───────────────────────────────────────────────
@@ -990,13 +1441,17 @@ def build_report(
"br_inline": len(residui["br_inline"]), "br_inline": len(residui["br_inline"]),
"simboli_encoding": len(residui["simboli_encoding"]), "simboli_encoding": len(residui["simboli_encoding"]),
"formule_inline": len(residui["formule_inline"]), "formule_inline": len(residui["formule_inline"]),
"backtick_esempi": residui["backtick"], "footnote_markers": len(residui["footnote_markers"]),
"dotleader_esempi": residui["dotleader"], "pua_markers": len(residui["pua_markers"]),
"url_esempi": residui["url"], "backtick_esempi": residui["backtick"],
"immagini_esempi": residui["immagini"], "dotleader_esempi": residui["dotleader"],
"br_inline_esempi": residui["br_inline"], "url_esempi": residui["url"],
"simboli_encoding_esempi": residui["simboli_encoding"], "immagini_esempi": residui["immagini"],
"formule_inline_esempi": residui["formule_inline"], "br_inline_esempi": residui["br_inline"],
"simboli_encoding_esempi": residui["simboli_encoding"],
"formule_inline_esempi": residui["formule_inline"],
"footnote_markers_esempi": residui["footnote_markers"],
"pua_markers_esempi": residui["pua_markers"],
}, },
} }
@@ -1035,10 +1490,17 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
try: try:
md_file = convert_pdf(pdf_path, Path(tmp)) md_file = convert_pdf(pdf_path, Path(tmp))
except MemoryError:
print(" ✗ Memoria esaurita durante la conversione")
return False
except Exception as e: except Exception as e:
print(f" ✗ Conversione fallita: {e}") print(f" ✗ Conversione fallita: {e}")
return False return False
raw_text = md_file.read_text(encoding="utf-8") try:
raw_text = md_file.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
print(f" ✗ Errore encoding nel file prodotto: {e}")
return False
size_kb = len(raw_text.encode()) // 1024 size_kb = len(raw_text.encode()) // 1024
n_lines = raw_text.count("\n") n_lines = raw_text.count("\n")
@@ -1048,14 +1510,19 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
print(" [3/4] Pulizia strutturale...") print(" [3/4] Pulizia strutturale...")
clean_text, t_stats = apply_transforms(raw_text) clean_text, t_stats = apply_transforms(raw_text)
reduction = 100 * (1 - len(clean_text) / len(raw_text)) if raw_text else 0 reduction = 100 * (1 - len(clean_text) / len(raw_text)) if raw_text else 0
print(f"Immagini rimosse: {t_stats['n_immagini_rimosse']}") print(f"Simboli PUA corretti: {t_stats['n_simboli_pua_corretti']}")
print(f" Immagini rimosse: {t_stats['n_immagini_rimosse']}")
print(f" Note rimossa: {t_stats['n_note_rimosse']}")
print(f" Accenti corretti: {t_stats['n_accenti_corretti']}") print(f" Accenti corretti: {t_stats['n_accenti_corretti']}")
print(f" Dot-leader rimossi: {t_stats['n_dotleader_rimossi']}") print(f" Dot-leader rimossi: {t_stats['n_dotleader_rimossi']}")
print(f" Header concat fixati: {t_stats['n_header_concat_fixati']}") print(f" Header concat fixati: {t_stats['n_header_concat_fixati']}")
print(f" Header num. normaliz.: {t_stats['n_header_numerati_normalizzati']}")
print(f" Articoli → ###: {t_stats['n_articoli_estratti']}") print(f" Articoli → ###: {t_stats['n_articoli_estratti']}")
print(f" Ambienti matematici: {t_stats['n_ambienti_matematici']}") print(f" Ambienti matematici: {t_stats['n_ambienti_matematici']}")
print(f" Titoli header uniti: {t_stats['n_titoli_uniti']}") print(f" Titoli header uniti: {t_stats['n_titoli_uniti']}")
print(f" TOC rimosso: {'' if t_stats['toc_rimosso'] else 'no'}") print(f" TOC rimosso: {'' if t_stats['toc_rimosso'] else 'no'}")
print(f" Versi poesia riprist.: {t_stats['n_versi_ripristinati']}")
print(f" Header verso demotati: {t_stats['n_header_verso_demotati']}")
print(f" ALL-CAPS → ##: {t_stats['n_header_allcaps']}") print(f" ALL-CAPS → ##: {t_stats['n_header_allcaps']}")
print(f" Sezioni → ###: {t_stats['n_sezioni_numerate']}") print(f" Sezioni → ###: {t_stats['n_sezioni_numerate']}")
print(f" Paragrafi uniti: {t_stats['n_paragrafi_uniti']}") print(f" Paragrafi uniti: {t_stats['n_paragrafi_uniti']}")
@@ -1063,9 +1530,13 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
# ── [4] Profilo strutturale ──────────────────────────────────────────── # ── [4] Profilo strutturale ────────────────────────────────────────────
print(" [4/4] Analisi struttura...") print(" [4/4] Analisi struttura...")
out_dir.mkdir(parents=True, exist_ok=True) try:
raw_out.write_text(raw_text, encoding="utf-8") out_dir.mkdir(parents=True, exist_ok=True)
clean_out.write_text(clean_text, encoding="utf-8") raw_out.write_text(raw_text, encoding="utf-8")
clean_out.write_text(clean_text, encoding="utf-8")
except PermissionError as e:
print(f" ✗ Permesso negato durante la scrittura: {e}")
return False
profile = analyze(clean_out) profile = analyze(clean_out)
_LIVELLO_DESC = {3: "ricca (h3)", 2: "parziale (h2)", 1: "paragrafi", 0: "testo piatto"} _LIVELLO_DESC = {3: "ricca (h3)", 2: "parziale (h2)", 1: "paragrafi", 0: "testo piatto"}
+2
View File
@@ -86,6 +86,8 @@ def _score(r: dict) -> tuple[int, list[str]]:
_pen("br_inline", 2, 15, "<br> inline") _pen("br_inline", 2, 15, "<br> inline")
_pen("simboli_encoding", 1, 10, "simboli encoding") _pen("simboli_encoding", 1, 10, "simboli encoding")
_pen("formule_inline", 1, 8, "formule inline") _pen("formule_inline", 1, 8, "formule inline")
_pen("footnote_markers", 1, 8, "footnote residui")
_pen("pua_markers", 2, 20, "caratteri PUA font Symbol")
# ── Anomalie ────────────────────────────────────────────────────────── # ── Anomalie ──────────────────────────────────────────────────────────
n_bare = anomalie.get("bare_headers", 0) n_bare = anomalie.get("bare_headers", 0)
+113
View File
@@ -0,0 +1,113 @@
# Ollama — Step 7 (Verifica Ambiente)
Prima di procedere con la vettorizzazione (step 8) devi avere installato:
- **Ollama** — server locale per LLM e embedding
- un **modello di embedding** (es. `qwen3-embedding:0.6b`, `bge-m3`)
- un **modello LLM** (es. `qwen3.5:4b`)
- **chromadb** — libreria Python per il vector store
---
## 1. Installa Ollama
```bash
curl -fsSL https://ollama.com/install.sh | sh
```
Verifica che il servizio sia attivo:
```bash
ollama list
```
### Disinstalla Ollama
```bash
# Ferma e rimuovi il servizio systemd
sudo systemctl stop ollama
sudo systemctl disable ollama
sudo rm /etc/systemd/system/ollama.service
sudo systemctl daemon-reload
# Rimuovi il binario
sudo rm /usr/local/bin/ollama
# Rimuovi modelli e dati (opzionale)
sudo rm -rf /usr/share/ollama
# Rimuovi utente e gruppo di sistema (opzionale)
sudo userdel ollama
sudo groupdel ollama
```
---
## 2. Scarica i modelli
### Modello di embedding (consigliato)
```bash
ollama pull qwen3-embedding:0.6b
```
Alternative supportate:
- `nomic-embed-text-v2-moe`
- `bge-m3`
- `nomic-embed-text`
Se cambi embedding model rispetto a quello usato in step-8, riesegui ingest con `--force` e aggiorna `EMBED_MODEL` in `config.py`.
### Modello LLM (consigliato per 8 GB RAM)
```bash
ollama pull qwen3.5:4b
```
Se usi un modello diverso, aggiorna `OLLAMA_MODEL` in `config.py`.
### Disinstalla un modello
```bash
ollama rm qwen3.5:4b
ollama rm qwen3-embedding:0.6b
```
---
## 3. Installa le dipendenze Python
```bash
source .venv/bin/activate
pip install -r requirements.txt
```
---
## 4. Verifica ambiente
```bash
source .venv/bin/activate
python ollama/check_env.py
```
Output atteso (esempio):
```text
✅ ollama trovato nel PATH
✅ ollama risponde correttamente
✅ embedding disponibile: qwen3-embedding:0.6b
✅ LLM disponibile: qwen3.5:4b
✅ chromadb importabile
✅ Ambiente pronto — procedi con la vettorizzazione:
python step-8/ingest.py --stem <nome>
```
---
## Prossimo step
```bash
python step-8/ingest.py --stem <nome>
```
+84 -42
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Step 7 Verifica ambiente Verifica ambiente Ollama
Controlla che tutti i prerequisiti per la vettorizzazione siano soddisfatti: Controlla che tutti i prerequisiti per la vettorizzazione siano soddisfatti:
1. ollama è nel PATH e risponde 1. ollama è nel PATH e risponde
@@ -12,7 +12,7 @@ Output: report a schermo con ✅ / ❌ per ogni componente.
Nessun file scritto. Exit 0 se tutto OK, 1 altrimenti. Nessun file scritto. Exit 0 se tutto OK, 1 altrimenti.
Uso: Uso:
python step-7/check_env.py python ollama/check_env.py
""" """
import shutil import shutil
@@ -22,7 +22,7 @@ from pathlib import Path
# ─── Lista canonica di modelli embedding supportati ─────────────────────────── # ─── Lista canonica di modelli embedding supportati ───────────────────────────
# Ordine: prima scelta → ultima scelta (come da README step-7) # Ordine: prima scelta → ultima scelta (come da ollama/README.md)
EMBED_MODELS = [ EMBED_MODELS = [
"qwen3-embedding", "qwen3-embedding",
"nomic-embed-text-v2-moe", "nomic-embed-text-v2-moe",
@@ -32,17 +32,35 @@ EMBED_MODELS = [
"paraphrase-multilingual", "paraphrase-multilingual",
"all-minilm", "all-minilm",
] ]
EMBED_MODEL_PREFIXES = tuple(EMBED_MODELS)
OLLAMA_SERVE_HINT = " → Avvia il servizio con: ollama serve"
RECOMMENDED_EMBED_MODEL = "qwen3-embedding:0.6b"
RECOMMENDED_LLM_MODEL = "qwen3.5:4b"
def _is_embed(model_name: str) -> bool: def _is_embed(model_name: str) -> bool:
"""True se il modello è riconosciuto come embedding (lista canonica o keyword).""" """True se il modello è riconosciuto come embedding (lista canonica o keyword)."""
base = model_name.split(":")[0].lower() base = model_name.split(":")[0].lower()
return any(base == e or base.startswith(e) for e in EMBED_MODELS) or "embed" in base return base.startswith(EMBED_MODEL_PREFIXES) or "embed" in base
# ─── Modelli configurati in step-9/config.py ───────────────────────────────── def _parse_ollama_models(raw_output: str) -> list[str]:
# Per spostare config.py alla root: cambia solo la riga qui sotto. """Estrae i nomi modello dall'output di `ollama list`."""
sys.path.insert(0, str(Path(__file__).parent.parent / "step-9")) models: list[str] = []
for idx, line in enumerate(raw_output.splitlines()):
line = line.strip()
if not line:
continue
# Prima riga: header tabellare ("NAME ...")
if idx == 0 and line.lower().startswith("name"):
continue
model_name = line.split(maxsplit=1)[0]
models.append(model_name)
return models
sys.path.insert(0, str(Path(__file__).parent.parent))
try: try:
from config import EMBED_MODEL as CONFIGURED_EMBED, OLLAMA_MODEL as CONFIGURED_LLM from config import EMBED_MODEL as CONFIGURED_EMBED, OLLAMA_MODEL as CONFIGURED_LLM
except Exception: except Exception:
@@ -54,6 +72,15 @@ REQUIRED_LIBS = ["chromadb"]
# ─── Checks ─────────────────────────────────────────────────────────────────── # ─── Checks ───────────────────────────────────────────────────────────────────
def _print_model_list(title: str, models: list[str]) -> None:
"""Stampa in modo uniforme una lista di modelli."""
if not models:
print(f" {title}: nessuno")
return
print(f" {title} ({len(models)}):")
for model in models:
print(f" - {model}")
def check_ollama_in_path() -> bool: def check_ollama_in_path() -> bool:
"""Verifica che ollama sia nel PATH.""" """Verifica che ollama sia nel PATH."""
found = shutil.which("ollama") is not None found = shutil.which("ollama") is not None
@@ -77,14 +104,9 @@ def check_ollama_running() -> list[str] | None:
) )
if result.returncode != 0: if result.returncode != 0:
print("❌ ollama non risponde (errore all'avvio)") print("❌ ollama non risponde (errore all'avvio)")
print(" → Avvia il servizio con: ollama serve") print(OLLAMA_SERVE_HINT)
return None return None
lines = result.stdout.strip().splitlines() models = _parse_ollama_models(result.stdout)
models = []
for line in lines[1:]: # salta l'header
parts = line.split()
if parts:
models.append(parts[0])
print("✅ ollama risponde correttamente") print("✅ ollama risponde correttamente")
return models return models
except FileNotFoundError: except FileNotFoundError:
@@ -92,7 +114,7 @@ def check_ollama_running() -> list[str] | None:
return None return None
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
print("❌ ollama non risponde (timeout)") print("❌ ollama non risponde (timeout)")
print(" → Avvia il servizio con: ollama serve") print(OLLAMA_SERVE_HINT)
return None return None
@@ -107,45 +129,58 @@ def _match(model_name: str, available: list[str]) -> str | None:
return None return None
def _check_configured_model(
configured_name: str | None,
available: list[str],
label: str,
) -> bool | None:
"""
Se esiste un modello configurato, lo verifica e ritorna True/False.
Se non è configurato, ritorna None (il chiamante userà il fallback).
"""
if not configured_name:
return None
print(f" modello configurato (config.py): {configured_name}")
found = _match(configured_name, available)
if found:
print(f"{label} disponibile: {found}")
return True
print(f"{configured_name} non trovato in Ollama")
print(f" → ollama pull {configured_name}")
return False
def check_embed_model(available: list[str]) -> bool: def check_embed_model(available: list[str]) -> bool:
"""Verifica che il modello di embedding configurato sia disponibile.""" """Verifica che il modello di embedding configurato sia disponibile."""
if CONFIGURED_EMBED: configured_check = _check_configured_model(CONFIGURED_EMBED, available, "embedding")
print(f" modello configurato (step-9/config.py): {CONFIGURED_EMBED}") if configured_check is not None:
found = _match(CONFIGURED_EMBED, available) return configured_check
if found:
print(f"✅ embedding disponibile: {found}")
return True
print(f"{CONFIGURED_EMBED} non trovato in Ollama")
print(f" → ollama pull {CONFIGURED_EMBED}")
return False
# fallback: config.py non leggibile # fallback: config.py non leggibile
found = next((m for m in available if _is_embed(m)), None) found = next((m for m in available if _is_embed(m)), None)
if found: if found:
print(f"✅ modello embedding trovato: {found}") print(f"✅ modello embedding trovato: {found}")
return True return True
print("❌ nessun modello di embedding trovato") print("❌ nessun modello di embedding trovato")
print(f" → Prima scelta: ollama pull qwen3-embedding:0.6b") print(f" → Prima scelta: ollama pull {RECOMMENDED_EMBED_MODEL}")
return False return False
def check_llm_model(available: list[str]) -> bool: def check_llm_model(available: list[str]) -> bool:
"""Verifica che il modello LLM configurato sia disponibile.""" """Verifica che il modello LLM configurato sia disponibile."""
if CONFIGURED_LLM: configured_check = _check_configured_model(CONFIGURED_LLM, available, "LLM")
print(f" modello configurato (step-9/config.py): {CONFIGURED_LLM}") if configured_check is not None:
found = _match(CONFIGURED_LLM, available) return configured_check
if found:
print(f"✅ LLM disponibile: {found}")
return True
print(f"{CONFIGURED_LLM} non trovato in Ollama")
print(f" → ollama pull {CONFIGURED_LLM}")
return False
# fallback: config.py non leggibile # fallback: config.py non leggibile
llm_candidates = [m for m in available if not _is_embed(m)] first_llm = next((m for m in available if not _is_embed(m)), None)
if llm_candidates: if first_llm:
print(f"✅ modello LLM trovato: {llm_candidates[0]}") print(f"✅ modello LLM trovato: {first_llm}")
return True return True
print("❌ nessun modello LLM trovato") print("❌ nessun modello LLM trovato")
print(f" → Consigliato per 8 GB RAM: ollama pull qwen3.5:4b") print(f" → Consigliato per 8 GB RAM: ollama pull {RECOMMENDED_LLM_MODEL}")
return False return False
@@ -164,7 +199,7 @@ def check_library(lib: str) -> bool:
# ─── Entry point ────────────────────────────────────────────────────────────── # ─── Entry point ──────────────────────────────────────────────────────────────
def main() -> int: def main() -> int:
print("─── Step 7 — Verifica ambiente ───────────────────────────────────────\n") print("─── Verifica ambiente Ollama ─────────────────────────────────────────\n")
results: list[bool] = [] results: list[bool] = []
@@ -179,6 +214,14 @@ def main() -> int:
results.extend([False, False, False]) results.extend([False, False, False])
else: else:
results.append(True) results.append(True)
_print_model_list(
"modelli embedding rilevati",
[m for m in available if _is_embed(m)],
)
_print_model_list(
"modelli LLM rilevati",
[m for m in available if not _is_embed(m)],
)
results.append(check_embed_model(available)) results.append(check_embed_model(available))
results.append(check_llm_model(available)) results.append(check_llm_model(available))
else: else:
@@ -195,11 +238,10 @@ def main() -> int:
print("──────────────────────────────────────────────────────────────────────") print("──────────────────────────────────────────────────────────────────────")
all_ok = all(results) all_ok = all(results)
if all_ok: if all_ok:
print("✅ Ambiente pronto — procedi con la vettorizzazione:") print("✅ Ambiente pronto")
print(" python step-8/ingest.py --stem <nome>")
else: else:
n_fail = sum(1 for r in results if not r) n_fail = sum(1 for r in results if not r)
print(f"⚠️ {n_fail} problema/i rilevato/i — risolvi prima di procedere con step-8.") print(f"⚠️ {n_fail} problema/i rilevato/i — risolvi prima di procedere.")
return 0 if all_ok else 1 return 0 if all_ok else 1
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Test chat locale Ollama senza RAG, senza ChromaDB. Test chat locale Ollama senza RAG, senza ChromaDB.
Uso: python step-9/test_ollama.py Uso: python ollama/test_ollama.py
""" """
import json import json
@@ -10,7 +10,7 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import config as _cfg import config as _cfg
OLLAMA_URL = _cfg.OLLAMA_URL OLLAMA_URL = _cfg.OLLAMA_URL
+6 -6
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Step 9 Pipeline RAG interattiva Pipeline RAG interattiva
Riceve una domanda, recupera i chunk più rilevanti da ChromaDB (retrieval) Riceve una domanda, recupera i chunk più rilevanti da ChromaDB (retrieval)
e genera una risposta tramite Ollama (generation). e genera una risposta tramite Ollama (generation).
@@ -9,7 +9,7 @@ Input: chroma_db/<stem> (collection ChromaDB)
Output: risposta a schermo Output: risposta a schermo
Uso: Uso:
python step-9/rag.py --stem <nome> python rag.py --stem <nome>
Nel loop interattivo: Nel loop interattivo:
Domanda: <testo> risposta Domanda: <testo> risposta
@@ -31,7 +31,7 @@ import chromadb
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
import config as _cfg import config as _cfg
project_root = Path(__file__).parent.parent project_root = Path(__file__).parent
CHROMA_DIR = project_root / "chroma_db" CHROMA_DIR = project_root / "chroma_db"
OLLAMA_URL = _cfg.OLLAMA_URL OLLAMA_URL = _cfg.OLLAMA_URL
@@ -183,7 +183,7 @@ def run_loop(collection: chromadb.Collection) -> None:
def _build_epilog() -> str: def _build_epilog() -> str:
lines = [ lines = [
"Uso:", "Uso:",
" python step-9/rag.py --stem <nome>", " python rag.py --stem <nome>",
"", "",
"Loop interattivo:", "Loop interattivo:",
" <domanda> risposta basata sul documento", " <domanda> risposta basata sul documento",
@@ -206,7 +206,7 @@ def _build_epilog() -> str:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
"Step 9 — Pipeline RAG interattiva\n\n" "Pipeline RAG interattiva\n\n"
"Risponde a domande in linguaggio naturale su un documento\n" "Risponde a domande in linguaggio naturale su un documento\n"
"indicizzato in ChromaDB da step-8/ingest.py." "indicizzato in ChromaDB da step-8/ingest.py."
), ),
@@ -223,7 +223,7 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
print("─── Step 9 — Pipeline RAG ────────────────────────────────────────────\n") print("─── Pipeline RAG ────────────────────────────────────────────\n")
print(f" Documento : {args.stem}") print(f" Documento : {args.stem}")
print(f" Modello : {LLM_MODEL}") print(f" Modello : {LLM_MODEL}")
print(f" Top-K : {TOP_K}") print(f" Top-K : {TOP_K}")
-8
View File
@@ -1,12 +1,4 @@
# Step 0-1 — Ispezione e verifica PDF
pdfplumber==0.11.9 pdfplumber==0.11.9
# Step 2 — Conversione PDF → Markdown
pymupdf4llm pymupdf4llm
# conversione/ — Pipeline automatica PDF → clean Markdown (alternativa a step 0+1+2+3+4)
# Richiede anche Java 11+ sul PATH: https://adoptium.net/
opendataloader-pdf opendataloader-pdf
# Step 8 — Vettorizzazione
chromadb chromadb
+6 -6
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Step 9 Retrieval puro (senza generazione LLM) Retrieval puro (senza generazione LLM)
Loop interattivo: inserisci una query, ottieni i chunk più simili dalla Loop interattivo: inserisci una query, ottieni i chunk più simili dalla
collection ChromaDB tramite embedding semantico senza chiamare Ollama collection ChromaDB tramite embedding semantico senza chiamare Ollama
@@ -15,7 +15,7 @@ Input: chroma_db/<stem> (collection ChromaDB)
Output: lista chunk con score di similarità Output: lista chunk con score di similarità
Uso: Uso:
python step-9/retrieve.py --stem <nome> python retrieve.py --stem <nome>
Nel loop interattivo: Nel loop interattivo:
Query: <testo> chunk più simili con score Query: <testo> chunk più simili con score
@@ -37,7 +37,7 @@ import chromadb
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
import config as _cfg import config as _cfg
project_root = Path(__file__).parent.parent project_root = Path(__file__).parent
CHROMA_DIR = project_root / "chroma_db" CHROMA_DIR = project_root / "chroma_db"
OLLAMA_URL = _cfg.OLLAMA_URL OLLAMA_URL = _cfg.OLLAMA_URL
@@ -145,7 +145,7 @@ def run_loop(collection: chromadb.Collection, top_k: int) -> None:
def _build_epilog() -> str: def _build_epilog() -> str:
lines = [ lines = [
"Uso:", "Uso:",
" python step-9/retrieve.py --stem <nome>", " python retrieve.py --stem <nome>",
"", "",
"Nel loop interattivo:", "Nel loop interattivo:",
" <query> chunk più simili con score (testo troncato)", " <query> chunk più simili con score (testo troncato)",
@@ -168,7 +168,7 @@ def _build_epilog() -> str:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
"Step 9 — Retrieval puro (senza LLM)\n\n" "Retrieval puro (senza LLM)\n\n"
"Loop interattivo: inserisci una query e ottieni i chunk più simili\n" "Loop interattivo: inserisci una query e ottieni i chunk più simili\n"
"tramite embedding semantico, senza generazione LLM." "tramite embedding semantico, senza generazione LLM."
), ),
@@ -189,7 +189,7 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
print("─── Step 9 — Retrieval puro ──────────────────────────────────────────\n") print("─── Retrieval puro ──────────────────────────────────────────\n")
print(f" Documento : {args.stem}") print(f" Documento : {args.stem}")
print(f" Embed model : {EMBED_MODEL}") print(f" Embed model : {EMBED_MODEL}")
print(f" Top-K : {args.top_k}") print(f" Top-K : {args.top_k}")
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Step 0 Verifica idoneità PDF
Legge tutti i PDF in sources/ e salva un report per ognuno in step-0/.
Uso:
python step-0/check_pdf.py
Output:
step-0/<nome_pdf>_step0_report.txt
"""
import sys
import statistics
from datetime import datetime
from pathlib import Path
def check_pdf(pdf_path: str, save: bool = True) -> None:
try:
import pdfplumber
except ImportError:
print("Errore: pdfplumber non è installato.")
print(" pip install pdfplumber")
sys.exit(1)
path = Path(pdf_path)
if not path.exists():
print(f"Errore: file non trovato — {pdf_path}")
sys.exit(1)
if path.suffix.lower() != ".pdf":
print(f"Errore: il file non è un PDF — {pdf_path}")
sys.exit(1)
lines = [] # righe del report
results = [] # (etichetta, stato, messaggio)
def out(text=""):
lines.append(text)
print(text)
out(f"Step 0 — Verifica idoneità PDF")
out(f"File: {path.name}")
out(f"Data: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
out("=" * 50)
# ------------------------------------------------------------------ #
# Criterio 1 — Non protetto da password
# ------------------------------------------------------------------ #
try:
with pdfplumber.open(path) as pdf:
n_pages = len(pdf.pages)
results.append(("Non protetto da password", "PASS", f"{n_pages} pagine"))
except Exception as e:
msg = str(e).lower()
if "password" in msg or "encrypted" in msg or "decrypt" in msg:
results.append(("Non protetto da password", "FAIL",
"Il PDF è cifrato — non può essere elaborato"))
else:
results.append(("Non protetto da password", "FAIL",
f"Impossibile aprire il file: {e}"))
_render_results(results, out)
_maybe_save(lines, path, save)
return
# ------------------------------------------------------------------ #
# Lettura pagine — una sola passata
# ------------------------------------------------------------------ #
char_counts = []
line_lengths = []
all_text = ""
empty_pages = 0
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
all_text += text + "\n"
chars = len(text.strip())
char_counts.append(chars)
if chars == 0:
empty_pages += 1
for line in text.splitlines():
stripped = line.strip()
if stripped:
line_lengths.append(len(stripped))
total_pages = len(char_counts)
pages_with_text = sum(1 for c in char_counts if c > 50)
text_coverage = pages_with_text / total_pages if total_pages > 0 else 0
# ------------------------------------------------------------------ #
# Criterio 2 — Testo estraibile
# ------------------------------------------------------------------ #
if text_coverage >= 0.7:
results.append(("Testo estraibile", "PASS",
f"{pages_with_text}/{total_pages} pagine con testo ({text_coverage:.0%})"))
elif text_coverage >= 0.4:
results.append(("Testo estraibile", "WARN",
f"Solo {pages_with_text}/{total_pages} pagine con testo — revisione estesa necessaria"))
else:
results.append(("Testo estraibile", "FAIL",
f"Solo {pages_with_text}/{total_pages} pagine con testo — probabilmente scansionato"))
# ------------------------------------------------------------------ #
# Criterio 3 — Generato digitalmente (non scansionato)
# ------------------------------------------------------------------ #
pages_text_only = [c for c in char_counts if c > 0]
avg_chars = statistics.mean(pages_text_only) if pages_text_only else 0
if avg_chars >= 300:
results.append(("Generato digitalmente (non scansionato)", "PASS",
f"Media {avg_chars:.0f} char/pagina"))
elif avg_chars >= 100:
results.append(("Generato digitalmente (non scansionato)", "WARN",
f"Media bassa: {avg_chars:.0f} char/pagina — alcune pagine potrebbero essere immagini"))
else:
results.append(("Generato digitalmente (non scansionato)", "FAIL",
f"Media molto bassa: {avg_chars:.0f} char/pagina — il PDF sembra scansionato"))
# ------------------------------------------------------------------ #
# Criterio 4 — Pagine vuote
# ------------------------------------------------------------------ #
if empty_pages == 0:
results.append(("Pagine vuote", "PASS", "Nessuna pagina vuota"))
elif empty_pages <= total_pages * 0.05:
results.append(("Pagine vuote", "WARN",
f"{empty_pages} pagine vuote (≤ 5%) — probabilmente copertine o separatori"))
else:
results.append(("Pagine vuote", "WARN",
f"{empty_pages} pagine vuote ({empty_pages/total_pages:.0%}) — controllare"))
# ------------------------------------------------------------------ #
# Criterio desiderabile — Layout a colonne singola
# ------------------------------------------------------------------ #
if line_lengths:
median_len = statistics.median(line_lengths)
short_lines = sum(1 for l in line_lengths if l < median_len * 0.4)
short_ratio = short_lines / len(line_lengths)
if short_ratio < 0.15:
results.append(("Layout a colonne singola (desiderabile)", "PASS",
f"Righe corte: {short_ratio:.0%} — struttura lineare"))
elif short_ratio < 0.35:
results.append(("Layout a colonne singola (desiderabile)", "WARN",
f"Righe corte: {short_ratio:.0%} — possibile layout a colonne parziale"))
else:
results.append(("Layout a colonne singola (desiderabile)", "WARN",
f"Righe corte: {short_ratio:.0%} — probabile layout a colonne multiple"))
else:
results.append(("Layout a colonne singola (desiderabile)", "WARN",
"Impossibile analizzare (nessuna riga estratta)"))
# ------------------------------------------------------------------ #
# Criterio desiderabile — Struttura logica (titoli)
# ------------------------------------------------------------------ #
candidate_headings = [
line.strip() for line in all_text.splitlines()
if 3 <= len(line.strip()) <= 80
and line.strip()[0].isupper()
and not line.strip().endswith(".")
and not line.strip().endswith(",")
and len(line.strip().split()) <= 10
]
heading_density = len(candidate_headings) / total_pages if total_pages > 0 else 0
if heading_density >= 1.0:
results.append(("Struttura logica riconoscibile (desiderabile)", "PASS",
f"~{len(candidate_headings)} possibili titoli rilevati ({heading_density:.1f}/pagina)"))
elif heading_density >= 0.3:
results.append(("Struttura logica riconoscibile (desiderabile)", "WARN",
f"~{len(candidate_headings)} possibili titoli ({heading_density:.1f}/pagina) — struttura parziale"))
else:
results.append(("Struttura logica riconoscibile (desiderabile)", "WARN",
"Pochi titoli rilevati — testo narrativo o struttura non standard"))
_render_results(results, out)
_maybe_save(lines, path, save)
def _render_results(results: list, out) -> None:
icons = {"PASS": "", "WARN": "⚠️ ", "FAIL": ""}
out()
for label, status, message in results:
icon = icons.get(status, " ")
out(f" {icon} {label}")
out(f" {message}")
out()
fails = [r for r in results if r[1] == "FAIL"]
warns = [r for r in results if r[1] == "WARN"]
if fails:
out("ESITO: ❌ PDF NON IDONEO")
out(" Criteri obbligatori non soddisfatti — scegli un PDF diverso.")
elif warns:
out("ESITO: ⚠️ PDF ACCETTABILE CON CAUTELA")
out(" Procedi, ma aspettati più lavoro nella revisione manuale (step 4).")
else:
out("ESITO: ✅ PDF IDONEO")
out(" Tutti i criteri soddisfatti — procedi con lo step 1.")
out()
def _maybe_save(lines: list, pdf_path: Path, save: bool) -> None:
if not save:
return
script_dir = Path(__file__).parent
out_file = script_dir / f"{pdf_path.stem}_step0_report.txt"
out_file.write_text("\n".join(lines), encoding="utf-8")
print(f"Report salvato in: {out_file}")
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
sources_dir = project_root / "sources"
if not sources_dir.exists():
print(f"Errore: cartella sources/ non trovata in {project_root}")
sys.exit(1)
pdfs = sorted(sources_dir.glob("*.pdf"))
if not pdfs:
print(f"Errore: nessun PDF trovato in {sources_dir}")
sys.exit(1)
for pdf in pdfs:
check_pdf(str(pdf), save=True)
if len(pdfs) > 1:
print("-" * 50)
-199
View File
@@ -1,199 +0,0 @@
#!/usr/bin/env python3
"""
Step 1 Ispezione automatica PDF
Analizza il PDF pagina per pagina e produce un report con score (0100)
e lista dei problemi per pagina. Serve per capire la qualità del documento
e mappare i problemi prima della revisione manuale (step 4).
Uso:
python step1/inspect.py
Output:
step1/<nome_pdf>_step1_report.txt
"""
import re
import sys
import statistics
from collections import Counter
from datetime import datetime
from pathlib import Path
# ── Penalità per il calcolo dello score ───────────────────────────────────
SYLLABIF_PENALTY = 0.3 # per occorrenza di sillabazione
COLUMN_PENALTY = 3.0 # per pagina con layout a colonne
UNICODE_PENALTY = 1.5 # per pagina con caratteri anomali
EMPTY_PENALTY = 1.0 # per pagina vuota
HEADER_FOOTER_PEN = 5.0 # fisso se intestazioni/piè ripetitivi rilevati
def inspect_pdf(pdf_path: str, save: bool = True) -> None:
try:
import pdfplumber
except ImportError:
print("Errore: pdfplumber non è installato.")
print(" pip install pdfplumber")
sys.exit(1)
path = Path(pdf_path)
if not path.exists():
print(f"Errore: file non trovato — {pdf_path}")
sys.exit(1)
lines = []
def out(text=""):
lines.append(text)
print(text)
out("Step 1 — Ispezione automatica PDF")
out(f"File: {path.name}")
out(f"Data: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
out("=" * 50)
# ── Lettura pagine ─────────────────────────────────────────────────────
with pdfplumber.open(path) as pdf:
n_pages = len(pdf.pages)
pages_text = [page.extract_text() or "" for page in pdf.pages]
# ── Analisi per pagina ─────────────────────────────────────────────────
issues = [] # (page_num, descrizione) — page_num=0 → problema globale
deductions = 0.0
first_lines = [] # prima riga significativa di ogni pagina (per header)
last_lines = [] # ultima riga significativa di ogni pagina (per footer)
for i, text in enumerate(pages_text):
page_num = i + 1
stripped = text.strip()
# 1. Pagina vuota
if len(stripped) < 50:
issues.append((page_num, "pagina vuota"))
deductions += EMPTY_PENALTY
continue
page_lines = text.splitlines()
nonempty = [l.strip() for l in page_lines if l.strip()]
# Raccogli prima/ultima riga per il controllo header/footer
if nonempty:
first_lines.append(nonempty[0])
last_lines.append(nonempty[-1])
# 2. Sillabazione a fine riga (es. "estra-" + a capo)
syllabif = sum(
1 for line in page_lines
if re.search(r'\b\w{2,}-$', line.rstrip())
)
if syllabif:
label = "occorrenza" if syllabif == 1 else "occorrenze"
issues.append((page_num, f"sillabazione rilevata ({syllabif} {label})"))
deductions += syllabif * SYLLABIF_PENALTY
# 3. Layout a colonne (righe molto corte e numerose)
if len(nonempty) >= 10:
median_len = statistics.median(len(l) for l in nonempty)
short_ratio = sum(1 for l in nonempty if len(l) < median_len * 0.4) / len(nonempty)
if short_ratio > 0.35:
issues.append((page_num, f"possibile layout a colonne ({short_ratio:.0%} righe corte)"))
deductions += COLUMN_PENALTY
# 4. Caratteri Unicode anomali
# (control chars esclusi \n \t \r, replacement char, PUA block)
anomalies = re.findall(
r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ufffd\ue000-\uf8ff]', text
)
if anomalies:
issues.append((page_num, f"caratteri Unicode anomali ({len(anomalies)} trovati)"))
deductions += UNICODE_PENALTY
# ── Intestazioni e piè di pagina ripetitivi ────────────────────────────
def _check_repetition(line_list: list, label: str) -> None:
nonlocal deductions
if not line_list:
return
threshold = max(3, len(line_list) * 0.25)
repeated = [
(txt, cnt) for txt, cnt in Counter(line_list).items()
if cnt >= threshold and len(txt) > 3
]
if repeated:
deductions += HEADER_FOOTER_PEN
for txt, cnt in repeated[:3]:
issues.append((0, f"{label} ripetitivo: \"{txt[:45]}\" ({cnt} volte)"))
_check_repetition(first_lines, "intestazione")
_check_repetition(last_lines, "piè di pagina")
# ── Score ──────────────────────────────────────────────────────────────
score = max(0, round(100 - deductions))
# ── Riepilogo ──────────────────────────────────────────────────────────
pages_with_issues = len({p for p, _ in issues if p > 0})
out()
out(f"Score: {score}/100")
out(f"Pagine totali: {n_pages}")
out(f"Pagine con problemi: {pages_with_issues}")
out()
if issues:
global_issues = [(p, d) for p, d in issues if p == 0]
page_issues = sorted([(p, d) for p, d in issues if p > 0])
for _, desc in global_issues:
out(f" ⚠️ {desc}")
for page_num, desc in page_issues:
out(f" Pagina {page_num:>4}: {desc}")
else:
out(" Nessun problema rilevato.")
out()
# ── Prossimi passi ─────────────────────────────────────────────────────
out("PROSSIMI PASSI:")
if score >= 70:
out(" → conversione con marker funzionerà bene")
elif score >= 40:
out(" → conversione possibile, attendi più errori nella revisione")
else:
out(" → qualità bassa — valuta una fonte PDF migliore")
attention_pages = sorted({p for p, _ in issues if p > 0})
if attention_pages:
sample = ", ".join(str(p) for p in attention_pages[:10])
if len(attention_pages) > 10:
sample += f" … e altre {len(attention_pages) - 10}"
out(f" → attenzione alle pagine {sample} nella revisione manuale")
out()
_maybe_save(lines, path, save)
def _maybe_save(lines: list, pdf_path: Path, save: bool) -> None:
if not save:
return
script_dir = Path(__file__).parent
out_file = script_dir / f"{pdf_path.stem}_step1_report.txt"
out_file.write_text("\n".join(lines), encoding="utf-8")
print(f"Report salvato in: {out_file}")
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
sources_dir = project_root / "sources"
if not sources_dir.exists():
print(f"Errore: cartella sources/ non trovata in {project_root}")
sys.exit(1)
pdfs = sorted(sources_dir.glob("*.pdf"))
if not pdfs:
print(f"Errore: nessun PDF trovato in {sources_dir}")
sys.exit(1)
for pdf in pdfs:
inspect_pdf(str(pdf), save=True)
if len(pdfs) > 1:
print("-" * 50)
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""
Step 2 Conversione PDF Markdown grezzo
Usa pymupdf4llm (PyMuPDF puro C, zero modelli ML, ~30-50 MB RAM)
per convertire ogni PDF in sources/ e organizza l'output in:
step-2/<stem>/raw.md MD grezzo, non modificare mai
step-2/<stem>/clean.md copia di lavoro per lo step 4
Uso:
python step-2/convert_pdf.py # tutti i PDF in sources/
python step-2/convert_pdf.py --pdf sources/doc.pdf # un solo PDF
"""
import argparse
import shutil
import sys
from pathlib import Path
import pymupdf4llm
def convert_pdf(pdf_path: Path, project_root: Path) -> bool:
stem = pdf_path.stem
out_dir = project_root / "step-2" / stem
raw_md = out_dir / "raw.md"
clean_md = out_dir / "clean.md"
print(f"\nConversione: {pdf_path.name}")
print(f" Output: step-2/{stem}/")
if raw_md.exists():
print(f" ⚠️ raw.md già presente — skip")
print(f" (elimina {raw_md} per riconvertire)")
return True
out_dir.mkdir(parents=True, exist_ok=True)
print(f" Conversione in corso...")
md_text = pymupdf4llm.to_markdown(str(pdf_path))
raw_md.write_text(md_text, encoding="utf-8")
shutil.copy2(raw_md, clean_md)
size_kb = raw_md.stat().st_size // 1024
print(f" ✅ raw.md salvato ({size_kb} KB)")
print(f" ✅ clean.md creato (copia di lavoro per step 4)")
return True
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(description="Step 2 — Conversione PDF → Markdown")
parser.add_argument("--pdf", help="Percorso di un singolo PDF da convertire")
args = parser.parse_args()
if args.pdf:
pdf_path = Path(args.pdf)
if not pdf_path.exists():
print(f"Errore: file non trovato — {args.pdf}")
sys.exit(1)
pdfs = [pdf_path]
else:
sources_dir = project_root / "sources"
if not sources_dir.exists():
print(f"Errore: cartella sources/ non trovata in {project_root}")
sys.exit(1)
pdfs = sorted(sources_dir.glob("*.pdf"))
if not pdfs:
print(f"Errore: nessun PDF trovato in {sources_dir}")
sys.exit(1)
results = [convert_pdf(p, project_root) for p in pdfs]
ok_count = sum(results)
total = len(results)
print(f"\n{'' if all(results) else '⚠️ '} {ok_count}/{total} PDF convertiti")
sys.exit(0 if all(results) else 1)
-223
View File
@@ -1,223 +0,0 @@
#!/usr/bin/env python3
"""
Step 3 Rilevamento struttura Markdown
Analizza il Markdown grezzo prodotto dallo step 2 senza modificarlo.
Copia i file da step-2/<stem>/ e produce structure_profile.json che
guida la revisione manuale (step 4) e il chunker adattivo (step 5).
Output in step-3/<stem>/:
raw.md copia da step-2 (non modificare mai)
clean.md copia da step-2 (da revisionare nello step 4)
structure_profile.json profilo strutturale
Uso:
python step-3/detect_structure.py # tutti i documenti in step-2/
python step-3/detect_structure.py --stem nietzsche # un solo documento
python step-3/detect_structure.py --force # riesegui anche se già presente
"""
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
# ─── Language detection ───────────────────────────────────────────────────────
_IT_WORDS = frozenset([
"il", "la", "di", "e", "che", "non", "per", "un", "una", "si",
"con", "da", "del", "della", "dei", "in", "ma", "se", "lo", "le",
"gli", "al", "alla", "ai", "alle", "sono", "ha", "hanno", "era",
"erano", "nel", "nella", "nei", "nelle", "questo", "questa", "così",
])
_EN_WORDS = frozenset([
"the", "of", "and", "to", "in", "is", "that", "it", "was", "for",
"on", "are", "as", "with", "his", "they", "at", "be", "this", "have",
"from", "or", "an", "but", "not", "by", "he", "she", "we", "you",
"which", "their", "been", "has", "would", "there", "when", "will",
])
def detect_language(text: str) -> str:
words = re.findall(r'\b[a-zA-Z]{2,}\b', text.lower())
sample = words[:2000]
it = sum(1 for w in sample if w in _IT_WORDS)
en = sum(1 for w in sample if w in _EN_WORDS)
if it == 0 and en == 0:
return "unknown"
return "it" if it >= en else "en"
# ─── Markdown parsing ─────────────────────────────────────────────────────────
def split_sections(text: str, header_level: int) -> list[str]:
"""
Split text on headers of the given level (1=h1, 2=h2, 3=h3).
Returns list of body texts for each matching section.
"""
prefix = "#" * header_level + " "
parts = re.split(rf'(?m)^{re.escape(prefix)}.+', text)
# parts[0] is preamble, rest are section bodies
return [p for p in parts[1:] if p.strip()]
def count_headers(text: str, level: int) -> int:
prefix = "#" * level + " "
return len(re.findall(rf'(?m)^{re.escape(prefix)}', text))
def count_paragraphs(text: str) -> int:
"""Count non-empty, non-header paragraph blocks."""
blocks = re.split(r'\n{2,}', text)
return sum(1 for b in blocks if b.strip() and not re.match(r'^#+\s', b.strip()))
# ─── Core analysis ────────────────────────────────────────────────────────────
def analyze(raw_md_path: Path) -> dict:
text = raw_md_path.read_text(encoding="utf-8")
n_h1 = count_headers(text, 1)
n_h2 = count_headers(text, 2)
n_h3 = count_headers(text, 3)
n_paragrafi = count_paragraphs(text)
# Determine structural level and primary boundary
if n_h3 >= 5:
livello = 3
boundary = "h3"
strategia = "h3_aware"
section_bodies = split_sections(text, 3)
elif n_h2 >= 3:
livello = 2
boundary = "h2"
strategia = "h2_paragraph_split"
section_bodies = split_sections(text, 2)
elif n_h1 + n_h2 + n_h3 >= 1:
livello = 1
boundary = "paragrafo"
strategia = "paragraph"
section_bodies = [b for b in re.split(r'\n{2,}', text) if b.strip()]
else:
if n_paragrafi >= 3:
livello = 1
boundary = "paragrafo"
strategia = "paragraph"
section_bodies = [b for b in re.split(r'\n{2,}', text) if b.strip()]
else:
livello = 0
boundary = "nessuno"
strategia = "sliding_window"
section_bodies = [text] if text.strip() else []
lengths = [len(b) for b in section_bodies if b.strip()]
lunghezza_media = int(sum(lengths) / len(lengths)) if lengths else 0
lingua = detect_language(text)
avvertenze = []
short = sum(1 for l in lengths if l < 200)
long_ = sum(1 for l in lengths if l > 800)
if short:
avvertenze.append(f"{short} sezioni sotto i 200 caratteri — verranno accorpate")
if long_:
avvertenze.append(f"{long_} sezioni sopra i 800 caratteri — verranno divise")
return {
"livello_struttura": livello,
"n_h1": n_h1,
"n_h2": n_h2,
"n_h3": n_h3,
"n_paragrafi": n_paragrafi,
"boundary_primario": boundary,
"lingua_rilevata": lingua,
"lunghezza_media_sezione": lunghezza_media,
"strategia_chunking": strategia,
"avvertenze": avvertenze,
}
# ─── Per-document processing ─────────────────────────────────────────────────
def process_stem(stem: str, project_root: Path, force: bool) -> bool:
src_dir = project_root / "step-2" / stem
out_dir = project_root / "step-3" / stem
raw_src = src_dir / "raw.md"
clean_src = src_dir / "clean.md"
profile_out = out_dir / "structure_profile.json"
print(f"\nDocumento: {stem}")
if not raw_src.exists():
print(f" ✗ raw.md non trovato in step-2/{stem}/ — skip")
return False
if profile_out.exists() and not force:
print(f" ⚠️ structure_profile.json già presente — skip")
print(f" (usa --force per rieseguire)")
return True
out_dir.mkdir(parents=True, exist_ok=True)
# Copy files from step-2
shutil.copy2(raw_src, out_dir / "raw.md")
if clean_src.exists():
shutil.copy2(clean_src, out_dir / "clean.md")
print(f" Copiati raw.md e clean.md da step-2/{stem}/")
# Analyze
print(f" Analisi struttura in corso...")
profile = analyze(out_dir / "raw.md")
profile_out.write_text(json.dumps(profile, ensure_ascii=False, indent=2), encoding="utf-8")
# Report
_LIVELLO_DESC = {
3: "struttura ricca (###)",
2: "struttura parziale (##)",
1: "solo paragrafi",
0: "testo piatto",
}
print(f" ✅ Livello {profile['livello_struttura']}{_LIVELLO_DESC[profile['livello_struttura']]}")
print(f" h1={profile['n_h1']} h2={profile['n_h2']} h3={profile['n_h3']} paragrafi={profile['n_paragrafi']}")
print(f" Boundary: {profile['boundary_primario']} | Strategia: {profile['strategia_chunking']}")
print(f" Lingua: {profile['lingua_rilevata']} | Lunghezza media sezione: {profile['lunghezza_media_sezione']} char")
for w in profile["avvertenze"]:
print(f" ⚠️ {w}")
print(f" ✅ structure_profile.json salvato")
return True
# ─── Entry point ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(description="Step 3 — Rilevamento struttura Markdown")
parser.add_argument("--stem", help="Nome del documento (sottocartella di step-2/)")
parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente")
args = parser.parse_args()
if args.stem:
stems = [args.stem]
else:
step2_dir = project_root / "step-2"
if not step2_dir.exists():
print(f"Errore: cartella step-2/ non trovata in {project_root}")
sys.exit(1)
stems = sorted(p.name for p in step2_dir.iterdir() if p.is_dir())
if not stems:
print(f"Errore: nessun documento trovato in step-2/")
sys.exit(1)
results = [process_stem(s, project_root, args.force) for s in stems]
ok = sum(results)
total = len(results)
print(f"\n{'' if all(results) else '⚠️ '} {ok}/{total} documenti analizzati")
sys.exit(0 if all(results) else 1)
-433
View File
@@ -1,433 +0,0 @@
#!/usr/bin/env python3
"""
Step 4 Revisione automatica del Markdown
Trasforma clean.md da step-3 rivelando la struttura latente del documento.
Le trasformazioni sono euristiche universali che funzionano su qualsiasi PDF:
- Normalizza whitespace multiplo (artefatto PDF)
- Riduce righe vuote multiple
- Rimuove marcatori **bold** nelle intestazioni esistenti
- Converte righe ALL-CAPS standalone ## header (euristico, qualsiasi lingua)
- Converte sezioni numerate "N. testo" ### N. (qualsiasi numerazione)
- Rimuove blocchi TOC (righe che iniziano con parole-chiave indice)
Per ogni documento viene ricalcolato il profilo strutturale: il livello può
salire (es. livello 1 3) se le strutture latenti vengono rilevate.
Output in step-4/<stem>/:
raw.md copia da step-3 (non modificare mai)
clean.md MD revisionato
structure_profile.json profilo aggiornato dopo la revisione
Uso:
python step-4/revise.py # tutti i documenti in step-3/
python step-4/revise.py --stem nietzsche # un solo documento
python step-4/revise.py --force # riesegui anche se già presente
"""
import argparse
import json
import re
import shutil
import sys
from datetime import date
from pathlib import Path
# Riusa la funzione analyze() già scritta nello step 3
sys.path.insert(0, str(Path(__file__).parent.parent / "step-3"))
from detect_structure import analyze # noqa: E402
# ─── Costanti ─────────────────────────────────────────────────────────────────
# Parole-chiave che identificano blocchi TOC (da rimuovere)
_TOC_KEYWORDS = frozenset([
"indice", "index", "contents", "table of contents",
"sommario", "inhaltsverzeichnis", "inhalt",
])
# Preposizioni/articoli da non capitalizzare nel title-case
_STOP_IT_EN = frozenset([
# italiano
"di", "del", "della", "dei", "delle", "da", "in", "e", "il", "la",
"lo", "le", "gli", "un", "una", "per", "a", "al", "alla", "ai",
"alle", "con", "su", "sul", "sulla", "che", "o",
# inglese
"of", "the", "a", "an", "and", "or", "but", "in", "on", "at",
"to", "for", "with", "by", "from", "as",
])
# Ordinali italiani → romani (per titoli come "CAPITOLO PRIMO")
_ORDINALS_IT = {
"PRIMO": "I", "SECONDO": "II", "TERZO": "III", "QUARTO": "IV",
"QUINTO": "V", "SESTO": "VI", "SETTIMO": "VII", "OTTAVO": "VIII",
"NONO": "IX", "DECIMO": "X",
}
# Ordinali inglesi → arabici (per "CHAPTER ONE")
_ORDINALS_EN = {
"ONE": "1", "TWO": "2", "THREE": "3", "FOUR": "4", "FIVE": "5",
"SIX": "6", "SEVEN": "7", "EIGHT": "8", "NINE": "9", "TEN": "10",
}
# ─── Utilità ──────────────────────────────────────────────────────────────────
def _sentence_case(s: str) -> str:
"""
Sentence-case: prima lettera maiuscola, resto minuscolo.
Corretto per l'italiano e accettabile per l'inglese accademico.
"""
if not s:
return s
lower = s.lower()
return lower[0].upper() + lower[1:]
def _is_allcaps_line(line: str) -> bool:
"""
True se la riga è una candidata per conversione a ## header.
Criterio: tutti i caratteri alfabetici sono maiuscoli, lunghezza >= 3.
"""
stripped = line.strip()
letters = [c for c in stripped if c.isalpha()]
return (
len(letters) >= 3
and all(c.isupper() for c in letters)
and not stripped.startswith("#")
)
def _allcaps_to_header(raw_line: str) -> str:
"""
Converte una riga ALL-CAPS in un ## header title-case.
Riconosce pattern specifici (CAPITOLO ORDINE, CHAPTER N) come bonus,
ma funziona in modalità generica su qualsiasi testo.
"""
text = raw_line.strip().rstrip('.').rstrip('?').strip()
# ── Pattern italiano: "CAPITOLO PRIMO. TITOLO DEL CAPITOLO"
_ORD_IT_PAT = "|".join(_ORDINALS_IT.keys())
m = re.match(rf'^CAPITOLO ({_ORD_IT_PAT})\. (.+)', text)
if m:
roman = _ORDINALS_IT[m.group(1)]
titolo = m.group(2).rstrip('.').rstrip('?').strip()
return f"## Capitolo {roman}{_sentence_case(titolo)}"
# ── Pattern inglese: "CHAPTER ONE. TITLE" o "CHAPTER 1. TITLE"
_ORD_EN_PAT = "|".join(_ORDINALS_EN.keys())
m = re.match(rf'^CHAPTER ({_ORD_EN_PAT}|\d+)\.? (.+)', text)
if m:
n = _ORDINALS_EN.get(m.group(1), m.group(1))
titolo = m.group(2).rstrip('.').rstrip('?').strip()
return f"## Chapter {n}{_sentence_case(titolo)}"
# ── Pattern generico con numerazione romana o arabica nel prefisso
m = re.match(r'^([IVXLCDM]+|[0-9]+)\. (.+)', text)
if m:
n = m.group(1)
titolo = m.group(2).rstrip('.').strip()
return f"## {n}. {_sentence_case(titolo)}"
# ── Caso generico: tutto maiuscolo senza pattern riconoscibile
return f"## {_sentence_case(text)}"
def _is_toc_line(line: str) -> bool:
"""True se la riga è l'intestazione di un blocco indice/TOC."""
first_word = line.strip().split('.')[0].strip().lower()
return first_word in _TOC_KEYWORDS
# ─── Trasformazioni ────────────────────────────────────────────────────────────
def apply_transforms(text: str) -> tuple[str, dict]:
"""
Applica tutte le trasformazioni strutturali al testo MD.
Restituisce (testo_modificato, statistiche).
"""
stats = {
"toc_rimosso": False,
"n_header_allcaps": 0,
"n_sezioni_numerate": 0,
"n_paragrafi_uniti": 0,
}
# ── 1. Rimuovi marcatori **bold** nelle intestazioni esistenti
# ## **Titolo** → ## Titolo
text = re.sub(
r'^(#{1,6})\s+\*\*(.+?)\*\*\s*$',
r'\1 \2',
text, flags=re.MULTILINE,
)
# ── 1b. Normalizza header esistenti con contenuto ALL-CAPS → sentence-case
# ## AL DI LA' DEL BENE E DEL MALE → ## Al di la' del bene e del male
def _norm_allcaps_header(m: re.Match) -> str:
hashes = m.group(1)
content = m.group(2).strip()
letters = [c for c in content if c.isalpha()]
if letters and all(c.isupper() for c in letters):
return f"{hashes} {_sentence_case(content)}"
return m.group(0)
text = re.sub(
r'^(#{1,6}) (.+)$',
_norm_allcaps_header,
text, flags=re.MULTILINE,
)
# ── 2. Rimuovi blocco TOC (riga indice + contenuto inline sulla stessa riga)
# "INDICE. Capitolo 1 Capitolo 2 ..." → rimossa
lines = text.split('\n')
new_lines = []
for line in lines:
if _is_toc_line(line):
stats["toc_rimosso"] = True
else:
new_lines.append(line)
text = '\n'.join(new_lines)
# ── 3. Converti righe ALL-CAPS standalone → ## header
# Una riga è "standalone" se è preceduta/seguita da riga vuota
# oppure si trova all'inizio/fine del documento.
blocks = text.split('\n\n')
new_blocks = []
for block in blocks:
stripped = block.strip()
# Blocco standalone = un'unica riga (nessun \n interno rilevante)
if '\n' not in stripped and _is_allcaps_line(stripped):
new_blocks.append(_allcaps_to_header(stripped))
stats["n_header_allcaps"] += 1
else:
# Controlla riga per riga per righe ALL-CAPS seguite da altri contenuti
sub_lines = block.split('\n')
converted = []
for ln in sub_lines:
if _is_allcaps_line(ln) and len(ln.strip()) > 3:
converted.append(_allcaps_to_header(ln))
stats["n_header_allcaps"] += 1
else:
converted.append(ln)
new_blocks.append('\n'.join(converted))
text = '\n\n'.join(new_blocks)
# ── 4. Converti sezioni numerate "N. testo" → "### N.\n\ntesto"
# Riconosce: "1. Testo", "42. Testo" (due o più spazi dopo il punto)
def _num_repl(m: re.Match) -> str:
num = m.group(1)
testo = m.group(2).strip()
stats["n_sezioni_numerate"] += 1
return f"### {num}.\n\n{testo}"
# Pattern standard: "1. testo" o "1. testo"
text = re.sub(
r'^(\d+)\.\s+(.+)$',
_num_repl,
text, flags=re.MULTILINE,
)
# Pattern con lettera-suffisso: "65 a. testo" o "65a. testo"
def _num_letter_repl(m: re.Match) -> str:
num = m.group(1) + m.group(2)
testo = m.group(3).strip()
stats["n_sezioni_numerate"] += 1
return f"### {num}.\n\n{testo}"
text = re.sub(
r'^(\d+)\s*([a-z])\.\s+(.+)$',
_num_letter_repl,
text, flags=re.MULTILINE,
)
# ── 5. Unisci paragrafi spezzati da salti pagina PDF
# Criterio: blocco A non finisce con punteggiatura di fine frase,
# blocco B non inizia con maiuscola "di sezione" né è un header.
# Unione sicura: mai attraverso confini ###/##.
_SENTENCE_END = set('.?!»)\'"')
blocks = text.split('\n\n')
merged = []
i = 0
while i < len(blocks):
b = blocks[i]
stripped = b.strip()
# Prova a unire con il successivo se la frase è spezzata
while (
i + 1 < len(blocks)
and stripped
and not stripped.startswith('#')
and stripped[-1] not in _SENTENCE_END
):
nxt = blocks[i + 1].strip()
# Non unire se il successivo è un header o è vuoto
if not nxt or nxt.startswith('#'):
break
# Non unire se il successivo inizia con una cifra seguita da punto
# (sarebbe l'inizio di un nuovo aforisma non ancora convertito)
if re.match(r'^\d+\.', nxt):
break
b = stripped + ' ' + nxt
stripped = b.strip()
stats["n_paragrafi_uniti"] += 1
i += 1
merged.append(b)
i += 1
text = '\n\n'.join(merged)
# ── 6. Normalizza whitespace multiplo interno alle righe
# "parola parola" → "parola parola" (inclusi gli header)
lines = text.split('\n')
normalized = []
for line in lines:
if not line.strip():
normalized.append(line)
else:
normalized.append(re.sub(r' +', ' ', line))
text = '\n'.join(normalized)
# ── 7. Riduci righe vuote multiple a doppie
text = re.sub(r'\n{3,}', '\n\n', text)
return text, stats
# ─── Aggiornamento revision log ────────────────────────────────────────────────
def update_revision_log(
log_path: Path,
stem: str,
profile_before: dict,
profile_after: dict,
t_stats: dict,
) -> None:
header_exists = log_path.exists() and log_path.stat().st_size > 0
avv = profile_after.get("avvertenze", [])
avv_str = "; ".join(avv) if avv else "nessuna"
entry = f"""
## {stem} — {date.today().isoformat()}
**Trasformazioni automatiche:**
- Normalizzazione whitespace multiplo e righe vuote
- Blocco TOC rimosso: {'' if t_stats['toc_rimosso'] else 'no'}
- Righe ALL-CAPS ## header: {t_stats['n_header_allcaps']}
- Sezioni numerate ### header: {t_stats['n_sezioni_numerate']}
- Paragrafi uniti (salti pagina PDF): {t_stats['n_paragrafi_uniti']}
- Livello struttura: {profile_before.get('livello_struttura', '?')} {profile_after.get('livello_struttura', '?')}
**Avvertenze residue:** {avv_str}
**Revisioni manuali pendenti:**
- [ ] Verificare conversioni ALL-CAPS errate
- [ ] Controllare sezioni troppo corte o troppo lunghe
"""
if not header_exists:
log_path.write_text("# Revision log\n" + entry, encoding="utf-8")
else:
existing = log_path.read_text(encoding="utf-8")
log_path.write_text(existing + entry, encoding="utf-8")
# ─── Per-document processing ─────────────────────────────────────────────────
def process_stem(stem: str, project_root: Path, force: bool) -> bool:
src_dir = project_root / "step-3" / stem
out_dir = project_root / "step-4" / stem
raw_src = src_dir / "raw.md"
clean_src = src_dir / "clean.md"
profile_src = src_dir / "structure_profile.json"
clean_out = out_dir / "clean.md"
profile_out = out_dir / "structure_profile.json"
print(f"\nDocumento: {stem}")
if not clean_src.exists():
print(f" ✗ clean.md non trovato in step-3/{stem}/ — skip")
return False
if clean_out.exists() and not force:
print(f" ⚠️ clean.md già presente — skip")
print(f" (usa --force per rieseguire)")
return True
out_dir.mkdir(parents=True, exist_ok=True)
# Copia raw.md immutabile (riferimento)
if raw_src.exists():
shutil.copy2(raw_src, out_dir / "raw.md")
print(f" Copiato raw.md da step-3/{stem}/")
# Leggi profilo step-3 (per confronto nel report)
profile_before: dict = {}
if profile_src.exists():
profile_before = json.loads(profile_src.read_text(encoding="utf-8"))
# Applica trasformazioni
print(f" Applicazione trasformazioni strutturali...")
text = clean_src.read_text(encoding="utf-8")
text_revised, t_stats = apply_transforms(text)
# Salva clean.md revisionato
clean_out.write_text(text_revised, encoding="utf-8")
# Ricalcola profilo sul nuovo clean.md
profile_after = analyze(clean_out)
profile_out.write_text(
json.dumps(profile_after, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Report
lv_b = profile_before.get("livello_struttura", "?")
lv_a = profile_after["livello_struttura"]
_STRAT = {3: "h3_aware", 2: "h2_paragraph_split", 1: "paragraph", 0: "sliding_window"}
print(f" ✅ Livello struttura: {lv_b}{lv_a} ({_STRAT.get(lv_a, '?')})")
print(f" h2: {profile_before.get('n_h2','?')}{profile_after['n_h2']}")
print(f" h3: {profile_before.get('n_h3','?')}{profile_after['n_h3']}")
print(f" TOC rimosso: {'' if t_stats['toc_rimosso'] else 'no'}")
print(f" Righe ALL-CAPS → ##: {t_stats['n_header_allcaps']}")
print(f" Sezioni numerate → ###: {t_stats['n_sezioni_numerate']}")
print(f" Paragrafi uniti (salti pagina): {t_stats['n_paragrafi_uniti']}")
for w in profile_after["avvertenze"]:
print(f" ⚠️ {w}")
# Aggiorna revision log (direttamente in step-4/, non in sottocartella)
log_path = project_root / "step-4" / "revision_log.md"
update_revision_log(log_path, stem, profile_before, profile_after, t_stats)
print(f" ✅ step-4/revision_log.md aggiornato")
print(f" ✅ structure_profile.json salvato")
return True
# ─── Entry point ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(description="Step 4 — Revisione automatica Markdown")
parser.add_argument("--stem", help="Nome del documento (sottocartella di step-3/)")
parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente")
args = parser.parse_args()
if args.stem:
stems = [args.stem]
else:
step3_dir = project_root / "step-3"
if not step3_dir.exists():
print(f"Errore: cartella step-3/ non trovata in {project_root}")
sys.exit(1)
stems = sorted(p.name for p in step3_dir.iterdir() if p.is_dir())
if not stems:
print(f"Errore: nessun documento trovato in step-3/")
sys.exit(1)
results = [process_stem(s, project_root, args.force) for s in stems]
ok = sum(results)
total = len(results)
print(f"\n{'' if all(results) else '⚠️ '} {ok}/{total} documenti revisionati")
sys.exit(0 if all(results) else 1)
+16 -16
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Step 5 Chunking adattivo Chunking adattivo
Divide il Markdown revisionato (step 4) in chunk semantici pronti per la Divide il Markdown revisionato in chunk semantici pronti per la
vettorizzazione. La strategia dipende dal profilo strutturale del documento. vettorizzazione. La strategia dipende dal profilo strutturale del documento.
Input: step-4/<stem>/clean.md + step-4/<stem>/structure_profile.json Input: conversione/<stem>/clean.md + conversione/<stem>/structure_profile.json
Output: step-5/<stem>/chunks.json Output: step-5/<stem>/chunks.json
Uso: Uso:
python step-5/chunker.py # tutti i documenti in step-4/ python step-5/chunker.py # tutti i documenti in conversione/
python step-5/chunker.py --stem documento # un solo documento python step-5/chunker.py --stem documento # un solo documento
python step-5/chunker.py --stem documento --force python step-5/chunker.py --stem documento --force
""" """
@@ -375,19 +375,19 @@ def chunk_document(clean_md: Path, profile: dict, stem: str) -> list[dict]:
# ─── Per-document processing ────────────────────────────────────────────────── # ─── Per-document processing ──────────────────────────────────────────────────
def process_stem(stem: str, project_root: Path, force: bool) -> bool: def process_stem(stem: str, project_root: Path, force: bool) -> bool:
step4_dir = project_root / "step-4" / stem conv_dir = project_root / "conversione" / stem
out_dir = project_root / "step-5" / stem out_dir = project_root / "step-5" / stem
clean_md = step4_dir / "clean.md" clean_md = conv_dir / "clean.md"
profile_path = step4_dir / "structure_profile.json" profile_path = conv_dir / "structure_profile.json"
out_file = out_dir / "chunks.json" out_file = out_dir / "chunks.json"
print(f"\nDocumento: {stem}") print(f"\nDocumento: {stem}")
if not clean_md.exists(): if not clean_md.exists():
print(f" ✗ clean.md non trovato in step-4/{stem}/ — skip") print(f" ✗ clean.md non trovato in conversione/{stem}/ — skip")
return False return False
if not profile_path.exists(): if not profile_path.exists():
print(f" ✗ structure_profile.json non trovato in step-4/{stem}/ — skip") print(f" ✗ structure_profile.json non trovato in conversione/{stem}/ — skip")
return False return False
if out_file.exists() and not force: if out_file.exists() and not force:
@@ -432,21 +432,21 @@ def process_stem(stem: str, project_root: Path, force: bool) -> bool:
if __name__ == "__main__": if __name__ == "__main__":
project_root = Path(__file__).parent.parent project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(description="Step 5 — Chunking adattivo") parser = argparse.ArgumentParser(description="Chunking adattivo")
parser.add_argument("--stem", help="Nome del documento (sottocartella di step-4/)") parser.add_argument("--stem", help="Nome del documento (sottocartella di conversione/)")
parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente") parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente")
args = parser.parse_args() args = parser.parse_args()
if args.stem: if args.stem:
stems = [args.stem] stems = [args.stem]
else: else:
step4_dir = project_root / "step-4" conv_dir = project_root / "conversione"
if not step4_dir.exists(): if not conv_dir.exists():
print(f"Errore: cartella step-4/ non trovata in {project_root}") print(f"Errore: cartella conversione/ non trovata in {project_root}")
sys.exit(1) sys.exit(1)
stems = sorted(p.name for p in step4_dir.iterdir() if p.is_dir()) stems = sorted(p.name for p in conv_dir.iterdir() if p.is_dir() and (p / "clean.md").exists())
if not stems: if not stems:
print(f"Errore: nessun documento trovato in step-4/") print(f"Errore: nessun documento trovato in conversione/")
sys.exit(1) sys.exit(1)
results = [process_stem(s, project_root, args.force) for s in stems] results = [process_stem(s, project_root, args.force) for s in stems]
+4 -4
View File
@@ -179,8 +179,8 @@ def verify_stem(stem: str, project_root: Path, min_chars: int, max_chars: int) -
print(f" [{c.get('chunk_id', '?')}] ...{last_line!r}") print(f" [{c.get('chunk_id', '?')}] ...{last_line!r}")
if len(incomplete) > 5: if len(incomplete) > 5:
print(f" ... e altri {len(incomplete) - 5}") print(f" ... e altri {len(incomplete) - 5}")
print(f" → Causa probabile: paragrafo spezzato nel MD (step 4)") print(f" → Causa probabile: paragrafo spezzato nel MD")
print(f" → Soluzione: correggi le righe spezzate in step-4/{stem}/clean.md") print(f" → Soluzione: correggi le righe spezzate in conversione/{stem}/clean.md")
# ── Costruisci e salva report.json ─────────────────────────────────────── # ── Costruisci e salva report.json ───────────────────────────────────────
@@ -263,10 +263,10 @@ def verify_stem(stem: str, project_root: Path, min_chars: int, max_chars: int) -
print() print()
if empty_chunks: if empty_chunks:
print(f"{len(empty_chunks)} chunk vuoti") print(f"{len(empty_chunks)} chunk vuoti")
print(f" → Controlla step-4/{stem}/clean.md per sezioni prive di testo") print(f" → Controlla conversione/{stem}/clean.md per sezioni prive di testo")
if no_prefix: if no_prefix:
print(f"{len(no_prefix)} chunk senza prefisso di contesto") print(f"{len(no_prefix)} chunk senza prefisso di contesto")
print(f" → Controlla che gli header ### siano corretti in step-4/{stem}/clean.md") print(f" → Controlla che gli header ### siano corretti in conversione/{stem}/clean.md")
if incomplete: if incomplete:
print(f"{len(incomplete)} chunk con frase spezzata") print(f"{len(incomplete)} chunk con frase spezzata")
print(f" → Esegui: python step-6/fix_chunks.py --stem {stem}") print(f" → Esegui: python step-6/fix_chunks.py --stem {stem}")
-147
View File
@@ -1,147 +0,0 @@
# Step 7 — Verifica ambiente
Prima di procedere con la vettorizzazione (step 8) devi avere installato:
- **Ollama** — server locale per LLM e embedding
- un **modello di embedding** (es. `nomic-embed-text`, `bge-m3`)
- un **modello LLM** (es. `qwen3.5:4b`, `qwen3:4b`)
- **chromadb** — libreria Python per il vector store
---
## 1. Installa Ollama
```bash
curl -fsSL https://ollama.com/install.sh | sh
```
Verifica che il servizio sia attivo:
```bash
ollama list
```
### Disinstalla Ollama
```bash
# Ferma e rimuovi il servizio systemd
sudo systemctl stop ollama
sudo systemctl disable ollama
sudo rm /etc/systemd/system/ollama.service
sudo systemctl daemon-reload
# Rimuovi il binario
sudo rm /usr/local/bin/ollama
# Rimuovi modelli e dati (opzionale — occupa spazio su disco)
# I modelli sono salvati sotto l'utente di sistema "ollama", non nella tua home
sudo rm -rf /usr/share/ollama
# Rimuovi l'utente e il gruppo di sistema creati dall'installer (opzionale)
sudo userdel ollama
sudo groupdel ollama
```
---
## 2. Scarica i modelli
### Modello di embedding
Per testi in italiano serve un modello multilingue — i modelli English-first producono embeddings di qualità inferiore su lingue diverse dall'inglese, con retrieval meno preciso.
Prima scelta consigliata:
```bash
ollama pull qwen3-embedding:0.6b
```
Stessa famiglia del LLM in uso (`qwen3.5`), multilingue, recente, gira comodamente in CPU.
| Modello | Dim | Dimensione | Lingue | Consigliato |
|---|---|---|---|---|
| `qwen3-embedding:0.6b` | 1024 | ~522 MB | multilingue | ✅ prima scelta |
| `nomic-embed-text-v2-moe` | 768 | ~523 MB | multilingue | ✅ seconda scelta |
| `bge-m3` | 1024 | ~1.2 GB | 100+ lingue incl. IT | ✅ terza scelta |
| `nomic-embed-text` | 768 | ~274 MB | principalmente EN | ⚠️ default corrente |
| `mxbai-embed-large` | 1024 | ~670 MB | principalmente EN | ❌ |
| `paraphrase-multilingual` | 768 | ~278 MB | multilingue | ❌ obsoleto |
| `all-minilm` | 384 | ~46 MB | principalmente EN | ❌ troppo piccolo |
Se cambi modello rispetto a quello usato in step-8, devi rieseguire la vettorizzazione con `--force` e aggiornare `EMBED_MODEL` in `step-9/config.py`.
### Modello LLM
Per RAG su testi italiani servono: buon instruction following, supporto multilingue e context window ampia (i prompt RAG includono più chunk).
Prima scelta consigliata per 8 GB RAM:
```bash
ollama pull qwen3.5:4b
```
Il progetto è pensato per la famiglia **Qwen3.5** — stessa famiglia dell'embedding consigliato (`qwen3-embedding`), context window 256K, ottimo italiano. Altri modelli sono compatibili ma non testati.
| Modello | RAM | Note |
|---|---|---|
| `qwen3.5:0.8b` | ≥ 1 GB | minimo assoluto |
| `qwen3.5:2b` | ≥ 3 GB | leggero |
| `qwen3.5:4b` | ≥ 5 GB | **consigliato per 8 GB** |
| `qwen3.5:9b` | ≥ 8 GB | lento su CPU, meglio con GPU |
Se usi un modello diverso da `qwen3.5:4b`, aggiorna `OLLAMA_MODEL` in `step-9/config.py`.
### Disinstalla un modello
```bash
ollama rm qwen3.5:4b
ollama rm nomic-embed-text
```
Per vedere tutti i modelli installati:
```bash
ollama list
```
---
## 3. Installa le dipendenze nel venv
Assicurati di avere `chromadb` installato nel `.venv`:
```bash
source .venv/bin/activate
pip install -r requirements.txt
```
---
## 4. Verifica tutto
```bash
source .venv/bin/activate
python step-7/check_env.py
```
Output atteso se tutto è a posto:
```
✅ ollama trovato nel PATH
✅ ollama risponde correttamente
✅ modello embedding trovato: nomic-embed-text:latest
✅ modello LLM trovato: qwen3.5:4b
✅ chromadb importabile
✅ Ambiente pronto — procedi con la vettorizzazione:
python step-8/ingest.py --stem <nome>
```
---
## Prossimo step
```bash
python step-8/ingest.py --stem <nome>
```
+8 -8
View File
@@ -15,14 +15,14 @@ salva in ChromaDB (vector store persistente su disco).
## Configurazione modello ## Configurazione modello
Il modello di embedding viene letto da **`step-9/config.py`**: Il modello di embedding viene letto da **`config.py`**:
```python ```python
# step-9/config.py # config.py
EMBED_MODEL = "nomic-embed-text" # ← cambia qui EMBED_MODEL = "nomic-embed-text" # ← cambia qui
``` ```
> Il modello scelto qui deve corrispondere a quello usato in step-9. > Il modello scelto qui deve corrispondere a quello usato in rag.py.
> Se lo cambi dopo aver già vettorizzato, devi rieseguire step-8 con `--force`. > Se lo cambi dopo aver già vettorizzato, devi rieseguire step-8 con `--force`.
--- ---
@@ -54,7 +54,7 @@ distanza coseno. La directory è ignorata da git (generata automaticamente).
## Modelli supportati ## Modelli supportati
Stessi modelli raccomandati nel [README di step-7](../step-7/README.md). Stessi modelli raccomandati nel [README di ollama](../ollama/README.md).
Il modello deve essere scaricato in Ollama prima di eseguire questo script Il modello deve essere scaricato in Ollama prima di eseguire questo script
(`ollama pull <modello>`). (`ollama pull <modello>`).
@@ -81,16 +81,16 @@ Prima scelta: `qwen3-embedding:0.6b`.
`qwen3-embedding` + `qwen3.5` condividono tokenizer e spazio semantico — `qwen3-embedding` + `qwen3.5` condividono tokenizer e spazio semantico —
il retrieval è più coerente rispetto a modelli di famiglie diverse. il retrieval è più coerente rispetto a modelli di famiglie diverse.
### Coerenza tra step-8 e step-9 ### Coerenza tra ingest e retrieval
**`EMBED_MODEL` deve essere identico in step-8 e step-9.** **`EMBED_MODEL` deve essere identico in `ingest.py` e `rag.py`.**
ChromaDB memorizza i vettori generati con un certo modello. Se step-9 usa un ChromaDB memorizza i vettori generati con un certo modello. Se `rag.py` usa un
modello diverso per la query di ricerca, gli spazi vettoriali non corrispondono modello diverso per la query di ricerca, gli spazi vettoriali non corrispondono
e il retrieval restituisce risultati casuali — senza alcun errore visibile. e il retrieval restituisce risultati casuali — senza alcun errore visibile.
**Dopo aver cambiato `EMBED_MODEL`, riesegui sempre con `--force`.** **Dopo aver cambiato `EMBED_MODEL`, riesegui sempre con `--force`.**
Senza `--force` lo script salta la collection già esistente — i vecchi vettori Senza `--force` lo script salta la collection già esistente — i vecchi vettori
(generati col modello precedente) restano e continuano a essere usati da step-9. (generati col modello precedente) restano e continuano a essere usati da `rag.py`.
```bash ```bash
# Cambio modello → ricrea sempre la collection # Cambio modello → ricrea sempre la collection
+4 -6
View File
@@ -5,9 +5,9 @@ Step 8 — Vettorizzazione
Legge i chunk prodotti da step-6, genera gli embedding tramite Ollama Legge i chunk prodotti da step-6, genera gli embedding tramite Ollama
e li indicizza in ChromaDB (persistente). e li indicizza in ChromaDB (persistente).
Il modello di embedding viene letto da step-9/config.py (EMBED_MODEL). Il modello di embedding viene letto da config.py (EMBED_MODEL).
Puoi sovrascriverlo con --model, ma deve corrispondere al modello che Puoi sovrascriverlo con --model, ma deve corrispondere al modello che
userai in step-9 altrimenti riesegui con --force dopo aver cambiato. userai in rag.py altrimenti riesegui con --force dopo aver cambiato.
Input: step-6/<stem>/chunks.json Input: step-6/<stem>/chunks.json
Output: chroma_db/<stem> (collection ChromaDB) Output: chroma_db/<stem> (collection ChromaDB)
@@ -36,9 +36,7 @@ project_root = Path(__file__).parent.parent
CHUNKS_DIR = project_root / "step-6" CHUNKS_DIR = project_root / "step-6"
CHROMA_DIR = project_root / "chroma_db" CHROMA_DIR = project_root / "chroma_db"
# Legge EMBED_MODEL e OLLAMA_URL da step-9/config.py (fonte di verità). sys.path.insert(0, str(project_root))
# Per spostare config.py alla root: cambia solo la riga qui sotto.
sys.path.insert(0, str(project_root / "step-9"))
from config import EMBED_MODEL, OLLAMA_URL # noqa: E402 from config import EMBED_MODEL, OLLAMA_URL # noqa: E402
EMBED_ENDPOINT = f"{OLLAMA_URL}/api/embeddings" EMBED_ENDPOINT = f"{OLLAMA_URL}/api/embeddings"
@@ -205,7 +203,7 @@ def main() -> int:
parser.add_argument("--force", action="store_true", parser.add_argument("--force", action="store_true",
help="Sovrascrive la collection se già esistente") help="Sovrascrive la collection se già esistente")
parser.add_argument("--model", default=EMBED_MODEL, parser.add_argument("--model", default=EMBED_MODEL,
help=f"Modello embedding Ollama (default da step-9/config.py: {EMBED_MODEL})") help=f"Modello embedding Ollama (default da config.py: {EMBED_MODEL})")
args = parser.parse_args() args = parser.parse_args()
print("─── Step 8 — Vettorizzazione ─────────────────────────────────────────\n") print("─── Step 8 — Vettorizzazione ─────────────────────────────────────────\n")
-109
View File
@@ -1,109 +0,0 @@
# Step 9 — Interrogazione del documento
Due modalità di interrogazione, entrambe con loop interattivo:
| Script | Modalità | Quando usarlo |
|---|---|---|
| `rag.py` | Retrieval + generazione LLM | Risposta in linguaggio naturale |
| `retrieve.py` | Solo retrieval (no LLM) | Debug, verifica chunk, ricerca semantica |
---
## Prerequisiti
- Step 8 completato (`chroma_db/` popolata)
- Ollama attivo con il modello di embedding scaricato
- Per `rag.py`: anche il modello LLM scaricato
---
## rag.py — Risposta in linguaggio naturale
```bash
source .venv/bin/activate
python step-9/rag.py --stem <nome>
```
Per ogni domanda: vettorizza la query, recupera i chunk più rilevanti da ChromaDB e genera la risposta tramite Ollama.
```
── Loop RAG ─────────────────────────────────────── (exit per uscire)
Domanda:
```
| Sintassi | Comportamento |
|---|---|
| `<testo>` | Risposta basata sul documento |
| `<testo> -v` | Risposta + chunk recuperati con score di similarità |
| `exit` | Esce dal programma |
Flusso interno:
```
domanda
▼ embed (EMBED_MODEL, Ollama)
vettore N-dim
▼ query ChromaDB — similarità coseno, top-K
chunk rilevanti
▼ build_prompt (SYSTEM_PROMPT + contesti + domanda)
▼ generate (OLLAMA_MODEL, Ollama)
risposta
```
Il LLM risponde esclusivamente dal contesto fornito. Se il contesto è irrilevante rispetto alla domanda, lo dichiara esplicitamente.
---
## retrieve.py — Retrieval puro (senza LLM)
```bash
source .venv/bin/activate
python step-9/retrieve.py --stem <nome>
```
Vettorizza la query e restituisce i chunk più simili con score di similarità — senza chiamare Ollama per la generation. Utile per verificare la qualità del retrieval e diagnosticare risposte sbagliate.
```
── Loop retrieval ──────────────────────── (exit per uscire, -f per testo completo)
Query:
```
| Sintassi | Comportamento |
|---|---|
| `<testo>` | Chunk più simili con score di similarità (testo troncato a 200 car.) |
| `<testo> -f` | Chunk più simili con testo completo |
| `exit` | Esce dal programma |
Accetta `--top-k N` per sovrascrivere il valore di `config.py` per quella sessione.
---
## Configurazione (`config.py`)
| Parametro | Default | Descrizione |
|---|---|---|
| `TOP_K` | `6` | Chunk recuperati per ogni domanda. Valori consigliati: `3``10` |
| `TEMPERATURE` | `0.0` | Deterministico a `0.0`, creativo verso `1.0`. Per RAG consigliato `0.0` |
| `NO_THINK` | `True` | Disabilita il chain-of-thought interno dei modelli Qwen3/Qwen3.5. `True` = risposta diretta, più veloce |
| `EMBED_MODEL` | `"nomic-embed-text"` | Deve corrispondere al modello usato in step-8. Se cambiato, rieseguire step-8 con `--force` |
| `OLLAMA_URL` | `"http://localhost:11434"` | Modifica solo se Ollama gira su porta o host diversi |
| `OLLAMA_MODEL` | `"qwen3.5:0.8b"` | Modello LLM. Vedi `step-7/README.md` per la scelta |
| `SYSTEM_PROMPT` | *(vedi file)* | Istruzioni di comportamento inviate al LLM. Modifica per cambiare tono, lingua o condizione di fallback |
---
## Test senza RAG
Per verificare che Ollama risponda correttamente prima di interrogare il documento:
```bash
python step-9/test_ollama.py
```
Chat diretta con il modello, senza ChromaDB. Usa gli stessi parametri di `config.py`.