Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 444942dc8f | |||
| 3f4689e8fd | |||
| 2c0b7a462e | |||
| 6e755c0b6c | |||
| 9598209f12 | |||
| 64dc403e80 | |||
| afbf29514d | |||
| ab4036591f | |||
| e41fcae248 | |||
| faa8acae84 | |||
| a158634378 | |||
| fe0ecc24ad | |||
| 995a8be735 | |||
| c87a7cb3eb | |||
| 4c0e0db2a5 | |||
| 5215f53ad0 | |||
| 4f28358ec1 |
+7
-20
@@ -26,26 +26,13 @@ __pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Report generati dagli script
|
|
||||||
step-0/*_step0_report.txt
|
|
||||||
step-1/*_step1_report.txt
|
|
||||||
|
|
||||||
# Output step-2 — MD grezzo generato da marker
|
# Output conversione/ — generati dagli script
|
||||||
step-2/*/
|
|
||||||
|
|
||||||
# Output step-3 — profilo struttura generato da detect_structure.py
|
|
||||||
step-3/*/
|
|
||||||
|
|
||||||
# Output step-4 — MD revisionato e log generati da revise.py
|
|
||||||
step-4/*/
|
|
||||||
step-4/revision_log.md
|
|
||||||
|
|
||||||
# Output step-5 — chunk generati da chunker.py
|
|
||||||
step-5/*/
|
|
||||||
|
|
||||||
# Output step-6 — report generati da verify_chunks.py
|
|
||||||
step-6/*/
|
|
||||||
|
|
||||||
# Output conversione/ — generati da conversione/pipeline.py
|
|
||||||
conversione/*/
|
conversione/*/
|
||||||
|
!conversione/_pipeline/
|
||||||
|
!conversione/_pipeline/**
|
||||||
|
conversione/_pipeline/__pycache__/
|
||||||
|
|
||||||
|
# Output chunks/ — generati da chunks/chunker.py e chunks/verify_chunks.py
|
||||||
|
chunks/*/
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,167 @@
|
|||||||
# CLAUDE.md — RAG from Scratch
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Missione
|
||||||
|
|
||||||
|
Convertire PDF digitali in Markdown **perfetto per la vettorizzazione RAG**, senza revisione manuale. L'output deve essere testo pulito, strutturato in sezioni semanticamente coerenti, privo di artefatti, pronto per chunking e indicizzazione in un vector store.
|
||||||
|
|
||||||
|
**Non supportato:** PDF scansionati (immagini), PDF protetti da password.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Regole invarianti
|
## Regole invarianti
|
||||||
|
|
||||||
- **Lingua:** Rispondi sempre in italiano.
|
- **Lingua:** Rispondi sempre in italiano.
|
||||||
- **Venv:** Usa `.venv/bin/python` o `source .venv/bin/activate`. Mai `pip`/`python` di sistema.
|
- **Venv:** Usa `.venv/bin/python` o `source .venv/bin/activate`. Mai `pip`/`python` di sistema.
|
||||||
- **`raw.md` immutabile:** La copia di lavoro è sempre `clean.md`.
|
- **`raw.md` immutabile:** Non modificare mai `raw.md`. La copia di lavoro è sempre `clean.md`.
|
||||||
|
- **Obiettivo zero revisioni:** ogni miglioramento alla pipeline deve ridurre i casi in cui il `clean.md` richiede correzioni manuali.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pipeline
|
## Setup
|
||||||
|
|
||||||
```
|
```bash
|
||||||
PDF → conversione → chunking → verifica → vettorizzazione → retrieval
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Java 11+ richiesto da opendataloader-pdf
|
||||||
|
sudo apt install default-jdk # Ubuntu/Debian/WSL
|
||||||
|
java -version
|
||||||
```
|
```
|
||||||
|
|
||||||
`--stem` = nome PDF senza estensione = nome collection ChromaDB.
|
---
|
||||||
|
|
||||||
Per i path degli script e degli output usa `git ls-files` o esplora la root: la struttura è in evoluzione verso un programma unico.
|
## Comandi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Converti un PDF (posizionalo prima in sources/<nome>.pdf)
|
||||||
|
.venv/bin/python conversione/ --stem <nome>
|
||||||
|
|
||||||
|
# Tutti i PDF in sources/
|
||||||
|
.venv/bin/python conversione/
|
||||||
|
|
||||||
|
# Forza riesecuzione (sovrascrive clean.md esistente)
|
||||||
|
.venv/bin/python conversione/ --stem <nome> --force
|
||||||
|
|
||||||
|
# Validazione batch di tutti gli stem
|
||||||
|
.venv/bin/python conversione/ validate
|
||||||
|
|
||||||
|
# Validazione con dettaglio penalità
|
||||||
|
.venv/bin/python conversione/ validate <stem> --detail
|
||||||
|
|
||||||
|
# Rimuove l'output di uno stem
|
||||||
|
bash conversione/clear.sh <nome>
|
||||||
|
```
|
||||||
|
|
||||||
|
`--stem` = nome file PDF senza estensione.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configurazione
|
## Architettura
|
||||||
|
|
||||||
`config.py` è la fonte di verità: `EMBED_MODEL`, `OLLAMA_MODEL`, `TOP_K`, `TEMPERATURE`, `SYSTEM_PROMPT`.
|
Il codice è organizzato in `conversione/__main__.py` (entry point) e il package `conversione/_pipeline/` (logica modulare).
|
||||||
|
|
||||||
**Se cambi `EMBED_MODEL`:** riesegui ingest con `--force` — embedding incoerenti non producono errori ma risposte insensate.
|
```
|
||||||
|
conversione/
|
||||||
|
├── __main__.py # Entry point unificato: convert + validate
|
||||||
|
├── clear.sh # Rimuove output di uno stem
|
||||||
|
└── _pipeline/
|
||||||
|
├── __init__.py # Re-export pubblico
|
||||||
|
├── extract.py # _check_deps() + validate_pdf() + convert_pdf()
|
||||||
|
├── runner.py # run() — orchestrazione 4 fasi
|
||||||
|
├── structure.py # analyze() + rilevamento lingua e struttura
|
||||||
|
├── report.py # build_report() → report.json
|
||||||
|
├── validator.py # validate() + _score() + _grade()
|
||||||
|
├── _apply.py # apply_transforms() — orchestratore in ordine semantico
|
||||||
|
├── _constants.py # Regex compilate e mappe statiche condivise
|
||||||
|
├── _encoding.py # Gruppo 1: PUA font Symbol, accenti LaTeX, simboli SI
|
||||||
|
├── _artifacts.py # Gruppo 2: immagini, BR, footnote, URL, righe ricorrenti, watermark
|
||||||
|
├── _headers.py # Gruppo 3: normalizzazione livelli, concat, bold, ALLCAPS
|
||||||
|
├── _structure.py # Gruppo 4: TOC, ALLCAPS→##, sezioni numerate, math, articoli
|
||||||
|
├── _text.py # Gruppo 5: merge paragrafi, whitespace, poesia, versi
|
||||||
|
├── _finish.py # Gruppo 6: header vuoti/garbage, formula-header, frontmatter
|
||||||
|
└── _helpers.py # Funzioni pure condivise (_sentence_case, _is_allcaps_line, ecc.)
|
||||||
|
```
|
||||||
|
|
||||||
**Se cambi `MIN_CHARS` / `MAX_CHARS`:** cerca tutte le occorrenze nel repo e sincronizza.
|
### `__main__.py` — entry point unificato
|
||||||
|
|
||||||
|
CLI con due modalità: conversione (default, `--stem`, `--force`) e validazione (subcommand `validate`, con stems opzionali e `--detail`). Aggiunge `conversione/` a `sys.path` e delega a `_pipeline`. Uso: `python conversione/ [--stem X] [--force]` oppure `python conversione/ validate [X] [--detail]`.
|
||||||
|
|
||||||
|
### `_pipeline/extract.py` — fronte PDF
|
||||||
|
|
||||||
|
Raggruppa in un unico modulo le tre responsabilità legate al PDF: `_check_deps()` verifica che `opendataloader-pdf` e Java 11+ siano disponibili; `validate_pdf(pdf_path) -> (bool, str)` controlla esistenza, dimensione e presenza di testo digitale; `convert_pdf(pdf_path, out_dir) -> Path` invoca opendataloader-pdf con i parametri RAG-ottimali e restituisce il percorso del `.md` prodotto. Auto-rileva i PDF taggati (Word/InDesign) per attivare `use_struct_tree`.
|
||||||
|
|
||||||
|
### `_pipeline/_apply.py` — cuore della pipeline
|
||||||
|
|
||||||
|
Contiene `apply_transforms(text) -> (text, stats)` che chiama ~35 trasformazioni atomiche (`_t_*`) in ordine semantico fisso — **non modificare l'ordine senza capire le dipendenze tra gruppi**. Ogni `_t_*` vive nel modulo del suo gruppo; le costanti regex compilate stanno in `_constants.py`.
|
||||||
|
|
||||||
|
Ordine logico dei gruppi:
|
||||||
|
1. **Encoding** (`_encoding.py`) — PUA font Symbol, accenti backtick LaTeX, moltiplicazione, micro
|
||||||
|
2. **Pulizia artefatti** (`_artifacts.py`) — immagini, `<br>`, footnote superscript, URL, box symbol, righe ricorrenti, watermark
|
||||||
|
3. **Struttura header** (`_headers.py`) — fix header+body concatenati, Capitolo inline, normalizzazione livelli numerati, `####`→`###`, bold, ALL-CAPS
|
||||||
|
4. **Costruzione struttura** (`_structure.py`) — TOC rimosso, ALL-CAPS→`##`, sezioni numerate→`###`, ambienti matematici, articoli
|
||||||
|
5. **Testo** (`_text.py`) — merge paragrafi spezzati, whitespace, blank lines, poesia, versi
|
||||||
|
6. **Rifinitura** (`_finish.py`) — header vuoti, garbage header, merge titoli isolati, frontmatter
|
||||||
|
|
||||||
|
Flag automatico: se il testo contiene "Esercizi/Problems/Homework", `_t_numbered_sections` non converte `- N. testo` in header (sono numerazioni di esercizi, non titoli).
|
||||||
|
|
||||||
|
### `_pipeline/structure.py` — analisi struttura
|
||||||
|
|
||||||
|
`analyze(md_path) -> dict` conta `#`/`##`/`###`, rileva lingua (it/en/fr/de/es), sceglie `strategia_chunking`:
|
||||||
|
|
||||||
|
| Strategia | Condizione |
|
||||||
|
|-----------|------------|
|
||||||
|
| `h3_aware` | ≥5 `###` |
|
||||||
|
| `h2_paragraph_split` | ≥3 `##`, pochi `###` |
|
||||||
|
| `paragraph` | struttura rada |
|
||||||
|
| `sliding_window` | testo piatto |
|
||||||
|
|
||||||
|
### `_pipeline/report.py` — metriche qualità
|
||||||
|
|
||||||
|
`build_report()` genera `report.json` con: statistiche trasformazioni, struttura, distribuzione lunghezze sezioni (`min`/`p25`/`mediana`/`p75`/`max`), anomalie (bare headers, sezioni corte/lunghe), residui con esempi (backtick, dot-leader, URL, `<br>`, simboli encoding, formule inline, footnote, PUA).
|
||||||
|
|
||||||
|
### `validate.py` — scoring
|
||||||
|
|
||||||
|
Assegna un voto 0–100 (A/B/C/D/F) leggendo `report.json`. Penalità principali:
|
||||||
|
|
||||||
|
| Problema | Penalità | Cap |
|
||||||
|
|----------|----------|-----|
|
||||||
|
| Struttura assente (livello 0) | −40 | — |
|
||||||
|
| Struttura piatta (livello 1) | −15 | — |
|
||||||
|
| Backtick residui | −2/cad | −20 |
|
||||||
|
| Caratteri PUA font Symbol | −2/cad | −20 |
|
||||||
|
| Dot-leader | −5/cad | −10 |
|
||||||
|
| URL/watermark | −5/cad | −15 |
|
||||||
|
| `<br>` inline | −2/cad | −15 |
|
||||||
|
| Bare headers | −3/cad | −15 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workflow consigliato
|
## Cosa rende un Markdown perfetto per la vettorizzazione
|
||||||
|
|
||||||
1. Converti il PDF con lo script di conversione
|
- **Struttura semantica:** header Markdown = confini naturali dei chunk; ogni sezione è un'unità concettuale.
|
||||||
2. `/prepare-md conversione/<stem>/clean.md`
|
- **Testo pulito:** nessun backtick, dot-leader, footnote superscript, carattere PUA, `<br>`.
|
||||||
3. Chunking
|
- **Paragrafi interi:** nessuna frase troncata da salto pagina PDF.
|
||||||
4. Vettorizza con `--stem <stem>`
|
- **Formule e simboli:** lettere greche e operatori in Unicode standard, non in font-encoding privato.
|
||||||
6. `python rag.py --stem <stem>`
|
- **Nessun rumore strutturale:** TOC, header/footer ripetuti, URL, watermark — tutto rimosso.
|
||||||
|
- **Gerarchia corretta:** h1/h2/h3 riflettono la struttura logica, non il layout tipografico.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linee guida per migliorare la pipeline
|
||||||
|
|
||||||
|
Quando si aggiunge una trasformazione in `apply_transforms()`:
|
||||||
|
- Ogni `_t_*` deve restituire `(testo, n_modifiche)` — il contatore alimenta `report.json`.
|
||||||
|
- Implementare la funzione nel modulo del gruppo corretto (`_encoding.py`, `_artifacts.py`, ecc.), importarla in `_apply.py` e inserire la coppia `("stat_key", _t_nuova)` nella lista `_transforms` nel punto logicamente corretto.
|
||||||
|
- Compilare i pattern regex in `_constants.py` come costanti di modulo, mai dentro la funzione.
|
||||||
|
- Testare con `.venv/bin/python conversione/ --stem <stem> --force` e confrontare `report.json`.
|
||||||
|
- Un nuovo tipo di artefatto: prima aggiungerlo come residuo in `report.py` (funzione `_scan`), poi implementare la `_t_*` che lo rimuove.
|
||||||
|
- I residui in `report.py` usano `_MATH_SYMBOLS_RE`, `_EXERCISE_TRIGGER_RE` e `_MATH_HDR_RE` da `transforms._constants` — non ridefinirli localmente.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Skills custom
|
## Skills custom
|
||||||
|
|
||||||
- `/prepare-md <path|stem>` — corregge `clean.md`: sillabazione, artefatti, header, paragrafi spezzati, gerarchia.
|
- `/prepare-md <path|stem>` — corregge `clean.md` quando la pipeline non basta: sillabazione, artefatti residui, header malformati, gerarchia incoerente.
|
||||||
- `/step6-fix <stem>` — verifica chunk, dry-run e applicazione fix via `fix_chunks.py`.
|
|
||||||
|
|||||||
@@ -1,468 +1,68 @@
|
|||||||
# RAG from Scratch — Singolo PDF Generico
|
# PDF → Markdown
|
||||||
|
|
||||||
Sistema RAG (Retrieval-Augmented Generation) costruito da zero, senza framework di alto livello.
|
Converte PDF digitali in Markdown strutturato e pulito.
|
||||||
Funziona su qualsiasi PDF digitale. Gira interamente in locale, senza GPU, senza cloud.
|
|
||||||
|
|
||||||
**Stack:** Python · Ollama · ChromaDB · Qwen3-embedding · Qwen3.5
|
**Stack:** Python · opendataloader-pdf (XY-Cut++) · Java 11+
|
||||||
**Compatibile con:** Linux · macOS · Windows (WSL2) · CPU Only · ~8 GB RAM libera
|
**Compatibile con:** Linux · macOS · Windows (WSL2)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Indice
|
## Setup
|
||||||
|
|
||||||
- [Panoramica](#panoramica)
|
|
||||||
- [Struttura del progetto](#struttura-del-progetto)
|
|
||||||
- [Pipeline](#pipeline)
|
|
||||||
- [Conversione](#conversione)
|
|
||||||
- [Revisione Markdown](#revisione-markdown)
|
|
||||||
- [Chunking](#chunking)
|
|
||||||
- [Verifica chunk](#verifica-chunk)
|
|
||||||
- [Ambiente Ollama](#ambiente-ollama)
|
|
||||||
- [Vettorizzazione](#vettorizzazione)
|
|
||||||
- [Interrogazione](#interrogazione)
|
|
||||||
- [Principi di progettazione](#principi-di-progettazione)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Panoramica
|
|
||||||
|
|
||||||
```
|
|
||||||
PDF (sources/)
|
|
||||||
│
|
|
||||||
▼ conversione/pipeline.py
|
|
||||||
clean.md ← revisiona con /prepare-md
|
|
||||||
│
|
|
||||||
▼ step-5/chunker.py
|
|
||||||
chunks.json
|
|
||||||
│
|
|
||||||
▼ step-6/verify_chunks.py + fix_chunks.py
|
|
||||||
chunks.json verificato
|
|
||||||
│
|
|
||||||
▼ step-8/ingest.py
|
|
||||||
ChromaDB
|
|
||||||
│
|
|
||||||
▼ rag.py
|
|
||||||
risposta
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dove si concentra il rischio
|
|
||||||
|
|
||||||
| Fase | Rischio | Motivo |
|
|
||||||
|---|---|---|
|
|
||||||
| Conversione | 🟡 Medio | Automatica, ma il PDF deve essere digitale e non protetto |
|
|
||||||
| Revisione Markdown | 🔴 Alto | Manuale — la qualità del MD determina la qualità del RAG |
|
|
||||||
| Chunking | 🟡 Medio | Logica adattiva, dipende dalla qualità del MD |
|
|
||||||
| Verifica chunk | 🟢 Basso | Automatica, solo verifica |
|
|
||||||
| Ambiente Ollama | 🟢 Basso | Installazione standard |
|
|
||||||
| Vettorizzazione | 🟢 Basso | Meccanica, lenta ma affidabile |
|
|
||||||
| Interrogazione | 🟡 Medio | Qualità del prompt e dei parametri in `config.py` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Struttura del progetto
|
|
||||||
|
|
||||||
```
|
|
||||||
rag-from-scratch/
|
|
||||||
│
|
|
||||||
├── sources/ # PDF originali — non modificare mai
|
|
||||||
│ └── documento.pdf
|
|
||||||
│
|
|
||||||
├── conversione/ # PDF → Markdown strutturato
|
|
||||||
│ ├── pipeline.py # Conversione PDF → clean.md
|
|
||||||
│ ├── validate.py # Validazione batch di tutti gli stem
|
|
||||||
│ └── <stem>/
|
|
||||||
│ ├── raw.md # MD grezzo (non toccare)
|
|
||||||
│ ├── clean.md # MD pulito — copia di lavoro
|
|
||||||
│ └── report.json # Metriche qualità conversione
|
|
||||||
│
|
|
||||||
├── step-5/ # Chunking adattivo
|
|
||||||
│ ├── chunker.py
|
|
||||||
│ └── <stem>/
|
|
||||||
│ └── chunks.json
|
|
||||||
│
|
|
||||||
├── step-6/ # Verifica e fix chunk
|
|
||||||
│ ├── verify_chunks.py
|
|
||||||
│ ├── fix_chunks.py
|
|
||||||
│ └── <stem>/
|
|
||||||
│ └── chunks.json # Chunk verificati
|
|
||||||
│
|
|
||||||
├── step-8/ # Vettorizzazione → ChromaDB
|
|
||||||
│ ├── ingest.py
|
|
||||||
│ └── README.md
|
|
||||||
│
|
|
||||||
├── 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
|
|
||||||
├── .gitignore
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pipeline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Conversione
|
|
||||||
|
|
||||||
**Tipo:** automatico
|
|
||||||
**Input:** `sources/<stem>.pdf`
|
|
||||||
**Output:** `conversione/<stem>/clean.md` + `report.json`
|
|
||||||
**Script:** `conversione/pipeline.py`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Singolo documento
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Java 11+** richiesto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install default-jdk # Ubuntu/Debian/WSL
|
||||||
|
java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utilizzo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Singolo PDF
|
||||||
python conversione/pipeline.py --stem <nome>
|
python conversione/pipeline.py --stem <nome>
|
||||||
|
|
||||||
# Tutti i PDF in sources/
|
# Tutti i PDF in sources/
|
||||||
python conversione/pipeline.py
|
python conversione/pipeline.py
|
||||||
|
|
||||||
# Forza riesecuzione (sovrascrive output esistente)
|
# Forza riesecuzione
|
||||||
python conversione/pipeline.py --stem <nome> --force
|
python conversione/pipeline.py --stem <nome> --force
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
`--stem` = nome file PDF senza estensione.
|
||||||
|
Esempio: `sources/analisi1.pdf` → `--stem analisi1`
|
||||||
|
|
||||||
Produce tre file in `conversione/<stem>/`:
|
---
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Per ogni stem in `conversione/<stem>/`:
|
||||||
|
|
||||||
| File | Descrizione |
|
| File | Descrizione |
|
||||||
|---|---|
|
|------|-------------|
|
||||||
| `raw.md` | Markdown grezzo estratto dal PDF — **non modificare mai** |
|
| `raw.md` | Markdown grezzo — **non modificare** |
|
||||||
| `clean.md` | Markdown pulito e strutturato — input per il chunker |
|
| `clean.md` | Markdown pulito — copia di lavoro |
|
||||||
| `report.json` | Metriche qualità, anomalie, strategia di chunking suggerita |
|
| `structure_profile.json` | Struttura rilevata e metriche |
|
||||||
|
| `report.json` | Statistiche complete della conversione |
|
||||||
|
|
||||||
**Requisiti aggiuntivi:** Java 11+ nel PATH (`opendataloader-pdf` lo richiede).
|
---
|
||||||
|
|
||||||
**Validazione batch:**
|
## Validazione batch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python conversione/validate.py
|
python conversione/validate.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Mostra una tabella di stato per tutti gli stem convertiti. Vedi [`conversione/README.md`](conversione/README.md) per dettagli completi.
|
Stampa una tabella di stato su tutti gli stem convertiti.
|
||||||
|
|
||||||
**PDF supportati:** digitali con testo selezionabile. Non supportati: scansionati (solo immagini) e protetti da password.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Revisione Markdown
|
Vedi [`conversione/README.md`](conversione/README.md) per dettagli sulla pipeline e i tipi di documento supportati.
|
||||||
|
|
||||||
**Tipo:** semi-automatico
|
|
||||||
**Input:** `conversione/<stem>/clean.md`
|
|
||||||
**Output:** `conversione/<stem>/clean.md` corretto in-place
|
|
||||||
|
|
||||||
> Questo è il passaggio più importante dell'intera pipeline.
|
|
||||||
> La qualità del RAG dipende da questo passaggio più di qualsiasi
|
|
||||||
> parametro tecnico o scelta di modello.
|
|
||||||
|
|
||||||
```
|
|
||||||
/prepare-md conversione/<stem>/clean.md
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Struttura target dopo la revisione:**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Titolo del documento
|
|
||||||
|
|
||||||
## Sezione principale
|
|
||||||
|
|
||||||
### Sottosezione o unità atomica
|
|
||||||
|
|
||||||
Testo fluente, frasi complete, nessun artefatto.
|
|
||||||
Ogni paragrafo è semanticamente autonomo.
|
|
||||||
Una riga vuota separa le sezioni.
|
|
||||||
```
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Chunking
|
|
||||||
|
|
||||||
**Tipo:** automatico
|
|
||||||
**Input:** `conversione/<stem>/clean.md`
|
|
||||||
**Output:** `step-5/<stem>/chunks.json`
|
|
||||||
**Script:** `step-5/chunker.py`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python step-5/chunker.py --stem <stem>
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Regole invarianti per qualsiasi documento:**
|
|
||||||
|
|
||||||
- Un chunk non attraversa mai il confine tra due sezioni diverse
|
|
||||||
- Un chunk non spezza mai una frase a metà
|
|
||||||
- Ogni chunk porta il suo contesto nel prefisso
|
|
||||||
- L'overlap tra chunk avviene solo su frasi intere, mai tra sezioni diverse
|
|
||||||
|
|
||||||
**Parametri (in `step-5/chunker.py`):**
|
|
||||||
|
|
||||||
| Parametro | Default | Significato |
|
|
||||||
|---|---|---|
|
|
||||||
| `MIN_CHARS` | 200 | Sotto questa soglia, accorpa al chunk successivo |
|
|
||||||
| `MAX_CHARS` | 800 | Sopra questa soglia, spezza su frasi |
|
|
||||||
| `OVERLAP_S` | 2 | Frasi di overlap tra sotto-chunk dello stesso boundary |
|
|
||||||
|
|
||||||
**Struttura di ogni chunk:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"chunk_id": "sezione_principale__sottosezione_3__s0",
|
|
||||||
"text": "[Sezione principale > Sottosezione 3]\nTesto del chunk...",
|
|
||||||
"sezione": "Sezione principale",
|
|
||||||
"titolo": "Sottosezione 3",
|
|
||||||
"sub_index": 0,
|
|
||||||
"n_chars": 412
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Il prefisso `[Sezione > Titolo]` è fondamentale: permette all'embedding di catturare il contesto topico del chunk anche quando il testo da solo sarebbe ambiguo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Verifica chunk
|
|
||||||
|
|
||||||
**Tipo:** automatico
|
|
||||||
**Input:** `step-5/<stem>/chunks.json`
|
|
||||||
**Output:** `step-6/<stem>/chunks.json` verificato + `report.json`
|
|
||||||
**Script:** `step-6/verify_chunks.py`, `step-6/fix_chunks.py`
|
|
||||||
|
|
||||||
Questo passaggio si articola in un ciclo: verifica → fix automatico → ri-verifica. Non si procede alla vettorizzazione finché non ci sono 🔴.
|
|
||||||
|
|
||||||
**Workflow:**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Verifica
|
|
||||||
python step-6/verify_chunks.py --stem <stem>
|
|
||||||
|
|
||||||
2a. Se ✅ OK o solo 🟡 → vai alla vettorizzazione
|
|
||||||
|
|
||||||
2b. Se ci sono 🔴 → prova il fix automatico:
|
|
||||||
python step-6/fix_chunks.py --stem <stem> --dry-run # anteprima
|
|
||||||
python step-6/fix_chunks.py --stem <stem> # applica
|
|
||||||
|
|
||||||
3. Ri-verifica dopo il fix:
|
|
||||||
python step-6/verify_chunks.py --stem <stem>
|
|
||||||
|
|
||||||
4. Se rimangono 🔴 → torna alla revisione Markdown e correggi clean.md,
|
|
||||||
poi riesegui dall'inizio:
|
|
||||||
python step-5/chunker.py --stem <stem> --force
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Output di `verify_chunks.py` — tre condizioni finali:**
|
|
||||||
|
|
||||||
| Condizione | Significato | Cosa fare |
|
|
||||||
|---|---|---|
|
|
||||||
| `✅ N/N documenti senza problemi` | Nessun problema | Procedi |
|
|
||||||
| `🟡 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 |
|
|
||||||
|
|
||||||
**Cosa verifica:**
|
|
||||||
|
|
||||||
- Nessun chunk sotto `MIN_CHARS` 🟡
|
|
||||||
- Nessun chunk sopra `MAX_CHARS × 1.5` 🟡
|
|
||||||
- Ogni chunk finisce con punteggiatura di fine frase 🔴
|
|
||||||
|
|
||||||
**Cosa corregge `fix_chunks.py`:**
|
|
||||||
|
|
||||||
| Operazione | Quando |
|
|
||||||
|---|---|
|
|
||||||
| Rimuovi chunk vuoti | Chunk privi di testo |
|
|
||||||
| Fondi chunk incompleto col successivo | Chunk che finisce senza punteggiatura |
|
|
||||||
| Fondi chunk troppo corto col successivo | Chunk sotto `MIN_CHARS` |
|
|
||||||
| Spezza chunk troppo lungo | Chunk sopra `MAX_CHARS × 1.5` |
|
|
||||||
|
|
||||||
**Se i 🔴 persistono dopo il fix** — 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 | Numero di pagina nel mezzo del testo | Trova e rimuovi il numero di pagina, unisci le righe |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Ambiente Ollama
|
|
||||||
|
|
||||||
**Tipo:** manuale (una volta sola)
|
|
||||||
**Input:** nessuno
|
|
||||||
**Output:** ambiente locale funzionante
|
|
||||||
**Script:** `ollama/check_env.py`
|
|
||||||
|
|
||||||
Installa Ollama, scarica i modelli e verifica l'ambiente. Si esegue una volta sola prima della vettorizzazione.
|
|
||||||
|
|
||||||
Vedi [`ollama/README.md`](ollama/README.md) per istruzioni dettagliate e scelta dei modelli.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ollama/check_env.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Vettorizzazione
|
|
||||||
|
|
||||||
**Tipo:** automatico (lento)
|
|
||||||
**Input:** `step-6/<stem>/chunks.json`
|
|
||||||
**Output:** `chroma_db/<stem>` popolato
|
|
||||||
**Script:** `step-8/ingest.py`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source .venv/bin/activate
|
|
||||||
python step-8/ingest.py --stem <nome>
|
|
||||||
```
|
|
||||||
|
|
||||||
Trasforma ogni chunk in un vettore numerico e lo salva in ChromaDB.
|
|
||||||
È il processo più lento — su CPU circa 1 secondo per chunk.
|
|
||||||
Per 900 chunk aspetta circa 15 minuti.
|
|
||||||
|
|
||||||
**Argomenti:**
|
|
||||||
|
|
||||||
| Argomento | Descrizione |
|
|
||||||
|---|---|
|
|
||||||
| `--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à |
|
|
||||||
|
|
||||||
**Quando usare `--force`:**
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Cosa succede per ogni chunk:**
|
|
||||||
|
|
||||||
```
|
|
||||||
testo del chunk
|
|
||||||
│
|
|
||||||
▼ Ollama (EMBED_MODEL)
|
|
||||||
vettore N-dim
|
|
||||||
│
|
|
||||||
▼ ChromaDB
|
|
||||||
salva: testo + vettore + metadati (sezione, titolo, sub_index)
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Interrogazione
|
|
||||||
|
|
||||||
**Tipo:** interattivo
|
|
||||||
**Input:** `chroma_db/<stem>` + domanda dell'utente
|
|
||||||
**Output:** risposta basata sul documento
|
|
||||||
|
|
||||||
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
|
|
||||||
source .venv/bin/activate
|
|
||||||
python rag.py --stem <nome>
|
|
||||||
```
|
|
||||||
|
|
||||||
| 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
|
|
||||||
```
|
|
||||||
|
|
||||||
#### retrieve.py — Retrieval puro (senza LLM)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source .venv/bin/activate
|
|
||||||
python 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.
|
|
||||||
|
|
||||||
| 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
|
|
||||||
```
|
|
||||||
|
|
||||||
Chat diretta con il modello, senza ChromaDB. Usa gli stessi parametri di `config.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Principi di progettazione
|
|
||||||
|
|
||||||
**Atomico**
|
|
||||||
Ogni fase fa una cosa sola. Il chunker non sa niente di Ollama.
|
|
||||||
L'ingestione non sa niente del MD originale.
|
|
||||||
Se un pezzo si rompe, sai esattamente dove.
|
|
||||||
|
|
||||||
**Verificabile**
|
|
||||||
Ogni fase ha un criterio di completamento oggettivo.
|
|
||||||
Non si passa alla fase successiva finché la precedente non è verificata.
|
|
||||||
|
|
||||||
**Reversibile**
|
|
||||||
Puoi tornare indietro senza perdere il lavoro delle altre fasi.
|
|
||||||
Cambi il MD? Riesegui solo chunking e vettorizzazione.
|
|
||||||
Cambi i parametri del chunker? Riesegui solo chunking e vettorizzazione.
|
|
||||||
Non si riparte mai da zero.
|
|
||||||
|
|
||||||
**Senza assunzioni**
|
|
||||||
Il sistema non assume nulla sulla struttura del documento.
|
|
||||||
Rileva il livello strutturale e si adatta.
|
|
||||||
Funziona su libri, manuali, articoli, contratti, dispense.
|
|
||||||
|
|
||||||
**Tutto locale**
|
|
||||||
Nessuna chiamata a API esterne.
|
|
||||||
Nessun dato trasmesso fuori dalla macchina.
|
|
||||||
Nessun costo di utilizzo.
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# ─── Configurazione RAG ───────────────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Modifica questo file per cambiare i parametri della pipeline.
|
|
||||||
#
|
|
||||||
# Uso:
|
|
||||||
# python rag.py --stem nietzsche
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# ── Retrieval ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Numero di chunk da recuperare per ogni domanda.
|
|
||||||
# Valori più alti = più contesto, risposte potenzialmente più complete,
|
|
||||||
# ma prompt più lunghi e generazione più lenta.
|
|
||||||
TOP_K = 6
|
|
||||||
|
|
||||||
# ── Generazione ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Temperatura del modello LLM.
|
|
||||||
# 0.0 = completamente deterministico (stessa risposta ad ogni run)
|
|
||||||
# 0.7 = più creativo e vario
|
|
||||||
TEMPERATURE = 0.0
|
|
||||||
|
|
||||||
# Disabilita il "thinking" (ragionamento interno) nei modelli Qwen3/Qwen3.5.
|
|
||||||
# True = risposta diretta, più veloce
|
|
||||||
# False = ragionamento interno abilitato (più lento ma potenzialmente più accurato)
|
|
||||||
NO_THINK = True
|
|
||||||
|
|
||||||
# ── Embedding ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Modello di embedding usato da Ollama.
|
|
||||||
# Deve corrispondere al modello usato durante la vettorizzazione (ingest.py).
|
|
||||||
# Se cambi questo, devi rieseguire ingest.py con --force.
|
|
||||||
EMBED_MODEL = "nomic-embed-text"
|
|
||||||
|
|
||||||
# ── Ollama ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# URL del server Ollama (default: locale sulla porta 11434).
|
|
||||||
OLLAMA_URL = "http://localhost:11434"
|
|
||||||
|
|
||||||
# Modello LLM. Scegli in base alla RAM disponibile (vedi README).
|
|
||||||
OLLAMA_MODEL = "qwen3.5:0.8b"
|
|
||||||
|
|
||||||
# ── Prompt di sistema ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Istruzioni di comportamento inviate al LLM prima del contesto e della domanda.
|
|
||||||
# Modifica per cambiare il tono, la lingua, il grado di libertà interpretativa
|
|
||||||
# o le condizioni di fallback ("non so rispondere").
|
|
||||||
SYSTEM_PROMPT = (
|
|
||||||
"Sei un assistente che risponde usando il contesto fornito. "
|
|
||||||
"Sintetizza e interpreta liberamente i passaggi del contesto per rispondere alla domanda. "
|
|
||||||
"Se il contesto contiene informazioni pertinenti, anche indirette, usale per costruire una risposta. "
|
|
||||||
"Solo se il contesto è completamente irrilevante, rispondi: "
|
|
||||||
"\"Non trovo questa informazione nel documento.\""
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pipeline PDF → clean Markdown per vettorizzazione RAG.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
# Converti
|
||||||
|
python conversione/ --stem <nome>
|
||||||
|
python conversione/ --stem <nome> --force
|
||||||
|
python conversione/ # tutti i PDF in sources/
|
||||||
|
|
||||||
|
# Valida
|
||||||
|
python conversione/ validate
|
||||||
|
python conversione/ validate <stem> [<stem> ...] --detail
|
||||||
|
|
||||||
|
Prerequisiti:
|
||||||
|
pip install opendataloader-pdf pdfplumber
|
||||||
|
Java 11+ sul PATH (https://adoptium.net/)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Rende _pipeline importabile da conversione/
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from _pipeline import _check_deps, run, validate
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="conversione",
|
||||||
|
description="PDF → clean Markdown strutturato, pronto per chunking RAG",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"Esempi:\n"
|
||||||
|
" python conversione/ --stem manuale\n"
|
||||||
|
" python conversione/ --stem manuale --force\n"
|
||||||
|
" python conversione/ validate\n"
|
||||||
|
" python conversione/ validate manuale --detail"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Subcommand: validate ──────────────────────────────────────────────
|
||||||
|
sub = parser.add_subparsers(dest="cmd", metavar="comando")
|
||||||
|
val = sub.add_parser(
|
||||||
|
"validate",
|
||||||
|
help="valida i report.json prodotti dalla conversione",
|
||||||
|
description="Legge i report.json e assegna un voto 0-100 (A/B/C/D/F).",
|
||||||
|
)
|
||||||
|
val.add_argument(
|
||||||
|
"stems",
|
||||||
|
nargs="*",
|
||||||
|
metavar="STEM",
|
||||||
|
help="stem da validare. Ometti per tutti.",
|
||||||
|
)
|
||||||
|
val.add_argument(
|
||||||
|
"--detail", "-d",
|
||||||
|
action="store_true",
|
||||||
|
help="mostra il dettaglio delle penalità per ogni documento",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Opzioni convert (modalità default) ───────────────────────────────
|
||||||
|
parser.add_argument(
|
||||||
|
"--stem",
|
||||||
|
metavar="NOME",
|
||||||
|
help="nome del PDF in sources/ (senza estensione). Ometti per tutti.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="riesegui anche se clean.md è già presente",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = _build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
# ── Validate ─────────────────────────────────────────────────────────
|
||||||
|
if args.cmd == "validate":
|
||||||
|
validate(args.stems, root, detail=args.detail)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Convert (default) ────────────────────────────────────────────────
|
||||||
|
_check_deps()
|
||||||
|
|
||||||
|
if args.stem:
|
||||||
|
stems = [args.stem]
|
||||||
|
else:
|
||||||
|
sources_dir = root / "sources"
|
||||||
|
if not sources_dir.exists():
|
||||||
|
print("Errore: cartella sources/ non trovata.")
|
||||||
|
sys.exit(1)
|
||||||
|
stems = sorted(p.stem for p in sources_dir.glob("*.pdf"))
|
||||||
|
if not stems:
|
||||||
|
print("Errore: nessun PDF trovato in sources/.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
results = [run(s, root, args.force) for s in stems]
|
||||||
|
ok = sum(results)
|
||||||
|
total = len(results)
|
||||||
|
print(f"\n{'✅' if all(results) else '⚠️ '} {ok}/{total} documenti convertiti")
|
||||||
|
sys.exit(0 if all(results) else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from .extract import _check_deps, validate_pdf, convert_pdf
|
||||||
|
from ._apply import apply_transforms
|
||||||
|
from .structure import analyze
|
||||||
|
from .report import build_report
|
||||||
|
from .runner import run
|
||||||
|
from .validator import validate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_check_deps",
|
||||||
|
"validate_pdf",
|
||||||
|
"convert_pdf",
|
||||||
|
"apply_transforms",
|
||||||
|
"analyze",
|
||||||
|
"build_report",
|
||||||
|
"run",
|
||||||
|
"validate",
|
||||||
|
]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Orchestratore: applica le trasformazioni in ordine semantico."""
|
||||||
|
import re
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from ._encoding import (
|
||||||
|
_t_fix_symbol_font, _t_fix_accents,
|
||||||
|
_t_fix_multiplication, _t_fix_micro,
|
||||||
|
)
|
||||||
|
from ._artifacts import (
|
||||||
|
_t_remove_images, _t_fix_br, _t_fix_tabsep, _t_remove_footnotes,
|
||||||
|
_t_remove_formula_labels, _t_remove_dotleaders, _t_remove_recurring_lines,
|
||||||
|
_t_fix_math_symbols, _t_remove_watermarks, _t_remove_urls,
|
||||||
|
_t_remove_page_markers, _t_remove_page_numbers, _t_remove_separators,
|
||||||
|
)
|
||||||
|
from ._headers import (
|
||||||
|
_t_fix_header_concat, _t_extract_capitolo,
|
||||||
|
_t_normalize_numbered_headings, _t_normalize_header_levels,
|
||||||
|
_t_remove_header_bold, _t_normalize_allcaps_headers, _t_demote_h1,
|
||||||
|
)
|
||||||
|
from ._structure import (
|
||||||
|
_t_remove_toc, _t_remove_orphan_toc, _t_allcaps_to_headers,
|
||||||
|
_t_numbered_sections, _t_promote_chapter_headers,
|
||||||
|
_t_extract_math, _t_extract_articles,
|
||||||
|
)
|
||||||
|
from ._text import (
|
||||||
|
_t_merge_paragraphs, _t_normalize_whitespace, _t_collapse_blank_lines,
|
||||||
|
_t_restore_poetry_lines, _t_demote_verse_headers,
|
||||||
|
)
|
||||||
|
from ._finish import (
|
||||||
|
_t_remove_empty_headers, _t_merge_title_headers,
|
||||||
|
_t_remove_garbage_headers, _t_math_header_demotion,
|
||||||
|
_t_remove_frontmatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_transforms(text: str, on_step=None) -> tuple[str, dict]:
|
||||||
|
"""
|
||||||
|
Applica le trasformazioni strutturali al Markdown grezzo.
|
||||||
|
Restituisce (testo_modificato, statistiche).
|
||||||
|
L'ordine è semantico: encoding → artefatti → struttura header →
|
||||||
|
costruzione struttura → testo → rifinitura.
|
||||||
|
|
||||||
|
on_step(i, total, label) — callback opzionale chiamato dopo ogni step.
|
||||||
|
"""
|
||||||
|
_has_ex = bool(re.search(r"\b(Esercizi|Exercises|Problems|Homework)\b", text, re.IGNORECASE))
|
||||||
|
|
||||||
|
# (stat_key, fn, label)
|
||||||
|
_transforms: list[tuple[str | None, object, str]] = [
|
||||||
|
# 1. Encoding
|
||||||
|
("n_simboli_pua_corretti", _t_fix_symbol_font, "encoding PUA Symbol"),
|
||||||
|
("n_accenti_corretti", _t_fix_accents, "accenti backtick LaTeX"),
|
||||||
|
("n_moltiplicazioni_corrette", _t_fix_multiplication, "simbolo moltiplicazione"),
|
||||||
|
("n_micro_corretti", _t_fix_micro, "simbolo micro SI"),
|
||||||
|
# 2. Pulizia artefatti
|
||||||
|
("n_page_markers_rimossi", _t_remove_page_markers, "rimozione page markers PDF"),
|
||||||
|
("n_separatori_rimossi", _t_remove_separators, "rimozione separatori underscore"),
|
||||||
|
("n_immagini_rimosse", _t_remove_images, "rimozione immagini"),
|
||||||
|
("n_br_rimossi", _t_fix_br, "fix <br> inline"),
|
||||||
|
("n_tabsep_rimossi", _t_fix_tabsep, "fix separatori tabella"),
|
||||||
|
("n_note_rimosse", _t_remove_footnotes, "rimozione footnote"),
|
||||||
|
("n_simboli_math_rimossi", _t_fix_math_symbols, "rimozione box math"),
|
||||||
|
("n_formule_rimossi", _t_remove_formula_labels, "rimozione label formula"),
|
||||||
|
("n_dotleader_rimossi", _t_remove_dotleaders, "rimozione dot-leader TOC"),
|
||||||
|
("n_righe_ricorrenti_rimosse", _t_remove_recurring_lines, "rimozione righe ricorrenti"),
|
||||||
|
("n_numeri_pagina_rimossi", _t_remove_page_numbers, "rimozione numeri pagina isolati"),
|
||||||
|
# 3. Struttura header
|
||||||
|
("n_header_concat_fixati", _t_fix_header_concat, "fix header+corpo concatenati"),
|
||||||
|
(None, _t_extract_capitolo, "estrazione Capitolo inline"),
|
||||||
|
("n_header_numerati_normalizzati", _t_normalize_numbered_headings, "normalizzazione livelli numerati"),
|
||||||
|
(None, _t_normalize_header_levels, "normalizzazione livelli ####→###"),
|
||||||
|
(None, _t_demote_h1, "demozione #→## (sezioni principali)"),
|
||||||
|
(None, _t_remove_header_bold, "rimozione bold negli header"),
|
||||||
|
(None, _t_normalize_allcaps_headers, "normalizzazione ALL-CAPS header"),
|
||||||
|
# 4. Costruzione struttura
|
||||||
|
("toc_rimosso", _t_remove_toc, "rimozione TOC"),
|
||||||
|
("n_toc_orfani_rimossi", _t_remove_orphan_toc, "rimozione voci TOC orfane"),
|
||||||
|
("n_header_allcaps", _t_allcaps_to_headers, "ALL-CAPS → ##"),
|
||||||
|
("n_sezioni_numerate", partial(_t_numbered_sections, has_exercises=_has_ex), "sezioni numerate → ###"),
|
||||||
|
("n_capitoli_promossi", _t_promote_chapter_headers, "promozione capitoli ### → ##"),
|
||||||
|
("n_ambienti_matematici", _t_extract_math, "estrazione ambienti matematici"),
|
||||||
|
("n_articoli_estratti", _t_extract_articles, "estrazione articoli → ###"),
|
||||||
|
# 5. Testo
|
||||||
|
("n_paragrafi_uniti", _t_merge_paragraphs, "merge paragrafi spezzati"),
|
||||||
|
(None, _t_normalize_whitespace, "normalizzazione whitespace"),
|
||||||
|
(None, _t_collapse_blank_lines, "riduzione righe vuote"),
|
||||||
|
("n_versi_ripristinati", _t_restore_poetry_lines, "ripristino versi poesia"),
|
||||||
|
("n_header_verso_demotati", _t_demote_verse_headers, "demozione header-verso"),
|
||||||
|
("n_url_rimossi", _t_remove_urls, "rimozione URL"),
|
||||||
|
# 6. Rifinitura
|
||||||
|
(None, lambda t: (re.sub(r"(?m)^(#{1,6}.+?)\s+pag\.\s*\d{1,4}\s*$", r"\1", t), 0), "strip pag.N dagli header"),
|
||||||
|
(None, _t_remove_empty_headers, "rimozione header vuoti"),
|
||||||
|
("n_titoli_uniti", _t_merge_title_headers, "merge titoli isolati"),
|
||||||
|
(None, lambda t: (re.sub(r"(?m)^(#{1,6}.+?)\s*\|\s*\d{1,3}\s*$", r"\1", t), 0), "fix header|pagina"),
|
||||||
|
("n_garbage_headers_rimossi", _t_remove_garbage_headers, "rimozione garbage header"),
|
||||||
|
("n_formula_headers_demotati", _t_math_header_demotion, "demozione formula-header"),
|
||||||
|
("n_frontmatter_rimossi", _t_remove_frontmatter, "rimozione frontmatter"),
|
||||||
|
("n_watermark_rimossi", _t_remove_watermarks, "rimozione watermark"),
|
||||||
|
]
|
||||||
|
|
||||||
|
total = len(_transforms)
|
||||||
|
stats: dict = {}
|
||||||
|
for i, (stat_key, fn, label) in enumerate(_transforms, 1):
|
||||||
|
text, n = fn(text)
|
||||||
|
if stat_key:
|
||||||
|
stats[stat_key] = stats.get(stat_key, 0) + n
|
||||||
|
if on_step:
|
||||||
|
on_step(i, total, label)
|
||||||
|
|
||||||
|
stats["toc_rimosso"] = bool(stats.get("toc_rimosso", 0))
|
||||||
|
return text, stats
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Rimozione artefatti: immagini, BR, footnote, URL, righe ricorrenti, watermark."""
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from ._constants import (
|
||||||
|
_WATERMARK_RE, _TABSEP_RE, _SUPERSCRIPT_RE, _FOOTNOTE_BODY_RE, _DOTLEADER_RE,
|
||||||
|
_PAGE_MARKER_RE, _STANDALONE_NUM_RE, _UNDERSCORE_SEP_RE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_images(text: str) -> tuple[str, int]:
|
||||||
|
n = len(re.findall(r"!\[[^\]]*\]\([^)]*\)", text))
|
||||||
|
text = re.sub(r"!\[[^\]]*\]\([^)]*\)\s*", "", text)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_fix_br(text: str) -> tuple[str, int]:
|
||||||
|
n = len(re.findall(r"<br>", text, re.IGNORECASE))
|
||||||
|
text = re.sub(r"<br>\s*", " ", text, flags=re.IGNORECASE)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_fix_tabsep(text: str) -> tuple[str, int]:
|
||||||
|
n = len(_TABSEP_RE.findall(text))
|
||||||
|
text = _TABSEP_RE.sub("", text)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_footnotes(text: str) -> tuple[str, int]:
|
||||||
|
lines = text.split("\n")
|
||||||
|
result, count = [], 0
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
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_remove_formula_labels(text: str) -> tuple[str, int]:
|
||||||
|
n = len(re.findall(r"\[\d+\.\d+\]", text))
|
||||||
|
text = re.sub(r"\s*\[\d+\.\d+\]\s*", " ", text)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_dotleaders(text: str) -> tuple[str, int]:
|
||||||
|
n = len(_DOTLEADER_RE.findall(text))
|
||||||
|
text = _DOTLEADER_RE.sub("", text)
|
||||||
|
text = re.sub(r"(?m)^(i{1,3}|iv|vi{0,3}|ix|xi{0,2}|x)$", "", text, flags=re.IGNORECASE)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_recurring_lines(text: str) -> tuple[str, int]:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _t_fix_math_symbols(text: str) -> tuple[str, int]:
|
||||||
|
lines = text.split("\n")
|
||||||
|
result, count = [], 0
|
||||||
|
for line in lines:
|
||||||
|
if line.strip() and re.match(r"^[\s■-◿☐-☒•▪▫◆◇●○•]+$", line):
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
result.append(line)
|
||||||
|
return "\n".join(result), count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_watermarks(text: str) -> tuple[str, int]:
|
||||||
|
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_remove_urls(text: str) -> tuple[str, int]:
|
||||||
|
n = len(re.findall(r"(?m)^(https?://|www\.)\S+\s*$", text))
|
||||||
|
text = re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_page_markers(text: str) -> tuple[str, int]:
|
||||||
|
"""Rimuove i marcatori <!-- page: N --> e i separatori --- adiacenti."""
|
||||||
|
n = len(_PAGE_MARKER_RE.findall(text))
|
||||||
|
# Rimuovi ---\n<!-- page: N --> come blocco unico (separatori di pagina PDF)
|
||||||
|
text = re.sub(r"(?m)^---\s*\n<!-- page: \d+ -->\s*\n?", "", text)
|
||||||
|
# Rimuovi eventuali <!-- page: N --> rimasti senza ---
|
||||||
|
text = _PAGE_MARKER_RE.sub("", text)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_page_numbers(text: str) -> tuple[str, int]:
|
||||||
|
"""Rimuove numeri di pagina isolati (1-3 cifre su una riga solitaria)."""
|
||||||
|
n = len(_STANDALONE_NUM_RE.findall(text))
|
||||||
|
text = _STANDALONE_NUM_RE.sub("", text)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_separators(text: str) -> tuple[str, int]:
|
||||||
|
"""Rimuove linee di separazione formate solo da underscore (___...)."""
|
||||||
|
n = len(_UNDERSCORE_SEP_RE.findall(text))
|
||||||
|
text = _UNDERSCORE_SEP_RE.sub("", text)
|
||||||
|
return text, n
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Costanti di modulo condivise tra i moduli di trasformazione.
|
||||||
|
Tutte le regex compilate e le mappe statiche vivono qui.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ─── Keyword sets ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_TOC_KEYWORDS = frozenset([
|
||||||
|
"indice", "index", "contents", "table of contents",
|
||||||
|
"sommario", "inhaltsverzeichnis", "inhalt",
|
||||||
|
"indice generale", "indice analitico", "indice dei contenuti",
|
||||||
|
"elenco dei capitoli", "argomenti", "table des matières",
|
||||||
|
"tabla de contenidos", "содержание",
|
||||||
|
])
|
||||||
|
|
||||||
|
_ORDINALS_IT = {
|
||||||
|
"PRIMO": "I", "SECONDO": "II", "TERZO": "III", "QUARTO": "IV",
|
||||||
|
"QUINTO": "V", "SESTO": "VI", "SETTIMO": "VII", "OTTAVO": "VIII",
|
||||||
|
"NONO": "IX", "DECIMO": "X",
|
||||||
|
}
|
||||||
|
_ORDINALS_EN = {
|
||||||
|
"ONE": "1", "TWO": "2", "THREE": "3", "FOUR": "4", "FIVE": "5",
|
||||||
|
"SIX": "6", "SEVEN": "7", "EIGHT": "8", "NINE": "9", "TEN": "10",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── PUA Symbol font map ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SYMBOL_PUA_MAP: dict[str, str] = {
|
||||||
|
"": " ",
|
||||||
|
"": "(",
|
||||||
|
"": ")",
|
||||||
|
"": "+",
|
||||||
|
"": "−",
|
||||||
|
"": ".",
|
||||||
|
"": "/",
|
||||||
|
"": "0", "": "1", "": "2", "": "3", "": "4",
|
||||||
|
"": "5", "": "6", "": "7", "": "8", "": "9",
|
||||||
|
"": ":", "": ";", "": "<", "": "=", "": ">",
|
||||||
|
"": "≅",
|
||||||
|
"": "Α", "": "Β", "": "Χ", "": "Δ", "": "Ε",
|
||||||
|
"": "Φ", "": "Γ", "": "Η", "": "Ι", "": "ϑ",
|
||||||
|
"": "Κ", "": "Λ", "": "Μ", "": "Ν", "": "Ο",
|
||||||
|
"": "Π", "": "Θ", "": "Ρ", "": "Σ", "": "Τ",
|
||||||
|
"": "Υ", "": "ς", "": "Ω", "": "Ξ", "": "Ψ",
|
||||||
|
"": "Ζ",
|
||||||
|
"": "[",
|
||||||
|
"": "∴",
|
||||||
|
"": "]",
|
||||||
|
"": "⊥",
|
||||||
|
"": "α", "": "β", "": "χ", "": "δ", "": "ε",
|
||||||
|
"": "φ", "": "γ", "": "η", "": "ι", "": "ϕ",
|
||||||
|
"": "κ", "": "λ", "": "μ", "": "ν", "": "ο",
|
||||||
|
"": "π", "": "θ", "": "ρ", "": "σ", "": "τ",
|
||||||
|
"": "υ", "": "ϖ", "": "ω", "": "ξ", "": "ψ",
|
||||||
|
"": "ζ",
|
||||||
|
"": "{",
|
||||||
|
"": "|",
|
||||||
|
"": "}",
|
||||||
|
"": "~",
|
||||||
|
"": "±",
|
||||||
|
"": "•",
|
||||||
|
"": "√",
|
||||||
|
"": "≤",
|
||||||
|
"": "≥",
|
||||||
|
"": "∝",
|
||||||
|
"": "×",
|
||||||
|
"": "÷",
|
||||||
|
"": "×",
|
||||||
|
"": "≠",
|
||||||
|
"": "≠",
|
||||||
|
"": "≥",
|
||||||
|
"": "′",
|
||||||
|
"": "*",
|
||||||
|
"": ",",
|
||||||
|
"": "≤",
|
||||||
|
"": "•",
|
||||||
|
"": "•",
|
||||||
|
"": "→",
|
||||||
|
"": "÷",
|
||||||
|
"": "",
|
||||||
|
"": "→",
|
||||||
|
"": "",
|
||||||
|
"": "",
|
||||||
|
"": "",
|
||||||
|
"": "",
|
||||||
|
# TeX Computer Modern bracket/delimiter pieces (U+F8EB–F8FE) → stringa vuota
|
||||||
|
"": "", # TeX large paren left
|
||||||
|
"": "", # TeX large paren extension
|
||||||
|
"": "", # TeX large paren right
|
||||||
|
"": "", # TeX large paren right ext
|
||||||
|
"": "", # TeX large bracket left
|
||||||
|
"": "", # TeX large bracket ext
|
||||||
|
"": "", # TeX brace top-left
|
||||||
|
"": "", # TeX brace mid
|
||||||
|
"": "", # TeX brace mid-right
|
||||||
|
"": "", # TeX brace extension
|
||||||
|
"": "", # TeX brace right
|
||||||
|
"": "", # TeX bracket right large
|
||||||
|
"": "", # TeX bracket right ext
|
||||||
|
"": "", # TeX bracket right close
|
||||||
|
"": "", # TeX integral large
|
||||||
|
"": "", # TeX integral extension
|
||||||
|
"": "", # TeX integral top
|
||||||
|
"": "", # TeX radical top
|
||||||
|
"": "", # TeX radical extension
|
||||||
|
"": "", # TeX arrowhead
|
||||||
|
}
|
||||||
|
|
||||||
|
_SYMBOL_PUA_RE = re.compile(
|
||||||
|
"[" + "".join(re.escape(k) for k in _SYMBOL_PUA_MAP) + "]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Regex compilate condivise ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUPERSCRIPT_RE = re.compile(r'[¹²³⁰⁴-⁹]+')
|
||||||
|
_FOOTNOTE_BODY_RE = re.compile(
|
||||||
|
r'^([¹²³⁰⁴-⁹]+\s+|\[\d{1,3}\]\s+)'
|
||||||
|
)
|
||||||
|
_NUMBERED_HDR_RE = re.compile(
|
||||||
|
r"^(#{1,6})\s+(\d+(?:\.\d+)*)\.\s+(.+)$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
_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'
|
||||||
|
r'|\b(ibid\.?|ibidem|op\.\s*cit\.?|cit\.|cfr\.|ivi[,;\s])\b',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Pattern autore accademico: iniziale maiuscola + cognome TUTTO-MAIUSCOLO (es. A. PAJNO, G. GUZZETTA)
|
||||||
|
_FOOTNOTE_AUTHOR_RE = re.compile(r'(?<![A-Z])[A-Z]\.\s+[A-Z]{3,}')
|
||||||
|
_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,
|
||||||
|
)
|
||||||
|
_TABSEP_RE = re.compile(r"(?m)^\|\s*\|\s*$|^\|---\|?\s*$")
|
||||||
|
_DOTLEADER_RE = re.compile(r"^[^\n]*(?:(?:\. ){3,}|\.{4,})[^\n]*$", re.MULTILINE)
|
||||||
|
_FM_RE = re.compile(
|
||||||
|
r"https?://|www\.|@[A-Za-z]|\bUniversit[àa]\b|\bDipartimento\b|"
|
||||||
|
r"\bCopyright\b|\bLicenza\b|\bEdizione\b|"
|
||||||
|
r"protetto da|tutti i diritti",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_VERSE_NUM_RE = re.compile(
|
||||||
|
r"([.!?\xbb'\"" + "’" + r"]\s+)(\d+)(\s+)(?=[A-Z\xc0-\xd9a-z\xe0-\xf9\xab“”‟])"
|
||||||
|
)
|
||||||
|
# Math header demotion
|
||||||
|
_MATH_SYMBOLS_RE = re.compile(
|
||||||
|
r"[=+∈∀∃≤≥∞∑∫∂→↔⊂⊃∩∪αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ]"
|
||||||
|
)
|
||||||
|
_EXERCISE_TRIGGER_RE = re.compile(
|
||||||
|
r"\b(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show that"
|
||||||
|
r"|Compute|Calculate|Dimostrare|Verificare)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MATH_HDR_RE = re.compile(r"^(#{2,3})\s+(.+)$")
|
||||||
|
_NUMBERED_PREFIX_RE = re.compile(r"^(\d+(?:\.\d+)*[.)])\s+(.+)$", re.DOTALL)
|
||||||
|
# Orphan TOC: voce di indice senza dot-leader (es. "3. Funzioni 174")
|
||||||
|
_TOC_ITEM_RE = re.compile(
|
||||||
|
r"^\d+(\.\d+)*\.?\s+[A-Za-zÀ-ú\'\(][^\n]{2,70}$"
|
||||||
|
)
|
||||||
|
_TOC_HDR_WITH_PAGE_RE = re.compile(
|
||||||
|
r"^#{1,3}\s+\d+\.?\s+.{3,60}\s+\d{1,4}$"
|
||||||
|
)
|
||||||
|
# Artefatti PDF: page markers e separatori
|
||||||
|
_PAGE_MARKER_RE = re.compile(r"(?m)^<!-- page: \d+ -->\s*$")
|
||||||
|
_STANDALONE_NUM_RE = re.compile(r"(?m)^(?:- )?\d{1,3}$")
|
||||||
|
_UNDERSCORE_SEP_RE = re.compile(r"(?m)^_{4,}\s*$")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Trasformazioni di encoding: PUA font Symbol, accenti LaTeX, simboli SI."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ._constants import _SYMBOL_PUA_MAP, _SYMBOL_PUA_RE
|
||||||
|
|
||||||
|
|
||||||
|
def _t_fix_symbol_font(text: str) -> tuple[str, int]:
|
||||||
|
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_fix_accents(text: str) -> tuple[str, int]:
|
||||||
|
_ACCENT_MAP = {
|
||||||
|
"e": "\xe8", "E": "\xc8", "a": "\xe0", "A": "\xc0",
|
||||||
|
"u": "\xf9", "U": "\xd9", "i": "\xec", "I": "\xcc",
|
||||||
|
"o": "\xf2", "O": "\xd2",
|
||||||
|
}
|
||||||
|
n_bt_before = text.count("`")
|
||||||
|
text = re.sub(r"`([eEaAuUiIoO])", lambda m: _ACCENT_MAP[m.group(1)], text)
|
||||||
|
text = re.sub(r"([eEaAuUiIoO])`", lambda m: _ACCENT_MAP[m.group(1)], text)
|
||||||
|
n_accenti = n_bt_before - text.count("`")
|
||||||
|
n_bt_orfani = text.count("`")
|
||||||
|
if n_bt_orfani:
|
||||||
|
text = re.sub(r"`", "", text)
|
||||||
|
n_accenti += n_bt_orfani
|
||||||
|
return text, n_accenti
|
||||||
|
|
||||||
|
|
||||||
|
def _t_fix_multiplication(text: str) -> tuple[str, int]:
|
||||||
|
n = len(re.findall(r'(?<=[0-9])"(?=[0-9(])', text))
|
||||||
|
text = re.sub(r'(?<=[0-9])"(?=[0-9(])', '×', text)
|
||||||
|
return text, n
|
||||||
|
|
||||||
|
|
||||||
|
def _t_fix_micro(text: str) -> tuple[str, int]:
|
||||||
|
_SI_UNITS_RE = r'[mAsgVWFHTKNJClΩ]'
|
||||||
|
n = len(re.findall(rf'\d\s*!(?={_SI_UNITS_RE})', text))
|
||||||
|
text = re.sub(rf'(\d)\s*!({_SI_UNITS_RE})', r'\1 µ\2', text)
|
||||||
|
return text, n
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Trasformazioni di rifinitura: header vuoti, garbage, demozione formula-header, frontmatter."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ._constants import (
|
||||||
|
_FM_RE, _MATH_HDR_RE, _MATH_SYMBOLS_RE,
|
||||||
|
_EXERCISE_TRIGGER_RE, _NUMBERED_PREFIX_RE,
|
||||||
|
)
|
||||||
|
from ._helpers import _merge_title_headers
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_empty_headers(text: str) -> tuple[str, int]:
|
||||||
|
blocks = re.split(r"\n{2,}", text)
|
||||||
|
cleaned = []
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
stripped = block.strip()
|
||||||
|
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_is_long_hdr = (
|
||||||
|
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_hdr
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
cleaned.append(block)
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), 0
|
||||||
|
|
||||||
|
|
||||||
|
def _t_merge_title_headers(text: str) -> tuple[str, int]:
|
||||||
|
return _merge_title_headers(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_garbage_headers(text: str) -> tuple[str, int]:
|
||||||
|
def _is_garbage(content: str) -> bool:
|
||||||
|
if content.lstrip().startswith("..."):
|
||||||
|
return True
|
||||||
|
if not re.search(r"[A-Za-z\xc0-\xffΑ-ω]{2,}", content):
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r"\(?\s*[A-Za-z]{1,4}\s*\)?", content.strip()):
|
||||||
|
return True
|
||||||
|
if len(content) > 60 and re.search(r"[!%#]\w|\w[!%#]|\b\w+-\s*\w", content):
|
||||||
|
return True
|
||||||
|
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
|
||||||
|
if re.match(r"^[A-Za-zΑ-ω_]{1,3}\s*[=<>≤≥]", content.strip()):
|
||||||
|
return True
|
||||||
|
if re.match(
|
||||||
|
r"^(Figura|Figure|Fig\.|Tabella|Table|Tab\.)\s+\d",
|
||||||
|
content.strip(), re.IGNORECASE,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
lines = text.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
m = re.match(r"^#{1,6} (.+)$", line)
|
||||||
|
if m and _is_garbage(m.group(1)):
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
text = "\n".join(new_lines)
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
return text, count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_math_header_demotion(text: str) -> tuple[str, int]:
|
||||||
|
lines = text.split("\n")
|
||||||
|
result, count = [], 0
|
||||||
|
for line in lines:
|
||||||
|
m = _MATH_HDR_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
result.append(line)
|
||||||
|
continue
|
||||||
|
body = m.group(2)
|
||||||
|
if len(body) <= 100:
|
||||||
|
result.append(line)
|
||||||
|
continue
|
||||||
|
has_math = len(_MATH_SYMBOLS_RE.findall(body)) >= 3
|
||||||
|
has_exercise = bool(_EXERCISE_TRIGGER_RE.search(body))
|
||||||
|
if not (has_math or has_exercise):
|
||||||
|
result.append(line)
|
||||||
|
continue
|
||||||
|
nm = _NUMBERED_PREFIX_RE.match(body)
|
||||||
|
if nm:
|
||||||
|
result.append(f"**{nm.group(1)}** {nm.group(2)}")
|
||||||
|
else:
|
||||||
|
result.append(body)
|
||||||
|
count += 1
|
||||||
|
return "\n".join(result), count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_frontmatter(text: str) -> tuple[str, int]:
|
||||||
|
blocks = re.split(r"\n{2,}", text)
|
||||||
|
cleaned = []
|
||||||
|
count = 0
|
||||||
|
total = len(blocks)
|
||||||
|
cutoff = max(5, min(15, int(total * 0.20)))
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
stripped = block.strip()
|
||||||
|
if i >= cutoff:
|
||||||
|
cleaned.append(block)
|
||||||
|
continue
|
||||||
|
if not re.match(r"^### ", stripped) or re.match(r"^### \d", stripped):
|
||||||
|
cleaned.append(block)
|
||||||
|
continue
|
||||||
|
body = blocks[i + 1].strip() if i + 1 < len(blocks) else ""
|
||||||
|
is_fm_body = len(body) < 250 and _FM_RE.search(body)
|
||||||
|
is_fm_hdr = _FM_RE.search(stripped)
|
||||||
|
if is_fm_body or is_fm_hdr:
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
cleaned.append(block)
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), count
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Trasformazioni sulla struttura degli header: normalizzazione livelli, concat, bold."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ._constants import _NUMBERED_HDR_RE
|
||||||
|
from ._helpers import _sentence_case
|
||||||
|
|
||||||
|
|
||||||
|
def _t_fix_header_concat(text: str) -> tuple[str, int]:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def _fix(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
hashes = m.group(1)
|
||||||
|
full = m.group(2).strip()
|
||||||
|
if len(full) < 60:
|
||||||
|
return m.group(0)
|
||||||
|
skip = min(10, len(full) // 3)
|
||||||
|
split = re.search(
|
||||||
|
r"(?<=[a-z\xe0\xe8\xe9\xec\xed\xf2\xf3\xf9\xfa\xe4])"
|
||||||
|
r"(?=[A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda])",
|
||||||
|
full[skip:],
|
||||||
|
)
|
||||||
|
if split:
|
||||||
|
pos = skip + split.start()
|
||||||
|
title = full[:pos].strip()
|
||||||
|
body = full[pos:].strip()
|
||||||
|
if len(title) >= 5 and len(body) >= 15:
|
||||||
|
count += 1
|
||||||
|
return f"{hashes} {title}\n\n{body}"
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
text = re.sub(r"^(#{2,6})\s+(.{40,})$", _fix, text, flags=re.MULTILINE)
|
||||||
|
return text, count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_extract_capitolo(text: str) -> tuple[str, int]:
|
||||||
|
def _repl(m: re.Match) -> str:
|
||||||
|
num = m.group(1)
|
||||||
|
titolo = _sentence_case(m.group(2).strip().rstrip("- ").strip())
|
||||||
|
return f"\n\n## Capitolo {num}: {titolo}\n\n"
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"\bCapitolo\s+(\d+)\s*[:\s]\s*([A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda\'L]"
|
||||||
|
r"[A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda\s\'\.,\(\)]{5,80}?)"
|
||||||
|
r"(?=\s*[-–]\s*\d|\s*\n|\s*$)",
|
||||||
|
_repl,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _t_normalize_numbered_headings(text: str) -> tuple[str, int]:
|
||||||
|
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 = min(depths)
|
||||||
|
max_depth = 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]:
|
||||||
|
text = re.sub(r"^#{3,6}\s*$", "", text, flags=re.MULTILINE)
|
||||||
|
text = re.sub(
|
||||||
|
r"^(#{3,6})\s+(\d{1,3})\s+(.+)$",
|
||||||
|
lambda m: f"### {m.group(2)}. {m.group(3)}",
|
||||||
|
text,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
text = re.sub(r"^#{4,6}\s+(.+)$", r"### \1", text, flags=re.MULTILINE)
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_header_bold(text: str) -> tuple[str, int]:
|
||||||
|
text = re.sub(
|
||||||
|
r"^(#{1,6})\s+\*\*(.+?)\*\*\s*$",
|
||||||
|
r"\1 \2",
|
||||||
|
text, flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _t_demote_h1(text: str) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Demota # → ## quando il documento usa # per sezioni principali (≥5 h1
|
||||||
|
con contenuto testuale). Crea gerarchia ## → ### invece di # → ###.
|
||||||
|
"""
|
||||||
|
h1_count = len(re.findall(r"^# [A-Za-z\xc0-\xff]", text, re.MULTILINE))
|
||||||
|
if h1_count < 5:
|
||||||
|
return text, 0
|
||||||
|
count = 0
|
||||||
|
def _repl(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
count += 1
|
||||||
|
return f"## {m.group(1)}"
|
||||||
|
text = re.sub(r"^# (.+)$", _repl, text, flags=re.MULTILINE)
|
||||||
|
return text, count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_normalize_allcaps_headers(text: str) -> tuple[str, int]:
|
||||||
|
def _norm(m: re.Match) -> str:
|
||||||
|
hashes, content = m.group(1), 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, text, flags=re.MULTILINE)
|
||||||
|
return text, 0
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Funzioni helper pure condivise tra i moduli di trasformazione."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ._constants import _ORDINALS_IT, _ORDINALS_EN
|
||||||
|
|
||||||
|
|
||||||
|
def _sentence_case(s: str) -> str:
|
||||||
|
if not s:
|
||||||
|
return s
|
||||||
|
lower = s.lower()
|
||||||
|
return lower[0].upper() + lower[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allcaps_line(line: str) -> bool:
|
||||||
|
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("#")
|
||||||
|
and not stripped.startswith("|")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allcaps_to_header(raw_line: str) -> str:
|
||||||
|
text = re.sub(r"^[-*+]\s+", "", raw_line.strip())
|
||||||
|
text = text.rstrip(".").rstrip("?").strip()
|
||||||
|
|
||||||
|
_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)}"
|
||||||
|
|
||||||
|
_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)}"
|
||||||
|
|
||||||
|
m = re.match(r"^([IVXLCDM]+|[0-9]+)\. (.+)", text)
|
||||||
|
if m:
|
||||||
|
return f"## {m.group(1)}. {_sentence_case(m.group(2).rstrip('.').strip())}"
|
||||||
|
|
||||||
|
return f"## {_sentence_case(text)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_math_environments(text: str) -> tuple[str, int]:
|
||||||
|
_ENVS = (
|
||||||
|
r"Definizione|Definition|Teorema|Theorem|Lemma|"
|
||||||
|
r"Proposizione|Proposition|Corollario|Corollary|"
|
||||||
|
r"Osservazione|Remark|Nota|Note|Esempio|Example"
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
blocks = text.split("\n\n")
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
stripped = block.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
result.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = re.match(
|
||||||
|
rf"^({_ENVS})\s+((?:\d+\.?){{1,4}})\s*(.*)",
|
||||||
|
stripped,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
result.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
env = m.group(1)
|
||||||
|
num = m.group(2).rstrip(".")
|
||||||
|
rest = m.group(3).strip()
|
||||||
|
|
||||||
|
title_m = re.match(r"^(\([^)]{2,60}\))\s+(.*)", rest, re.DOTALL)
|
||||||
|
if title_m:
|
||||||
|
header = f"### {env} {num} {title_m.group(1)}"
|
||||||
|
body = title_m.group(2).strip()
|
||||||
|
else:
|
||||||
|
header = f"### {env} {num}."
|
||||||
|
body = rest
|
||||||
|
|
||||||
|
result.append(f"{header}\n\n{body}" if body else header)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return "\n\n".join(result), count
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_title_headers(text: str) -> tuple[str, int]:
|
||||||
|
count = 0
|
||||||
|
blocks = re.split(r"\n{2,}", text)
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
while i < len(blocks):
|
||||||
|
block = blocks[i]
|
||||||
|
stripped = block.strip()
|
||||||
|
if (
|
||||||
|
re.match(r"^#{2,3} \d+\.\s*$", stripped)
|
||||||
|
and i + 1 < len(blocks)
|
||||||
|
):
|
||||||
|
nxt = blocks[i + 1].strip()
|
||||||
|
if (
|
||||||
|
nxt
|
||||||
|
and "\n" not in nxt
|
||||||
|
and len(nxt) <= 80
|
||||||
|
and not nxt.startswith("#")
|
||||||
|
and not re.match(r"^\d+[\.\)]\s", nxt)
|
||||||
|
):
|
||||||
|
result.append(stripped.rstrip() + " " + nxt)
|
||||||
|
count += 1
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
result.append(block)
|
||||||
|
i += 1
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", "\n\n".join(result)), count
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_article_headers(text: str) -> tuple[str, int]:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def _repl(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
num = m.group(1)
|
||||||
|
rest = m.group(2).strip()
|
||||||
|
|
||||||
|
title_m = re.match(
|
||||||
|
r"^([A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda].{1,74}?)\.\s+"
|
||||||
|
r"([A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda\(\d].{4,})",
|
||||||
|
rest,
|
||||||
|
)
|
||||||
|
if title_m:
|
||||||
|
count += 1
|
||||||
|
return (
|
||||||
|
f"### Art. {num}. {title_m.group(1)}.\n\n"
|
||||||
|
f"{title_m.group(2).strip()}"
|
||||||
|
)
|
||||||
|
if rest:
|
||||||
|
count += 1
|
||||||
|
return f"### Art. {num}.\n\n{rest}"
|
||||||
|
count += 1
|
||||||
|
return f"### Art. {num}."
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"^-\s+Art\.\s+([\d]+[a-z\-]*)\.\s*(.*)",
|
||||||
|
_repl,
|
||||||
|
text,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
return text, count
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Costruzione struttura: TOC, ALLCAPS→##, sezioni numerate, ambienti matematici, articoli."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ._constants import (
|
||||||
|
_TOC_KEYWORDS, _BIB_MARKERS_RE, _FOOTNOTE_AUTHOR_RE,
|
||||||
|
_TOC_ITEM_RE, _TOC_HDR_WITH_PAGE_RE,
|
||||||
|
)
|
||||||
|
from ._helpers import (
|
||||||
|
_is_allcaps_line, _allcaps_to_header,
|
||||||
|
_extract_math_environments, _extract_article_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_toc(text: str) -> tuple[str, int]:
|
||||||
|
lines = text.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
_in_toc = False
|
||||||
|
removed = False
|
||||||
|
for line in lines:
|
||||||
|
bare = re.sub(r"^#+\s*", "", line.strip())
|
||||||
|
first_word = bare.split(".")[0].strip().lower()
|
||||||
|
if first_word in _TOC_KEYWORDS:
|
||||||
|
removed = True
|
||||||
|
_in_toc = True
|
||||||
|
continue
|
||||||
|
if _in_toc:
|
||||||
|
if re.match(r"^\s*$", line) or re.match(r"^\s*[-*+]\s+\d", line):
|
||||||
|
continue
|
||||||
|
if re.match(r"^\s*[-*+]\s+.{2,70}\s+\d{1,3}\s*$", line):
|
||||||
|
continue
|
||||||
|
# Righe brevi con riferimento pagina (es. "Prefazione pag. 4")
|
||||||
|
if re.match(r"^.{3,80}\s+pag\.\s*\d{1,4}\s*$", line.strip()):
|
||||||
|
continue
|
||||||
|
if len(line.strip()) > 200:
|
||||||
|
_in_toc = False
|
||||||
|
new_lines.append(line)
|
||||||
|
continue
|
||||||
|
_in_toc = False
|
||||||
|
new_lines.append(line)
|
||||||
|
return "\n".join(new_lines), 1 if removed else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _t_remove_orphan_toc(text: str) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Rimuove voci di sommario senza dot-leader che sfuggono a _t_remove_toc.
|
||||||
|
Rileva: (a) blocchi di 3+ righe consecutive che matchano il pattern TOC
|
||||||
|
nei primi 25% del documento; (b) header ### N. Titolo PAGINA il cui corpo
|
||||||
|
è una lista di voci numerate.
|
||||||
|
"""
|
||||||
|
blocks = re.split(r"\n{2,}", text)
|
||||||
|
total = len(blocks)
|
||||||
|
cutoff = max(10, min(40, int(total * 0.25)))
|
||||||
|
to_drop = set()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < cutoff and i < total:
|
||||||
|
b = blocks[i].strip()
|
||||||
|
|
||||||
|
# (a) Sequenza di 3+ blocchi TOC consecutivi
|
||||||
|
if _TOC_ITEM_RE.match(b):
|
||||||
|
j = i
|
||||||
|
while j < min(cutoff, i + 60) and j < len(blocks) and _TOC_ITEM_RE.match(blocks[j].strip()):
|
||||||
|
j += 1
|
||||||
|
if j - i >= 3:
|
||||||
|
for k in range(i, j):
|
||||||
|
to_drop.add(k)
|
||||||
|
# Rimuovi anche l'header ### precedente se ha numero di pagina
|
||||||
|
if i > 0 and _TOC_HDR_WITH_PAGE_RE.match(blocks[i - 1].strip()):
|
||||||
|
to_drop.add(i - 1)
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
|
||||||
|
# (b) Header ### N. Titolo PAGINA con corpo che è lista di voci numerate
|
||||||
|
if _TOC_HDR_WITH_PAGE_RE.match(b):
|
||||||
|
body = blocks[i + 1].strip() if i + 1 < len(blocks) else ""
|
||||||
|
# Il corpo contiene 2+ occorrenze di "N. Titolo"
|
||||||
|
toc_hits = re.findall(r"\d+\.?\s+[A-Za-zÀ-ú]", body)
|
||||||
|
if len(toc_hits) >= 2 and len(body) < 300:
|
||||||
|
to_drop.add(i)
|
||||||
|
if i + 1 < total:
|
||||||
|
to_drop.add(i + 1)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not to_drop:
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
kept = [b for idx, b in enumerate(blocks) if idx not in to_drop]
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", "\n\n".join(kept)), len(to_drop)
|
||||||
|
|
||||||
|
|
||||||
|
def _t_allcaps_to_headers(text: str) -> tuple[str, int]:
|
||||||
|
count = 0
|
||||||
|
blocks = text.split("\n\n")
|
||||||
|
new_blocks = []
|
||||||
|
for block in blocks:
|
||||||
|
stripped = block.strip()
|
||||||
|
if "\n" not in stripped and _is_allcaps_line(stripped):
|
||||||
|
new_blocks.append(_allcaps_to_header(stripped))
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
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))
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
converted.append(ln)
|
||||||
|
new_blocks.append("\n".join(converted))
|
||||||
|
return "\n\n".join(new_blocks), count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, int]:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def _num_repl(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
content = m.group(2).strip()
|
||||||
|
if content.endswith(".") and len(content) > 40:
|
||||||
|
return m.group(0)
|
||||||
|
# Paragrafo lungo: non è un titolo di sezione
|
||||||
|
if len(content) > 130:
|
||||||
|
return m.group(0)
|
||||||
|
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
|
||||||
|
return m.group(0)
|
||||||
|
count += 1
|
||||||
|
# Prova a separare titolo dal corpo alla prima transizione minusc→Maiusc
|
||||||
|
split = re.search(
|
||||||
|
r"(?<=[a-z\xe0\xe8\xe9\xec\xed\xf2\xf3\xf9\xfa])\s+"
|
||||||
|
r"(?=[A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda])",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
if split and 3 <= split.start() and len(content) - split.end() >= 40:
|
||||||
|
title = content[: split.start()].strip()
|
||||||
|
body = content[split.end():].strip()
|
||||||
|
return f"### {m.group(1)}. {title}\n\n{body}"
|
||||||
|
return f"### {m.group(1)}. {content}"
|
||||||
|
|
||||||
|
text = re.sub(r"^(\d+)\.\s+(.+)$", _num_repl, text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
def _num_letter_repl(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
count += 1
|
||||||
|
return f"### {m.group(1)}{m.group(2)}.\n\n{m.group(3).strip()}"
|
||||||
|
|
||||||
|
text = re.sub(r"^(\d+)\s*([a-z])\.\s+(.+)$", _num_letter_repl, text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
if not has_exercises:
|
||||||
|
def _aphorism_repl(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
content = m.group(2).strip()
|
||||||
|
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
|
||||||
|
return m.group(0)
|
||||||
|
count += 1
|
||||||
|
split = re.search(
|
||||||
|
r"(?<=[a-z\xe0\xe8\xe9\xec\xed\xf2\xf3\xf9\xfa])\s+"
|
||||||
|
r"(?=[A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda])",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
if split and 3 <= split.start() and len(content) - split.end() >= 40:
|
||||||
|
title = content[: split.start()].strip()
|
||||||
|
body = content[split.end():].strip()
|
||||||
|
return f"\n\n### {m.group(1)}. {title}\n\n{body}"
|
||||||
|
return f"\n\n### {m.group(1)}. {content}"
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"^-[ \t]+(\d{1,3})\.[ \t]+(.{10,})$",
|
||||||
|
_aphorism_repl,
|
||||||
|
text,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _list_section_repl(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
num = m.group(1)
|
||||||
|
content = m.group(2).strip()
|
||||||
|
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
|
||||||
|
return m.group(0)
|
||||||
|
count += 1
|
||||||
|
split = re.search(
|
||||||
|
r"(?<=[a-z\xe0\xe8\xe9\xec\xed\xf2\xf3\xf9\xfa])\s+"
|
||||||
|
r"(?=[A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda])",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
if split and split.start() >= 3:
|
||||||
|
title = content[: split.start()].strip()
|
||||||
|
body = content[split.end():].strip()
|
||||||
|
if len(body) >= 20:
|
||||||
|
return f"\n\n### {num}. {title}\n\n{body}"
|
||||||
|
return f"\n\n### {num}. {content}"
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"^-\s+(\d{1,3})\s+([A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda\'L].{10,})$",
|
||||||
|
_list_section_repl,
|
||||||
|
text,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
return text, count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_promote_chapter_headers(text: str) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Promuove ### N. Titolo → ## N. Titolo quando sembrano capitoli principali.
|
||||||
|
Condizioni: ≥3 headers ### con numero 1–50, nessun ## già presente,
|
||||||
|
numeri di capitolo sequenziali e NON duplicati.
|
||||||
|
Numeri duplicati indicano una raccolta multi-articolo: non promuovere.
|
||||||
|
"""
|
||||||
|
if re.search(r"^## \d", text, re.MULTILINE):
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
pattern = re.compile(r"^### (\d+)\. (.+)$", re.MULTILINE)
|
||||||
|
matches = list(pattern.finditer(text))
|
||||||
|
chapter_matches = [m for m in matches if int(m.group(1)) <= 50]
|
||||||
|
|
||||||
|
if len(chapter_matches) < 3:
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
chapter_nums_list = [int(m.group(1)) for m in chapter_matches]
|
||||||
|
|
||||||
|
# Se qualche numero appare ≥3 volte è una raccolta multi-articolo: non promuovere
|
||||||
|
num_counter: dict[int, int] = {}
|
||||||
|
for n in chapter_nums_list:
|
||||||
|
num_counter[n] = num_counter.get(n, 0) + 1
|
||||||
|
if max(num_counter.values()) >= 3:
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
chapter_nums = set(chapter_nums_list)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def _repl(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
if int(m.group(1)) in chapter_nums:
|
||||||
|
count += 1
|
||||||
|
return f"## {m.group(1)}. {m.group(2)}"
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
return pattern.sub(_repl, text), count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_extract_math(text: str) -> tuple[str, int]:
|
||||||
|
return _extract_math_environments(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _t_extract_articles(text: str) -> tuple[str, int]:
|
||||||
|
return _extract_article_headers(text)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Trasformazioni sul testo: merge paragrafi, whitespace, poesia, versi."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ._constants import _VERSE_NUM_RE
|
||||||
|
|
||||||
|
|
||||||
|
def _t_merge_paragraphs(text: str) -> tuple[str, int]:
|
||||||
|
_SENTENCE_END = set(".?!\xbb)\"'")
|
||||||
|
blocks = text.split("\n\n")
|
||||||
|
merged = []
|
||||||
|
count = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(blocks):
|
||||||
|
b = blocks[i]
|
||||||
|
stripped = b.strip()
|
||||||
|
while (
|
||||||
|
i + 1 < len(blocks)
|
||||||
|
and stripped
|
||||||
|
and not stripped.startswith("#")
|
||||||
|
and not stripped.startswith("|")
|
||||||
|
and stripped[-1] not in _SENTENCE_END
|
||||||
|
):
|
||||||
|
nxt = blocks[i + 1].strip()
|
||||||
|
if (
|
||||||
|
not nxt
|
||||||
|
or nxt.startswith("#")
|
||||||
|
or nxt.startswith("|")
|
||||||
|
or re.match(r"^\d+\.", nxt)
|
||||||
|
or re.match(r"^[-*+]\s", nxt)
|
||||||
|
):
|
||||||
|
break
|
||||||
|
b = stripped + " " + nxt
|
||||||
|
stripped = b.strip()
|
||||||
|
count += 1
|
||||||
|
i += 1
|
||||||
|
merged.append(b)
|
||||||
|
i += 1
|
||||||
|
text = "\n\n".join(merged)
|
||||||
|
text = re.sub(r"(?m)^\|---\|\s*", "", text)
|
||||||
|
return text, count
|
||||||
|
|
||||||
|
|
||||||
|
def _t_normalize_whitespace(text: str) -> tuple[str, int]:
|
||||||
|
lines = text.split("\n")
|
||||||
|
text = "\n".join(
|
||||||
|
re.sub(r" +", " ", line) if line.strip() else line
|
||||||
|
for line in lines
|
||||||
|
)
|
||||||
|
return text, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _t_collapse_blank_lines(text: str) -> tuple[str, int]:
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", text), 0
|
||||||
|
|
||||||
|
|
||||||
|
def _t_restore_poetry_lines(text: str) -> tuple[str, int]:
|
||||||
|
count = 0
|
||||||
|
blocks = text.split("\n\n")
|
||||||
|
result = []
|
||||||
|
|
||||||
|
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)]
|
||||||
|
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))
|
||||||
|
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_demote_verse_headers(text: str) -> tuple[str, int]:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def _demote(m: re.Match) -> str:
|
||||||
|
nonlocal count
|
||||||
|
hashes, content = m.group(1), m.group(2).strip()
|
||||||
|
if not re.search(r"\s\d{1,4}\s*$", content):
|
||||||
|
return m.group(0)
|
||||||
|
inner = re.sub(r"\s\d{1,4}\s*$", "", content)
|
||||||
|
if not re.search(r'[,;:.!?\xbb"\'][\ ]+[A-Za-z\xc0-\xff\xab""]', inner):
|
||||||
|
return m.group(0)
|
||||||
|
count += 1
|
||||||
|
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
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Estrazione PDF: verifica dipendenze, validazione, metadati, conversione → raw Markdown."""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dipendenze ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _check_deps() -> None:
|
||||||
|
try:
|
||||||
|
import opendataloader_pdf # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
print("Errore: opendataloader-pdf non installato.")
|
||||||
|
print(" pip install opendataloader-pdf")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["java", "-version"], capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise FileNotFoundError
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Errore: Java 11+ non trovato sul PATH.")
|
||||||
|
print(" Installa da https://adoptium.net/")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Validazione PDF ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def validate_pdf(pdf_path: Path) -> tuple[bool, str]:
|
||||||
|
"""Verifica esistenza, leggibilità e presenza di testo digitale estraibile."""
|
||||||
|
if not pdf_path.exists():
|
||||||
|
return False, f"File non trovato: {pdf_path}"
|
||||||
|
if pdf_path.suffix.lower() != ".pdf":
|
||||||
|
return False, f"Non è un PDF: {pdf_path.name}"
|
||||||
|
size = pdf_path.stat().st_size
|
||||||
|
if size == 0:
|
||||||
|
return False, "File vuoto"
|
||||||
|
if size < 1024:
|
||||||
|
return False, f"File troppo piccolo ({size} byte) — probabilmente corrotto"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pdfplumber
|
||||||
|
with pdfplumber.open(pdf_path) as pdf:
|
||||||
|
n_pages = len(pdf.pages)
|
||||||
|
if n_pages == 0:
|
||||||
|
return False, "PDF senza pagine"
|
||||||
|
sample = min(5, n_pages)
|
||||||
|
pages_with_text = sum(
|
||||||
|
1 for i in range(sample)
|
||||||
|
if len((pdf.pages[i].extract_text() or "").strip()) > 50
|
||||||
|
)
|
||||||
|
if pages_with_text == 0:
|
||||||
|
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, (
|
||||||
|
f"Nessun testo nelle prime {extended} pagine "
|
||||||
|
f"— probabilmente scansionato (OCR non supportato)"
|
||||||
|
)
|
||||||
|
return True, f"{n_pages} pagine, testo digitale confermato"
|
||||||
|
except MemoryError:
|
||||||
|
return False, "Memoria esaurita durante l'apertura del PDF"
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e).lower()
|
||||||
|
if "password" in msg or "encrypted" in msg:
|
||||||
|
return False, "PDF protetto da password"
|
||||||
|
return False, f"Impossibile aprire: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Metadati PDF ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def extract_metadata(pdf_path: Path) -> dict:
|
||||||
|
"""
|
||||||
|
Estrae title, author, year e page count dal PDF tramite fitz.
|
||||||
|
Restituisce un dict con chiavi sempre presenti (stringa vuota se assenti).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import fitz
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
meta = doc.metadata
|
||||||
|
pages = len(doc)
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
def _clean(s: str) -> str:
|
||||||
|
return s.strip() if s else ""
|
||||||
|
|
||||||
|
year = ""
|
||||||
|
creation = meta.get("creationDate", "")
|
||||||
|
m = re.match(r"D:(\d{4})", creation)
|
||||||
|
if m:
|
||||||
|
year = m.group(1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": pdf_path.name,
|
||||||
|
"title": _clean(meta.get("title", "")),
|
||||||
|
"author": _clean(meta.get("author", "")),
|
||||||
|
"year": year,
|
||||||
|
"pages": pages,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"source": pdf_path.name, "title": "", "author": "", "year": "", "pages": 0}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Conversione PDF → Markdown ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _is_tagged_pdf(pdf_path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
import fitz
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
tagged = "StructTreeRoot" in doc.pdf_catalog()
|
||||||
|
doc.close()
|
||||||
|
return tagged
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def convert_pdf(pdf_path: Path, out_dir: Path) -> Path:
|
||||||
|
"""
|
||||||
|
Converte il PDF in Markdown tramite opendataloader-pdf (XY-Cut++).
|
||||||
|
|
||||||
|
Parametri per output RAG-ottimale:
|
||||||
|
keep_line_breaks=False → testo fluente, elimina hard-wrap del PDF
|
||||||
|
reading_order="xycut" → ricostruisce ordine di lettura multi-colonna
|
||||||
|
sanitize=False → preserva il testo originale senza filtri
|
||||||
|
image_output="off" → nessuna immagine estratta né referenziata
|
||||||
|
table_method="cluster" → rileva tabelle anche senza bordi visibili
|
||||||
|
content_safety_off → non scarta footnote (tiny) né layer OCG nascosti
|
||||||
|
use_struct_tree → attivo solo per PDF taggati (Word/InDesign)
|
||||||
|
markdown_page_separator → inserisce separatore + marker pagina tra pagine
|
||||||
|
replace_invalid_chars → sostituisce caratteri non validi con spazio
|
||||||
|
"""
|
||||||
|
import opendataloader_pdf
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tagged = _is_tagged_pdf(pdf_path)
|
||||||
|
|
||||||
|
opendataloader_pdf.convert(
|
||||||
|
input_path=str(pdf_path),
|
||||||
|
output_dir=str(out_dir),
|
||||||
|
format="markdown",
|
||||||
|
keep_line_breaks=False,
|
||||||
|
reading_order="xycut",
|
||||||
|
sanitize=False,
|
||||||
|
image_output="off",
|
||||||
|
table_method="cluster",
|
||||||
|
content_safety_off=["tiny", "hidden-ocg"],
|
||||||
|
use_struct_tree=tagged,
|
||||||
|
markdown_page_separator="\n\n---\n<!-- page: %page-number% -->\n\n",
|
||||||
|
replace_invalid_chars=" ",
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
md_file = out_dir / f"{pdf_path.stem}.md"
|
||||||
|
if not md_file.exists():
|
||||||
|
candidates = list(out_dir.glob("*.md"))
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError(f"Nessun file .md prodotto in {out_dir}")
|
||||||
|
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
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .structure import _parse_sections_with_body
|
||||||
|
from ._constants import _MATH_SYMBOLS_RE, _EXERCISE_TRIGGER_RE, _MATH_HDR_RE
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
stem: str,
|
||||||
|
out_dir: Path,
|
||||||
|
clean_text: str,
|
||||||
|
t_stats: dict,
|
||||||
|
profile: dict,
|
||||||
|
reduction: float,
|
||||||
|
) -> Path:
|
||||||
|
text_lines = clean_text.split("\n")
|
||||||
|
|
||||||
|
sections = _parse_sections_with_body(clean_text, 3)
|
||||||
|
lengths = [len(body) for _, body in sections]
|
||||||
|
|
||||||
|
def _pct(data: list[int], p: float) -> int:
|
||||||
|
if not data:
|
||||||
|
return 0
|
||||||
|
s = sorted(data)
|
||||||
|
return s[max(0, min(len(s) - 1, int(len(s) * p)))]
|
||||||
|
|
||||||
|
distribution = {
|
||||||
|
"min": min(lengths) if lengths else 0,
|
||||||
|
"p25": _pct(lengths, 0.25),
|
||||||
|
"mediana": _pct(lengths, 0.50),
|
||||||
|
"p75": _pct(lengths, 0.75),
|
||||||
|
"max": max(lengths) if lengths else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
bare_hdrs = [
|
||||||
|
{"header": hdr, "corpo_inizio": body[:120].replace("\n", " ")}
|
||||||
|
for hdr, body in sections
|
||||||
|
if re.match(r"^### \d+\.\s*$", hdr) and len(body.strip()) < 30
|
||||||
|
]
|
||||||
|
short_secs = [
|
||||||
|
{"header": hdr, "chars": length, "testo": body[:80].replace("\n", " ")}
|
||||||
|
for (hdr, body), length in zip(sections, lengths)
|
||||||
|
if 0 < length < 150
|
||||||
|
]
|
||||||
|
long_secs = [
|
||||||
|
{"header": hdr, "chars": length}
|
||||||
|
for (hdr, _), length in zip(sections, lengths)
|
||||||
|
if length > 1500
|
||||||
|
]
|
||||||
|
|
||||||
|
def _scan(pattern: str, max_n: int = 10) -> list[dict]:
|
||||||
|
hits = []
|
||||||
|
for i, line in enumerate(text_lines):
|
||||||
|
if re.search(pattern, line) and not re.match(r"^#+ ", line):
|
||||||
|
hits.append({"riga": i + 1, "testo": line.strip()[:120]})
|
||||||
|
if len(hits) >= max_n:
|
||||||
|
break
|
||||||
|
return hits
|
||||||
|
|
||||||
|
def _scan_formula_headers(max_n: int = 10) -> list[dict]:
|
||||||
|
hits = []
|
||||||
|
for i, line in enumerate(text_lines):
|
||||||
|
m = _MATH_HDR_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
body = m.group(2)
|
||||||
|
if len(body) <= 100:
|
||||||
|
continue
|
||||||
|
has_math = len(_MATH_SYMBOLS_RE.findall(body)) >= 3
|
||||||
|
has_ex = bool(_EXERCISE_TRIGGER_RE.search(body))
|
||||||
|
if has_math or has_ex:
|
||||||
|
hits.append({"riga": i + 1, "testo": line.strip()[:120]})
|
||||||
|
if len(hits) >= max_n:
|
||||||
|
break
|
||||||
|
return hits
|
||||||
|
|
||||||
|
residui = {
|
||||||
|
"backtick": _scan(r"`"),
|
||||||
|
"dotleader": _scan(r"(?:\. ){3,}"),
|
||||||
|
"url": _scan(r"^(https?://|www\.)\S+"),
|
||||||
|
"immagini": _scan(r"!\[[^\]]*\]\([^)]*\)"),
|
||||||
|
"br_inline": _scan(r"<br>"),
|
||||||
|
"simboli_encoding": _scan(r'(?<=[0-9A-Za-z])[!"](?=[0-9A-Za-z])'),
|
||||||
|
"formule_inline": _scan(r"\[\d+\.\d+\]"),
|
||||||
|
"footnote_markers": _scan(r'[¹²³⁰⁴-⁹]'),
|
||||||
|
"pua_markers": _scan(r'[-]'),
|
||||||
|
"formula_headers": _scan_formula_headers(),
|
||||||
|
}
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"stem": stem,
|
||||||
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"transforms": {
|
||||||
|
**t_stats,
|
||||||
|
"riduzione_pct": round(reduction),
|
||||||
|
},
|
||||||
|
"structure": profile,
|
||||||
|
"distribution": distribution,
|
||||||
|
"anomalie": {
|
||||||
|
"bare_headers": len(bare_hdrs),
|
||||||
|
"short_sections": len(short_secs),
|
||||||
|
"long_sections": len(long_secs),
|
||||||
|
"bare_headers_list": bare_hdrs,
|
||||||
|
"short_sections_list": short_secs,
|
||||||
|
"long_sections_list": long_secs,
|
||||||
|
},
|
||||||
|
"residui": {
|
||||||
|
"backtick": len(residui["backtick"]),
|
||||||
|
"dotleader": len(residui["dotleader"]),
|
||||||
|
"url": len(residui["url"]),
|
||||||
|
"immagini": len(residui["immagini"]),
|
||||||
|
"br_inline": len(residui["br_inline"]),
|
||||||
|
"simboli_encoding": len(residui["simboli_encoding"]),
|
||||||
|
"formule_inline": len(residui["formule_inline"]),
|
||||||
|
"footnote_markers": len(residui["footnote_markers"]),
|
||||||
|
"pua_markers": len(residui["pua_markers"]),
|
||||||
|
"backtick_esempi": residui["backtick"],
|
||||||
|
"dotleader_esempi": residui["dotleader"],
|
||||||
|
"url_esempi": residui["url"],
|
||||||
|
"immagini_esempi": residui["immagini"],
|
||||||
|
"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"],
|
||||||
|
"formula_headers": len(residui["formula_headers"]),
|
||||||
|
"formula_headers_esempi": residui["formula_headers"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
report_path = out_dir / "report.json"
|
||||||
|
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return report_path
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .extract import validate_pdf, convert_pdf, extract_metadata
|
||||||
|
from ._apply import apply_transforms
|
||||||
|
from .structure import analyze
|
||||||
|
from .report import build_report
|
||||||
|
from .validator import _score, _grade
|
||||||
|
|
||||||
|
|
||||||
|
_LIVELLO_DESC = {3: "ricca (h3)", 2: "parziale (h2)", 1: "paragrafi", 0: "testo piatto"}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_frontmatter(meta: dict) -> str:
|
||||||
|
lines = ["---", f"source: {meta['source']}"]
|
||||||
|
if meta["title"]:
|
||||||
|
lines.append(f'title: "{meta["title"]}"')
|
||||||
|
if meta["author"]:
|
||||||
|
lines.append(f'author: "{meta["author"]}"')
|
||||||
|
if meta["year"]:
|
||||||
|
lines.append(f"year: {meta['year']}")
|
||||||
|
if meta["pages"]:
|
||||||
|
lines.append(f"pages: {meta['pages']}")
|
||||||
|
lines += ["---", ""]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
_SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||||
|
|
||||||
|
|
||||||
|
class _Spinner:
|
||||||
|
"""Spinner animato in un thread separato — mostra frame + tempo trascorso."""
|
||||||
|
|
||||||
|
def __init__(self, prefix: str):
|
||||||
|
self._prefix = prefix
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._t0 = 0.0
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self._t0 = time.perf_counter()
|
||||||
|
self._thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
self._stop.set()
|
||||||
|
self._thread.join()
|
||||||
|
sys.stdout.write("\r" + " " * 72 + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
i = 0
|
||||||
|
while not self._stop.wait(0.1):
|
||||||
|
elapsed = time.perf_counter() - self._t0
|
||||||
|
frame = _SPIN_FRAMES[i % len(_SPIN_FRAMES)]
|
||||||
|
sys.stdout.write(f"\r {frame} {self._prefix} {elapsed:.0f}s")
|
||||||
|
sys.stdout.flush()
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def run(stem: str, project_root: Path, force: bool) -> bool:
|
||||||
|
pdf_path = project_root / "sources" / f"{stem}.pdf"
|
||||||
|
out_dir = project_root / "conversione" / stem
|
||||||
|
raw_out = out_dir / "raw.md"
|
||||||
|
clean_out = out_dir / "clean.md"
|
||||||
|
|
||||||
|
print(f"\n{'─' * 52}")
|
||||||
|
print(f" {stem}")
|
||||||
|
print(f"{'─' * 52}")
|
||||||
|
|
||||||
|
if clean_out.exists() and not force:
|
||||||
|
print(f" ⚠️ conversione/{stem}/clean.md già presente — skip")
|
||||||
|
print(f" (usa --force per rieseguire)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# [1] Validazione + metadati
|
||||||
|
print(" [1/4] Validazione PDF...")
|
||||||
|
pdf_mb = pdf_path.stat().st_size / (1024 * 1024) if pdf_path.exists() else 0
|
||||||
|
print(f" File: {pdf_path.name} ({pdf_mb:.1f} MB)")
|
||||||
|
ok, msg = validate_pdf(pdf_path)
|
||||||
|
if not ok:
|
||||||
|
print(f" ✗ {msg}")
|
||||||
|
return False
|
||||||
|
print(f" ✅ {msg}")
|
||||||
|
meta = extract_metadata(pdf_path)
|
||||||
|
if meta["title"]:
|
||||||
|
print(f" Titolo: {meta['title']}")
|
||||||
|
if meta["author"]:
|
||||||
|
print(f" Autore: {meta['author']}")
|
||||||
|
|
||||||
|
# [2] Conversione
|
||||||
|
print(" [2/4] Conversione PDF → Markdown (opendataloader-pdf)...")
|
||||||
|
with _Spinner("opendataloader-pdf in esecuzione...") as spinner:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
try:
|
||||||
|
md_file = convert_pdf(pdf_path, Path(tmp))
|
||||||
|
except MemoryError:
|
||||||
|
print(" ✗ Memoria esaurita durante la conversione")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Conversione fallita: {e}")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
raw_text = md_file.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
print(f" ✗ Errore encoding nel file prodotto: {e}")
|
||||||
|
return False
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
|
||||||
|
size_kb = len(raw_text.encode()) // 1024
|
||||||
|
n_lines = raw_text.count("\n")
|
||||||
|
print(f" ✅ Markdown grezzo: {size_kb} KB, {n_lines} righe ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
# [3] Pulizia strutturale
|
||||||
|
print(" [3/4] Pulizia strutturale...")
|
||||||
|
|
||||||
|
def _on_step(i: int, total: int, label: str) -> None:
|
||||||
|
sys.stdout.write(f"\r [{i}/{total}] {label:<45}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
clean_text, t = apply_transforms(raw_text, on_step=_on_step)
|
||||||
|
sys.stdout.write("\r" + " " * 72 + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
clean_text = _build_frontmatter(meta) + clean_text
|
||||||
|
reduction = 100 * (1 - len(clean_text) / len(raw_text)) if raw_text else 0
|
||||||
|
print(f" ✅ Encoding")
|
||||||
|
print(f" Simboli PUA corretti: {t['n_simboli_pua_corretti']}")
|
||||||
|
print(f" Accenti corretti: {t['n_accenti_corretti']}")
|
||||||
|
print(f" Artefatti")
|
||||||
|
print(f" Immagini rimosse: {t['n_immagini_rimosse']}")
|
||||||
|
print(f" <br> rimossi: {t['n_br_rimossi']}")
|
||||||
|
print(f" Note rimosse: {t['n_note_rimosse']}")
|
||||||
|
print(f" Dot-leader rimossi: {t['n_dotleader_rimossi']}")
|
||||||
|
print(f" Righe ricorrenti rim.: {t['n_righe_ricorrenti_rimosse']}")
|
||||||
|
print(f" URL rimossi: {t['n_url_rimossi']}")
|
||||||
|
print(f" Watermark rimossi: {t['n_watermark_rimossi']}")
|
||||||
|
print(f" Header")
|
||||||
|
print(f" Header concat fixati: {t['n_header_concat_fixati']}")
|
||||||
|
print(f" Header num. normaliz.: {t['n_header_numerati_normalizzati']}")
|
||||||
|
print(f" Struttura")
|
||||||
|
print(f" TOC rimosso: {'sì' if t['toc_rimosso'] else 'no'}")
|
||||||
|
print(f" TOC orfani rimossi: {t['n_toc_orfani_rimossi']}")
|
||||||
|
print(f" ALL-CAPS → ##: {t['n_header_allcaps']}")
|
||||||
|
print(f" Sezioni → ###: {t['n_sezioni_numerate']}")
|
||||||
|
print(f" Ambienti matematici: {t['n_ambienti_matematici']}")
|
||||||
|
print(f" Articoli → ###: {t['n_articoli_estratti']}")
|
||||||
|
print(f" Testo")
|
||||||
|
print(f" Paragrafi uniti: {t['n_paragrafi_uniti']}")
|
||||||
|
print(f" Versi poesia riprist.: {t['n_versi_ripristinati']}")
|
||||||
|
print(f" Header verso demotati: {t['n_header_verso_demotati']}")
|
||||||
|
print(f" Rifinitura")
|
||||||
|
print(f" Garbage header rim.: {t['n_garbage_headers_rimossi']}")
|
||||||
|
print(f" Titoli header uniti: {t['n_titoli_uniti']}")
|
||||||
|
print(f" Formula-hdr demotati: {t['n_formula_headers_demotati']}")
|
||||||
|
print(f" Frontmatter rimossi: {t['n_frontmatter_rimossi']}")
|
||||||
|
print(f" Riduzione testo: {reduction:.0f}%")
|
||||||
|
|
||||||
|
# [4] Profilo strutturale
|
||||||
|
print(" [4/4] Analisi struttura...")
|
||||||
|
try:
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
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)
|
||||||
|
(out_dir / "structure_profile.json").write_text(
|
||||||
|
json.dumps(profile, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✅ Struttura: livello {profile['livello_struttura']} — "
|
||||||
|
f"{_LIVELLO_DESC[profile['livello_struttura']]}")
|
||||||
|
print(f" h1={profile['n_h1']} h2={profile['n_h2']} h3={profile['n_h3']} "
|
||||||
|
f"paragrafi={profile['n_paragrafi']}")
|
||||||
|
print(f" Strategia chunking: {profile['strategia_chunking']}")
|
||||||
|
print(f" Lingua rilevata: {profile['lingua_rilevata']}")
|
||||||
|
for w in profile["avvertenze"]:
|
||||||
|
print(f" ⚠️ {w}")
|
||||||
|
|
||||||
|
report_path = build_report(stem, out_dir, clean_text, t, profile, reduction)
|
||||||
|
report_data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
score, _ = _score(report_data)
|
||||||
|
|
||||||
|
print(f"\n Output → conversione/{stem}/")
|
||||||
|
print(f" raw.md (immutabile) clean.md report.json")
|
||||||
|
print(f" Punteggio qualità: {score}/100 {_grade(score)}")
|
||||||
|
return True
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ─── Rilevamento lingua ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_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",
|
||||||
|
])
|
||||||
|
_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:
|
||||||
|
words = re.findall(r"\b[a-zA-Z]{2,}\b", text.lower())
|
||||||
|
sample = words[:2000]
|
||||||
|
scores = {
|
||||||
|
"it": sum(1 for w in sample if w in _IT_WORDS),
|
||||||
|
"en": sum(1 for w in sample if w in _EN_WORDS),
|
||||||
|
"fr": sum(1 for w in sample if w in _FR_WORDS),
|
||||||
|
"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"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Analisi struttura ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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:
|
||||||
|
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()))
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sections(text: str, level: int) -> list[str]:
|
||||||
|
prefix = "#" * level + " "
|
||||||
|
parts = re.split(rf"(?m)^{re.escape(prefix)}.+", text)
|
||||||
|
return [p for p in parts[1:] if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sections_with_body(text: str, level: int = 3) -> list[tuple[str, str]]:
|
||||||
|
"""Restituisce lista di (header_line, body_text) per tutti gli header al livello dato."""
|
||||||
|
prefix = "#" * level + " "
|
||||||
|
lines = text.split("\n")
|
||||||
|
sections: list[tuple[str, str]] = []
|
||||||
|
cur_hdr: str | None = None
|
||||||
|
cur_body: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith(prefix):
|
||||||
|
if cur_hdr is not None:
|
||||||
|
sections.append((cur_hdr, "\n".join(cur_body).strip()))
|
||||||
|
cur_hdr = line
|
||||||
|
cur_body = []
|
||||||
|
elif cur_hdr is not None:
|
||||||
|
cur_body.append(line)
|
||||||
|
if cur_hdr is not None:
|
||||||
|
sections.append((cur_hdr, "\n".join(cur_body).strip()))
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(md_path: Path) -> dict:
|
||||||
|
text = 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)
|
||||||
|
|
||||||
|
if n_h3 >= 5:
|
||||||
|
livello, boundary, strategia = 3, "h3", "h3_aware"
|
||||||
|
section_bodies = _split_sections(text, 3)
|
||||||
|
# Se h3 sono enormi e h2 più brevi, h2 è il boundary corretto
|
||||||
|
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:
|
||||||
|
livello, boundary, strategia = 2, "h2", "h2_paragraph_split"
|
||||||
|
section_bodies = _split_sections(text, 2)
|
||||||
|
elif n_h1 + n_h2 + n_h3 >= 1:
|
||||||
|
livello, boundary, strategia = 1, "paragrafo", "paragraph"
|
||||||
|
section_bodies = [b for b in re.split(r"\n{2,}", text) if b.strip()]
|
||||||
|
elif n_paragrafi >= 3:
|
||||||
|
livello, boundary, strategia = 1, "paragrafo", "paragraph"
|
||||||
|
section_bodies = [b for b in re.split(r"\n{2,}", text) if b.strip()]
|
||||||
|
else:
|
||||||
|
livello, boundary, strategia = 0, "nessuno", "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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_GRADES = [(90, "A"), (75, "B"), (60, "C"), (40, "D"), (0, "F")]
|
||||||
|
|
||||||
|
|
||||||
|
def _score(r: dict) -> tuple[int, list[str]]:
|
||||||
|
"""
|
||||||
|
Voto 0-100 sulla qualità del clean.md per vettorizzazione.
|
||||||
|
|
||||||
|
Penalità struttura:
|
||||||
|
livello 0 (assente) → −40
|
||||||
|
livello 1 (piatto) → −15
|
||||||
|
|
||||||
|
Penalità residui (degradano il retrieval):
|
||||||
|
backtick → −2/cad (max −20)
|
||||||
|
dot-leader → −5/cad (max −10)
|
||||||
|
URL/watermark → −5/cad (max −15)
|
||||||
|
immagini → −5/cad (max −10)
|
||||||
|
<br> inline → −2/cad (max −15)
|
||||||
|
simboli encoding → −1/cad (max −10)
|
||||||
|
formule inline [N.M] → −1/cad (max −8)
|
||||||
|
footnote residui → −1/cad (max −8)
|
||||||
|
caratteri PUA → −2/cad (max −20)
|
||||||
|
|
||||||
|
Penalità anomalie:
|
||||||
|
bare headers → −3/cad (max −15)
|
||||||
|
"""
|
||||||
|
score = 100
|
||||||
|
detail = []
|
||||||
|
structure = r.get("structure", {})
|
||||||
|
anomalie = r.get("anomalie", {})
|
||||||
|
residui = r.get("residui", {})
|
||||||
|
|
||||||
|
livello = structure.get("livello_struttura", 0)
|
||||||
|
if livello == 0:
|
||||||
|
score -= 40
|
||||||
|
detail.append("struttura assente −40")
|
||||||
|
elif livello == 1:
|
||||||
|
score -= 15
|
||||||
|
detail.append("struttura piatta −15")
|
||||||
|
|
||||||
|
def _pen(key: str, per_item: int, cap: int, label: str) -> None:
|
||||||
|
n = residui.get(key, 0)
|
||||||
|
if n:
|
||||||
|
p = min(cap, n * per_item)
|
||||||
|
nonlocal score
|
||||||
|
score -= p
|
||||||
|
detail.append(f"{label} ×{n} −{p}")
|
||||||
|
|
||||||
|
_pen("backtick", 2, 20, "backtick")
|
||||||
|
_pen("dotleader", 5, 10, "dot-leader")
|
||||||
|
_pen("url", 5, 15, "url")
|
||||||
|
_pen("immagini", 5, 10, "immagini")
|
||||||
|
_pen("br_inline", 2, 15, "<br> inline")
|
||||||
|
_pen("simboli_encoding", 1, 10, "simboli encoding")
|
||||||
|
_pen("formule_inline", 1, 8, "formule inline")
|
||||||
|
_pen("footnote_markers", 1, 8, "footnote residui")
|
||||||
|
_pen("pua_markers", 2, 20, "caratteri PUA font Symbol")
|
||||||
|
_pen("formula_headers", 3, 15, "formula/esercizio come header")
|
||||||
|
|
||||||
|
n_bare = anomalie.get("bare_headers", 0)
|
||||||
|
if n_bare:
|
||||||
|
p = min(15, n_bare * 3)
|
||||||
|
score -= p
|
||||||
|
detail.append(f"bare headers ×{n_bare} −{p}")
|
||||||
|
|
||||||
|
return max(0, score), detail
|
||||||
|
|
||||||
|
|
||||||
|
def _grade(score: int) -> str:
|
||||||
|
return next(g for threshold, g in _GRADES if score >= threshold)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(stems: list[str], project_root: Path, detail: bool = False) -> None:
|
||||||
|
conv_dir = project_root / "conversione"
|
||||||
|
|
||||||
|
paths = (
|
||||||
|
[conv_dir / s / "report.json" for s in stems]
|
||||||
|
if stems
|
||||||
|
else sorted(conv_dir.glob("*/report.json"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not paths:
|
||||||
|
print("Nessun report.json trovato in conversione/*/")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
json.loads(p.read_text(encoding="utf-8")) if p.exists()
|
||||||
|
else {"stem": p.parent.name, "_missing": True}
|
||||||
|
for p in paths
|
||||||
|
]
|
||||||
|
|
||||||
|
col = max(len(r.get("stem", "stem")) for r in rows) + 2
|
||||||
|
header = (
|
||||||
|
f"{'stem':<{col}}"
|
||||||
|
f"{'h2':>4}{'h3':>5} "
|
||||||
|
f"{'strategia':<18}"
|
||||||
|
f"{'bare':>5}{'corte':>6}{'lunghe':>7}"
|
||||||
|
f"{'btk':>5}{'br':>4}{'enc':>4}{'url':>4}{'fhdr':>5}"
|
||||||
|
f"{'med':>6}"
|
||||||
|
f" {'voto':>4} grade"
|
||||||
|
)
|
||||||
|
sep = "─" * len(header)
|
||||||
|
print(f"\n{header}\n{sep}")
|
||||||
|
|
||||||
|
scores = []
|
||||||
|
for r in rows:
|
||||||
|
if r.get("_missing"):
|
||||||
|
print(f"{r['stem']:<{col}} (report.json non trovato)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
st = r.get("structure", {})
|
||||||
|
an = r.get("anomalie", {})
|
||||||
|
res = r.get("residui", {})
|
||||||
|
dist = r.get("distribution", {})
|
||||||
|
s, pen = _score(r)
|
||||||
|
scores.append(s)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{r['stem']:<{col}}"
|
||||||
|
f"{st.get('n_h2', 0):>4}"
|
||||||
|
f"{st.get('n_h3', 0):>5} "
|
||||||
|
f"{st.get('strategia_chunking','?'):<18}"
|
||||||
|
f"{an.get('bare_headers', 0):>5}"
|
||||||
|
f"{an.get('short_sections', 0):>6}"
|
||||||
|
f"{an.get('long_sections', 0):>7}"
|
||||||
|
f"{res.get('backtick', 0):>5}"
|
||||||
|
f"{res.get('br_inline', 0):>4}"
|
||||||
|
f"{res.get('simboli_encoding', 0):>4}"
|
||||||
|
f"{res.get('url', 0):>4}"
|
||||||
|
f"{res.get('formula_headers', 0):>5}"
|
||||||
|
f"{dist.get('mediana', 0):>6}"
|
||||||
|
f" {s:>4} {_grade(s)}"
|
||||||
|
)
|
||||||
|
if detail and pen:
|
||||||
|
for p in pen:
|
||||||
|
print(f" {'':>{col}} ↳ {p}")
|
||||||
|
|
||||||
|
print(sep)
|
||||||
|
if scores:
|
||||||
|
media = sum(scores) / len(scores)
|
||||||
|
print(
|
||||||
|
f"Documenti: {len(scores)} "
|
||||||
|
f"Media: {media:.0f}/100 {_grade(int(media))} "
|
||||||
|
f"(A≥90 B≥75 C≥60 D≥40 F<40)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"\nColonne: bare=header vuoti corte=sez<150ch lunghe=sez>1500ch "
|
||||||
|
"btk=backtick br=<br>inline enc=simboli encoding fhdr=formula-header med=mediana chars\n"
|
||||||
|
)
|
||||||
+24
-6
@@ -4,10 +4,30 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
mapfile -t dirs < <(find . -maxdepth 1 -mindepth 1 -type d | sort)
|
STEM="${1:-}"
|
||||||
|
|
||||||
|
if [[ -n "$STEM" ]]; then
|
||||||
|
# ── Modalità singolo stem ─────────────────────────────────────────────
|
||||||
|
target="./$STEM"
|
||||||
|
if [[ ! -d "$target" ]]; then
|
||||||
|
echo "Errore: cartella '$STEM' non trovata in conversione/."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf "$target"
|
||||||
|
echo "Rimossa: conversione/$STEM/"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Modalità batch: tutti gli output (escluse cartelle infrastruttura) ────
|
||||||
|
mapfile -t dirs < <(
|
||||||
|
find . -maxdepth 1 -mindepth 1 -type d \
|
||||||
|
! -name '_*' \
|
||||||
|
! -name '__*' \
|
||||||
|
| sort
|
||||||
|
)
|
||||||
|
|
||||||
if [[ ${#dirs[@]} -eq 0 ]]; then
|
if [[ ${#dirs[@]} -eq 0 ]]; then
|
||||||
echo "Nessuna cartella da cancellare."
|
echo "Nessuna cartella di output da cancellare."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -16,10 +36,8 @@ for d in "${dirs[@]}"; do
|
|||||||
echo " $d"
|
echo " $d"
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "${1:-}" != "-f" ]]; then
|
read -r -p "Confermi? [s/N] " answer
|
||||||
read -r -p "Confermi? [s/N] " answer
|
[[ "$answer" =~ ^[sS]$ ]] || { echo "Annullato."; exit 0; }
|
||||||
[[ "$answer" =~ ^[sS]$ ]] || { echo "Annullato."; exit 0; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
for d in "${dirs[@]}"; do
|
for d in "${dirs[@]}"; do
|
||||||
rm -rf "$d"
|
rm -rf "$d"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
conversione/validate.py — Validazione qualità Markdown
|
|
||||||
|
|
||||||
Legge i report.json prodotti da pipeline.py, stampa una tabella di stato
|
|
||||||
e assegna un voto (0-100) a ogni documento.
|
|
||||||
|
|
||||||
90-100 A — ottimo, pronto per il chunker
|
|
||||||
75-89 B — buono, qualche sezione lunga ma accettabile
|
|
||||||
60-74 C — accettabile, anomalie minori da verificare
|
|
||||||
40-59 D — da rivedere, problemi strutturali o residui evidenti
|
|
||||||
0-39 F — da riprocessare, struttura assente o testo corrotto
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
python conversione/validate.py # tutti gli stem
|
|
||||||
python conversione/validate.py analisi1 # stem specifico
|
|
||||||
python conversione/validate.py a b c # stem multipli
|
|
||||||
python conversione/validate.py --detail analisi1 # mostra dettaglio penalità
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Punteggio ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_GRADES = [(90, "A"), (75, "B"), (60, "C"), (40, "D"), (0, "F")]
|
|
||||||
|
|
||||||
|
|
||||||
def _score(r: dict) -> tuple[int, list[str]]:
|
|
||||||
"""
|
|
||||||
Calcola un punteggio 0-100 sulla qualità del clean.md ai fini della
|
|
||||||
suddivisione in chunk e vettorizzazione.
|
|
||||||
Restituisce (score, lista_penalità_applicate).
|
|
||||||
|
|
||||||
Penalità struttura (il chunker non può operare senza header):
|
|
||||||
struttura assente (livello 0) → −40
|
|
||||||
struttura piatta (livello 1) → −15
|
|
||||||
|
|
||||||
Penalità residui (finiscono nei vettori e degradano il retrieval):
|
|
||||||
backtick → −2/cad (max −20)
|
|
||||||
dot-leader → −5/cad (max −10)
|
|
||||||
URL / watermark → −5/cad (max −15)
|
|
||||||
immagini residue → −5/cad (max −10)
|
|
||||||
<br> inline (artefatti tabelle) → −2/cad (max −15)
|
|
||||||
simboli encoding (!/" residui) → −1/cad (max −10)
|
|
||||||
formule inline [N.M] → −1/cad (max −8)
|
|
||||||
|
|
||||||
Penalità anomalie:
|
|
||||||
bare headers → −3/cad (max −15)
|
|
||||||
|
|
||||||
Non penalizzate (il chunker le normalizza):
|
|
||||||
sezioni corte, sezioni lunghe, mediana, p25
|
|
||||||
"""
|
|
||||||
score = 100
|
|
||||||
detail = []
|
|
||||||
structure = r.get("structure", {})
|
|
||||||
anomalie = r.get("anomalie", {})
|
|
||||||
residui = r.get("residui", {})
|
|
||||||
|
|
||||||
livello = structure.get("livello_struttura", 0)
|
|
||||||
|
|
||||||
# ── Struttura ─────────────────────────────────────────────────────────
|
|
||||||
if livello == 0:
|
|
||||||
score -= 40
|
|
||||||
detail.append("struttura assente −40")
|
|
||||||
elif livello == 1:
|
|
||||||
score -= 15
|
|
||||||
detail.append("struttura piatta −15")
|
|
||||||
|
|
||||||
# ── Residui ───────────────────────────────────────────────────────────
|
|
||||||
def _pen(key: str, per_item: int, cap: int, label: str) -> None:
|
|
||||||
n = residui.get(key, 0)
|
|
||||||
if n:
|
|
||||||
p = min(cap, n * per_item)
|
|
||||||
nonlocal score
|
|
||||||
score -= p
|
|
||||||
detail.append(f"{label} ×{n} −{p}")
|
|
||||||
|
|
||||||
_pen("backtick", 2, 20, "backtick")
|
|
||||||
_pen("dotleader", 5, 10, "dot-leader")
|
|
||||||
_pen("url", 5, 15, "url")
|
|
||||||
_pen("immagini", 5, 10, "immagini")
|
|
||||||
_pen("br_inline", 2, 15, "<br> inline")
|
|
||||||
_pen("simboli_encoding", 1, 10, "simboli encoding")
|
|
||||||
_pen("formule_inline", 1, 8, "formule inline")
|
|
||||||
_pen("footnote_markers", 1, 8, "footnote residui")
|
|
||||||
_pen("pua_markers", 2, 20, "caratteri PUA font Symbol")
|
|
||||||
|
|
||||||
# ── Anomalie ──────────────────────────────────────────────────────────
|
|
||||||
n_bare = anomalie.get("bare_headers", 0)
|
|
||||||
if n_bare:
|
|
||||||
p = min(15, n_bare * 3)
|
|
||||||
score -= p
|
|
||||||
detail.append(f"bare headers ×{n_bare} −{p}")
|
|
||||||
|
|
||||||
return max(0, score), detail
|
|
||||||
|
|
||||||
|
|
||||||
def _grade(score: int) -> str:
|
|
||||||
return next(g for threshold, g in _GRADES if score >= threshold)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Validazione ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def validate(stems: list[str], project_root: Path, detail: bool = False) -> None:
|
|
||||||
conv_dir = project_root / "conversione"
|
|
||||||
|
|
||||||
paths = (
|
|
||||||
[conv_dir / s / "report.json" for s in stems]
|
|
||||||
if stems
|
|
||||||
else sorted(conv_dir.glob("*/report.json"))
|
|
||||||
)
|
|
||||||
|
|
||||||
if not paths:
|
|
||||||
print("Nessun report.json trovato in conversione/*/")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
rows = [
|
|
||||||
json.loads(p.read_text(encoding="utf-8")) if p.exists()
|
|
||||||
else {"stem": p.parent.name, "_missing": True}
|
|
||||||
for p in paths
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Intestazione ─────────────────────────────────────────────────────
|
|
||||||
col = max(len(r.get("stem", "stem")) for r in rows) + 2
|
|
||||||
header = (
|
|
||||||
f"{'stem':<{col}}"
|
|
||||||
f"{'h2':>4}{'h3':>5} "
|
|
||||||
f"{'strategia':<18}"
|
|
||||||
f"{'bare':>5}{'corte':>6}{'lunghe':>7}"
|
|
||||||
f"{'btk':>5}{'br':>4}{'enc':>4}{'url':>4}"
|
|
||||||
f"{'med':>6}"
|
|
||||||
f" {'voto':>4} grade"
|
|
||||||
)
|
|
||||||
sep = "─" * len(header)
|
|
||||||
print(f"\n{header}\n{sep}")
|
|
||||||
|
|
||||||
scores = []
|
|
||||||
|
|
||||||
# ── Righe ─────────────────────────────────────────────────────────────
|
|
||||||
for r in rows:
|
|
||||||
if r.get("_missing"):
|
|
||||||
print(f"{r['stem']:<{col}} (report.json non trovato)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
st = r.get("structure", {})
|
|
||||||
an = r.get("anomalie", {})
|
|
||||||
res = r.get("residui", {})
|
|
||||||
dist = r.get("distribution", {})
|
|
||||||
s, pen = _score(r)
|
|
||||||
scores.append(s)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"{r['stem']:<{col}}"
|
|
||||||
f"{st.get('n_h2', 0):>4}"
|
|
||||||
f"{st.get('n_h3', 0):>5} "
|
|
||||||
f"{st.get('strategia_chunking','?'):<18}"
|
|
||||||
f"{an.get('bare_headers', 0):>5}"
|
|
||||||
f"{an.get('short_sections', 0):>6}"
|
|
||||||
f"{an.get('long_sections', 0):>7}"
|
|
||||||
f"{res.get('backtick', 0):>5}"
|
|
||||||
f"{res.get('br_inline', 0):>4}"
|
|
||||||
f"{res.get('simboli_encoding', 0):>4}"
|
|
||||||
f"{res.get('url', 0):>4}"
|
|
||||||
f"{dist.get('mediana', 0):>6}"
|
|
||||||
f" {s:>4} {_grade(s)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if detail and pen:
|
|
||||||
for p in pen:
|
|
||||||
print(f" {'':>{col}} ↳ {p}")
|
|
||||||
|
|
||||||
# ── Riepilogo ─────────────────────────────────────────────────────────
|
|
||||||
print(sep)
|
|
||||||
if scores:
|
|
||||||
media = sum(scores) / len(scores)
|
|
||||||
print(
|
|
||||||
f"Documenti: {len(scores)} "
|
|
||||||
f"Media: {media:.0f}/100 {_grade(int(media))} "
|
|
||||||
f"(A≥90 B≥75 C≥60 D≥40 F<40)"
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
"\nColonne: bare=header vuoti corte=sez<150ch lunghe=sez>1500ch "
|
|
||||||
"btk=backtick br=<br>inline enc=simboli encoding med=mediana chars\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Valida i report Markdown prodotti da pipeline.py",
|
|
||||||
epilog="Senza argomenti valida tutti gli stem in conversione/*/",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"stems",
|
|
||||||
nargs="*",
|
|
||||||
metavar="STEM",
|
|
||||||
help="stem da validare (es: analisi1). Ometti per tutti.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--detail", "-d",
|
|
||||||
action="store_true",
|
|
||||||
help="mostra dettaglio penalità per ogni documento",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
validate(args.stems, Path(__file__).parent.parent, detail=args.detail)
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# 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>
|
|
||||||
```
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Verifica ambiente Ollama
|
|
||||||
|
|
||||||
Controlla che tutti i prerequisiti per la vettorizzazione siano soddisfatti:
|
|
||||||
1. ollama è nel PATH e risponde
|
|
||||||
2. Almeno un modello di embedding è scaricato
|
|
||||||
3. Almeno un modello LLM è scaricato
|
|
||||||
4. chromadb è importabile
|
|
||||||
|
|
||||||
Output: report a schermo con ✅ / ❌ per ogni componente.
|
|
||||||
Nessun file scritto. Exit 0 se tutto OK, 1 altrimenti.
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
python ollama/check_env.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Lista canonica di modelli embedding supportati ───────────────────────────
|
|
||||||
# Ordine: prima scelta → ultima scelta (come da ollama/README.md)
|
|
||||||
EMBED_MODELS = [
|
|
||||||
"qwen3-embedding",
|
|
||||||
"nomic-embed-text-v2-moe",
|
|
||||||
"bge-m3",
|
|
||||||
"nomic-embed-text",
|
|
||||||
"mxbai-embed-large",
|
|
||||||
"paraphrase-multilingual",
|
|
||||||
"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:
|
|
||||||
"""True se il modello è riconosciuto come embedding (lista canonica o keyword)."""
|
|
||||||
base = model_name.split(":")[0].lower()
|
|
||||||
return base.startswith(EMBED_MODEL_PREFIXES) or "embed" in base
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ollama_models(raw_output: str) -> list[str]:
|
|
||||||
"""Estrae i nomi modello dall'output di `ollama list`."""
|
|
||||||
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:
|
|
||||||
from config import EMBED_MODEL as CONFIGURED_EMBED, OLLAMA_MODEL as CONFIGURED_LLM
|
|
||||||
except Exception:
|
|
||||||
CONFIGURED_EMBED = None
|
|
||||||
CONFIGURED_LLM = None
|
|
||||||
|
|
||||||
REQUIRED_LIBS = ["chromadb"]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── 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:
|
|
||||||
"""Verifica che ollama sia nel PATH."""
|
|
||||||
found = shutil.which("ollama") is not None
|
|
||||||
if found:
|
|
||||||
print("✅ ollama trovato nel PATH")
|
|
||||||
else:
|
|
||||||
print("❌ ollama non trovato nel PATH")
|
|
||||||
print(" → Installa con: curl -fsSL https://ollama.com/install.sh | sh")
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def check_ollama_running() -> list[str] | None:
|
|
||||||
"""
|
|
||||||
Esegue 'ollama list' e ritorna la lista dei modelli disponibili.
|
|
||||||
Ritorna None se ollama non risponde.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["ollama", "list"],
|
|
||||||
capture_output=True, text=True, timeout=10
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print("❌ ollama non risponde (errore all'avvio)")
|
|
||||||
print(OLLAMA_SERVE_HINT)
|
|
||||||
return None
|
|
||||||
models = _parse_ollama_models(result.stdout)
|
|
||||||
print("✅ ollama risponde correttamente")
|
|
||||||
return models
|
|
||||||
except FileNotFoundError:
|
|
||||||
print("❌ ollama non trovato (FileNotFoundError)")
|
|
||||||
return None
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print("❌ ollama non risponde (timeout)")
|
|
||||||
print(OLLAMA_SERVE_HINT)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _match(model_name: str, available: list[str]) -> str | None:
|
|
||||||
"""
|
|
||||||
Ritorna il nome completo del modello trovato in 'available' che corrisponde
|
|
||||||
a 'model_name' (confronto per prefisso), oppure None.
|
|
||||||
"""
|
|
||||||
for m in available:
|
|
||||||
if m == model_name or m.startswith(model_name + ":") or m.startswith(model_name + "-"):
|
|
||||||
return m
|
|
||||||
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:
|
|
||||||
"""Verifica che il modello di embedding configurato sia disponibile."""
|
|
||||||
configured_check = _check_configured_model(CONFIGURED_EMBED, available, "embedding")
|
|
||||||
if configured_check is not None:
|
|
||||||
return configured_check
|
|
||||||
|
|
||||||
# fallback: config.py non leggibile
|
|
||||||
found = next((m for m in available if _is_embed(m)), None)
|
|
||||||
if found:
|
|
||||||
print(f"✅ modello embedding trovato: {found}")
|
|
||||||
return True
|
|
||||||
print("❌ nessun modello di embedding trovato")
|
|
||||||
print(f" → Prima scelta: ollama pull {RECOMMENDED_EMBED_MODEL}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_llm_model(available: list[str]) -> bool:
|
|
||||||
"""Verifica che il modello LLM configurato sia disponibile."""
|
|
||||||
configured_check = _check_configured_model(CONFIGURED_LLM, available, "LLM")
|
|
||||||
if configured_check is not None:
|
|
||||||
return configured_check
|
|
||||||
|
|
||||||
# fallback: config.py non leggibile
|
|
||||||
first_llm = next((m for m in available if not _is_embed(m)), None)
|
|
||||||
if first_llm:
|
|
||||||
print(f"✅ modello LLM trovato: {first_llm}")
|
|
||||||
return True
|
|
||||||
print("❌ nessun modello LLM trovato")
|
|
||||||
print(f" → Consigliato per 8 GB RAM: ollama pull {RECOMMENDED_LLM_MODEL}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_library(lib: str) -> bool:
|
|
||||||
"""Verifica che una libreria Python sia importabile."""
|
|
||||||
try:
|
|
||||||
__import__(lib)
|
|
||||||
print(f"✅ {lib} importabile")
|
|
||||||
return True
|
|
||||||
except ImportError:
|
|
||||||
print(f"❌ {lib} non importabile")
|
|
||||||
print(f" → Installa con: pip install {lib}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
print("─── Verifica ambiente Ollama ─────────────────────────────────────────\n")
|
|
||||||
|
|
||||||
results: list[bool] = []
|
|
||||||
|
|
||||||
# 1. ollama nel PATH
|
|
||||||
in_path = check_ollama_in_path()
|
|
||||||
results.append(in_path)
|
|
||||||
|
|
||||||
# 2. ollama risponde + modelli
|
|
||||||
if in_path:
|
|
||||||
available = check_ollama_running()
|
|
||||||
if available is None:
|
|
||||||
results.extend([False, False, False])
|
|
||||||
else:
|
|
||||||
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_llm_model(available))
|
|
||||||
else:
|
|
||||||
results.extend([False, False, False])
|
|
||||||
print("⚠️ modelli non verificabili (ollama non trovato)")
|
|
||||||
|
|
||||||
# 3. Librerie Python
|
|
||||||
print()
|
|
||||||
for lib in REQUIRED_LIBS:
|
|
||||||
results.append(check_library(lib))
|
|
||||||
|
|
||||||
# ── Riepilogo ─────────────────────────────────────────────────────────────
|
|
||||||
print()
|
|
||||||
print("──────────────────────────────────────────────────────────────────────")
|
|
||||||
all_ok = all(results)
|
|
||||||
if all_ok:
|
|
||||||
print("✅ Ambiente pronto")
|
|
||||||
else:
|
|
||||||
n_fail = sum(1 for r in results if not r)
|
|
||||||
print(f"⚠️ {n_fail} problema/i rilevato/i — risolvi prima di procedere.")
|
|
||||||
|
|
||||||
return 0 if all_ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test chat locale Ollama — senza RAG, senza ChromaDB.
|
|
||||||
Uso: python ollama/test_ollama.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
import config as _cfg
|
|
||||||
|
|
||||||
OLLAMA_URL = _cfg.OLLAMA_URL
|
|
||||||
MODEL = _cfg.OLLAMA_MODEL
|
|
||||||
TEMPERATURE = _cfg.TEMPERATURE
|
|
||||||
NO_THINK = _cfg.NO_THINK
|
|
||||||
|
|
||||||
|
|
||||||
def chat(prompt: str) -> str:
|
|
||||||
payload = json.dumps({
|
|
||||||
"model": MODEL,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
"think": not NO_THINK,
|
|
||||||
"options": {"temperature": TEMPERATURE},
|
|
||||||
}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{OLLAMA_URL}/api/generate",
|
|
||||||
data=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
||||||
return json.loads(resp.read())["response"].strip()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
print(f"─── Chat Ollama ──────────────────────────────── (exit per uscire)")
|
|
||||||
print(f" Modello : {MODEL}")
|
|
||||||
print(f" Thinking : {'off' if NO_THINK else 'on'}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
user = input("Tu: ").strip()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
print("\nUscita.")
|
|
||||||
break
|
|
||||||
if not user:
|
|
||||||
continue
|
|
||||||
if user.lower() == "exit":
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
reply = chat(user)
|
|
||||||
print(f"\nAssistente: {reply}\n")
|
|
||||||
except (urllib.error.URLError, OSError) as e:
|
|
||||||
print(f"❌ Errore: {e}")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Pipeline RAG interattiva
|
|
||||||
|
|
||||||
Riceve una domanda, recupera i chunk più rilevanti da ChromaDB (retrieval)
|
|
||||||
e genera una risposta tramite Ollama (generation).
|
|
||||||
|
|
||||||
Input: chroma_db/<stem> (collection ChromaDB)
|
|
||||||
Output: risposta a schermo
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
python rag.py --stem <nome>
|
|
||||||
|
|
||||||
Nel loop interattivo:
|
|
||||||
Domanda: <testo> → risposta
|
|
||||||
Domanda: <testo> -v → risposta + chunk recuperati
|
|
||||||
Domanda: exit → uscita
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import chromadb
|
|
||||||
|
|
||||||
# ─── Configurazione ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
import config as _cfg
|
|
||||||
|
|
||||||
project_root = Path(__file__).parent
|
|
||||||
CHROMA_DIR = project_root / "chroma_db"
|
|
||||||
|
|
||||||
OLLAMA_URL = _cfg.OLLAMA_URL
|
|
||||||
EMBED_MODEL = _cfg.EMBED_MODEL
|
|
||||||
LLM_MODEL = _cfg.OLLAMA_MODEL
|
|
||||||
TOP_K = _cfg.TOP_K
|
|
||||||
TEMPERATURE = _cfg.TEMPERATURE
|
|
||||||
NO_THINK = _cfg.NO_THINK
|
|
||||||
SYSTEM_PROMPT = _cfg.SYSTEM_PROMPT
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Embedding ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def embed(text: str) -> list[float]:
|
|
||||||
"""Genera il vettore della domanda tramite Ollama."""
|
|
||||||
payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{OLLAMA_URL}/api/embeddings",
|
|
||||||
data=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
||||||
return json.loads(resp.read())["embedding"]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Generazione ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def call_ollama(prompt: str, system: str = "") -> str:
|
|
||||||
"""Chiama Ollama /api/generate e ritorna la risposta."""
|
|
||||||
payload = json.dumps({
|
|
||||||
"model": LLM_MODEL,
|
|
||||||
"system": system,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
"think": not NO_THINK,
|
|
||||||
"options": {"temperature": TEMPERATURE},
|
|
||||||
}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{OLLAMA_URL}/api/generate",
|
|
||||||
data=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
||||||
return json.loads(resp.read())["response"].strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Retrieval ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def retrieve(collection: chromadb.Collection, question: str) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Genera l'embedding della domanda e recupera i TOP_K chunk più simili.
|
|
||||||
Ritorna lista di dict con chiavi: text, sezione, titolo, distance.
|
|
||||||
"""
|
|
||||||
vector = embed(question)
|
|
||||||
results = collection.query(
|
|
||||||
query_embeddings=[vector],
|
|
||||||
n_results=TOP_K,
|
|
||||||
include=["documents", "metadatas", "distances"],
|
|
||||||
)
|
|
||||||
chunks = []
|
|
||||||
for text, meta, dist in zip(
|
|
||||||
results["documents"][0],
|
|
||||||
results["metadatas"][0],
|
|
||||||
results["distances"][0],
|
|
||||||
):
|
|
||||||
chunks.append({
|
|
||||||
"text": text,
|
|
||||||
"sezione": meta.get("sezione", ""),
|
|
||||||
"titolo": meta.get("titolo", ""),
|
|
||||||
"distance": dist,
|
|
||||||
})
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Prompt ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_prompt(question: str, chunks: list[dict]) -> str:
|
|
||||||
"""Ritorna (system, user_prompt) separati per l'API Ollama."""
|
|
||||||
context_parts = []
|
|
||||||
for i, c in enumerate(chunks, start=1):
|
|
||||||
header = f"[Contesto {i}"
|
|
||||||
if c["sezione"]:
|
|
||||||
header += f" — {c['sezione']}"
|
|
||||||
if c["titolo"]:
|
|
||||||
header += f" > {c['titolo']}"
|
|
||||||
header += "]"
|
|
||||||
context_parts.append(f"{header}\n{c['text']}")
|
|
||||||
|
|
||||||
context = "\n\n".join(context_parts)
|
|
||||||
user_prompt = f"{context}\n\nDomanda: {question}"
|
|
||||||
return SYSTEM_PROMPT, user_prompt
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Loop interattivo ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def answer(question: str, collection: chromadb.Collection, verbose: bool) -> None:
|
|
||||||
try:
|
|
||||||
chunks = retrieve(collection, question)
|
|
||||||
except (urllib.error.URLError, OSError) as e:
|
|
||||||
print(f"❌ Errore embedding: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print("\n── Chunk recuperati ──────────────────────────────────────────")
|
|
||||||
for i, c in enumerate(chunks, start=1):
|
|
||||||
loc = c["sezione"]
|
|
||||||
if c["titolo"]:
|
|
||||||
loc += f" > {c['titolo']}"
|
|
||||||
sim = 1 - c["distance"]
|
|
||||||
print(f" [{i}] {loc} (similarità: {sim:.3f})")
|
|
||||||
print(f" {c['text'][:120].replace(chr(10), ' ')}...")
|
|
||||||
print("──────────────────────────────────────────────────────────────\n")
|
|
||||||
|
|
||||||
system, prompt = build_prompt(question, chunks)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = call_ollama(prompt, system=system)
|
|
||||||
except (urllib.error.URLError, OSError) as e:
|
|
||||||
print(f"❌ Errore generazione: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"\n{response}\n")
|
|
||||||
|
|
||||||
|
|
||||||
def run_loop(collection: chromadb.Collection) -> None:
|
|
||||||
print("── Loop RAG ─────────────────────────────────────── (exit per uscire)\n")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
raw = input("Domanda: ").strip()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
print("\nUscita.")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
if raw.lower() == "exit":
|
|
||||||
break
|
|
||||||
|
|
||||||
verbose = raw.endswith(" -v")
|
|
||||||
question = raw[:-3].strip() if verbose else raw
|
|
||||||
|
|
||||||
answer(question, collection, verbose)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_epilog() -> str:
|
|
||||||
lines = [
|
|
||||||
"Uso:",
|
|
||||||
" python rag.py --stem <nome>",
|
|
||||||
"",
|
|
||||||
"Loop interattivo:",
|
|
||||||
" <domanda> risposta basata sul documento",
|
|
||||||
" <domanda> -v risposta + chunk recuperati con score di similarità",
|
|
||||||
" exit termina",
|
|
||||||
]
|
|
||||||
if CHROMA_DIR.exists():
|
|
||||||
try:
|
|
||||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
|
||||||
names = [c.name for c in client.list_collections()]
|
|
||||||
if names:
|
|
||||||
lines += ["", f"Collection disponibili: {', '.join(names)}"]
|
|
||||||
else:
|
|
||||||
lines += ["", "Nessuna collection trovata — eseguire prima: python step-8/ingest.py"]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"Pipeline RAG interattiva\n\n"
|
|
||||||
"Risponde a domande in linguaggio naturale su un documento\n"
|
|
||||||
"indicizzato in ChromaDB da step-8/ingest.py."
|
|
||||||
),
|
|
||||||
epilog=_build_epilog(),
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--stem",
|
|
||||||
required=True,
|
|
||||||
help=(
|
|
||||||
"Nome della collection ChromaDB da interrogare. "
|
|
||||||
"Le collection vengono create da: python step-8/ingest.py --stem <nome>"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
print("─── Pipeline RAG ────────────────────────────────────────────\n")
|
|
||||||
print(f" Documento : {args.stem}")
|
|
||||||
print(f" Modello : {LLM_MODEL}")
|
|
||||||
print(f" Top-K : {TOP_K}")
|
|
||||||
print(f" Thinking : {'off' if NO_THINK else 'on'}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if not CHROMA_DIR.exists():
|
|
||||||
print("❌ chroma_db/ non trovata — esegui prima step-8")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
|
||||||
collections = [c.name for c in client.list_collections()]
|
|
||||||
if args.stem not in collections:
|
|
||||||
print(f"❌ Collection '{args.stem}' non trovata in chroma_db/")
|
|
||||||
print(f" → python step-8/ingest.py --stem {args.stem}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
collection = client.get_collection(args.stem)
|
|
||||||
print(f"✅ Collection '{args.stem}' caricata ({collection.count()} chunk)\n")
|
|
||||||
|
|
||||||
run_loop(collection)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pdfplumber==0.11.9
|
pdfplumber==0.11.9
|
||||||
pymupdf4llm
|
pymupdf4llm
|
||||||
opendataloader-pdf
|
opendataloader-pdf
|
||||||
chromadb
|
|
||||||
|
|||||||
-217
@@ -1,217 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Retrieval puro (senza generazione LLM)
|
|
||||||
|
|
||||||
Loop interattivo: inserisci una query, ottieni i chunk più simili dalla
|
|
||||||
collection ChromaDB tramite embedding semantico — senza chiamare Ollama
|
|
||||||
per la generation.
|
|
||||||
|
|
||||||
Utile per:
|
|
||||||
- verificare la qualità del retrieval prima di diagnosticare risposte sbagliate
|
|
||||||
- controllare che i chunk giusti vengano recuperati per una query
|
|
||||||
- usare la pipeline come motore di ricerca semantica
|
|
||||||
|
|
||||||
Input: chroma_db/<stem> (collection ChromaDB)
|
|
||||||
Output: lista chunk con score di similarità
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
python retrieve.py --stem <nome>
|
|
||||||
|
|
||||||
Nel loop interattivo:
|
|
||||||
Query: <testo> → chunk più simili con score
|
|
||||||
Query: <testo> -f → testo completo dei chunk
|
|
||||||
Query: exit → uscita
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import chromadb
|
|
||||||
|
|
||||||
# ─── Configurazione ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
import config as _cfg
|
|
||||||
|
|
||||||
project_root = Path(__file__).parent
|
|
||||||
CHROMA_DIR = project_root / "chroma_db"
|
|
||||||
|
|
||||||
OLLAMA_URL = _cfg.OLLAMA_URL
|
|
||||||
EMBED_MODEL = _cfg.EMBED_MODEL
|
|
||||||
TOP_K = _cfg.TOP_K
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Embedding ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def embed(text: str) -> list[float]:
|
|
||||||
"""Genera il vettore della query tramite Ollama."""
|
|
||||||
payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{OLLAMA_URL}/api/embeddings",
|
|
||||||
data=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
||||||
return json.loads(resp.read())["embedding"]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Retrieval ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def retrieve(collection: chromadb.Collection, query: str, top_k: int) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Genera l'embedding della query e recupera i top_k chunk più simili.
|
|
||||||
Ritorna lista di dict con chiavi: rank, similarity, sezione, titolo, text.
|
|
||||||
"""
|
|
||||||
vector = embed(query)
|
|
||||||
results = collection.query(
|
|
||||||
query_embeddings=[vector],
|
|
||||||
n_results=top_k,
|
|
||||||
include=["documents", "metadatas", "distances"],
|
|
||||||
)
|
|
||||||
chunks = []
|
|
||||||
for rank, (text, meta, dist) in enumerate(
|
|
||||||
zip(
|
|
||||||
results["documents"][0],
|
|
||||||
results["metadatas"][0],
|
|
||||||
results["distances"][0],
|
|
||||||
),
|
|
||||||
start=1,
|
|
||||||
):
|
|
||||||
chunks.append({
|
|
||||||
"rank": rank,
|
|
||||||
"similarity": round(1 - dist, 4),
|
|
||||||
"sezione": meta.get("sezione", ""),
|
|
||||||
"titolo": meta.get("titolo", ""),
|
|
||||||
"text": text,
|
|
||||||
})
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Output ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def print_results(chunks: list[dict], full: bool = False) -> None:
|
|
||||||
print(f"── {len(chunks)} chunk recuperati ─────────────────────────────────\n")
|
|
||||||
for c in chunks:
|
|
||||||
loc = c["sezione"]
|
|
||||||
if c["titolo"]:
|
|
||||||
loc += f" > {c['titolo']}"
|
|
||||||
print(f" [{c['rank']}] similarità: {c['similarity']:.4f} | {loc}")
|
|
||||||
if full:
|
|
||||||
print()
|
|
||||||
print(c["text"])
|
|
||||||
else:
|
|
||||||
print(f" {c['text'][:200].replace(chr(10), ' ')}")
|
|
||||||
if len(c["text"]) > 200:
|
|
||||||
print(f" … ({len(c['text'])} caratteri totali)")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Loop interattivo ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def run_loop(collection: chromadb.Collection, top_k: int) -> None:
|
|
||||||
print("── Loop retrieval ──────────────────────── (exit per uscire, -f per testo completo)\n")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
raw = input("Query: ").strip()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
print("\nUscita.")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
if raw.lower() == "exit":
|
|
||||||
break
|
|
||||||
|
|
||||||
full = raw.endswith(" -f")
|
|
||||||
query = raw[:-3].strip() if full else raw
|
|
||||||
|
|
||||||
try:
|
|
||||||
chunks = retrieve(collection, query, top_k)
|
|
||||||
except (urllib.error.URLError, OSError) as e:
|
|
||||||
print(f"❌ Errore embedding (Ollama raggiungibile?): {e}\n")
|
|
||||||
continue
|
|
||||||
|
|
||||||
print()
|
|
||||||
print_results(chunks, full=full)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_epilog() -> str:
|
|
||||||
lines = [
|
|
||||||
"Uso:",
|
|
||||||
" python retrieve.py --stem <nome>",
|
|
||||||
"",
|
|
||||||
"Nel loop interattivo:",
|
|
||||||
" <query> chunk più simili con score (testo troncato)",
|
|
||||||
" <query> -f testo completo dei chunk",
|
|
||||||
" exit termina",
|
|
||||||
]
|
|
||||||
if CHROMA_DIR.exists():
|
|
||||||
try:
|
|
||||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
|
||||||
names = [c.name for c in client.list_collections()]
|
|
||||||
if names:
|
|
||||||
lines += ["", f"Collection disponibili: {', '.join(names)}"]
|
|
||||||
else:
|
|
||||||
lines += ["", "Nessuna collection trovata — eseguire prima: python step-8/ingest.py"]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"Retrieval puro (senza LLM)\n\n"
|
|
||||||
"Loop interattivo: inserisci una query e ottieni i chunk più simili\n"
|
|
||||||
"tramite embedding semantico, senza generazione LLM."
|
|
||||||
),
|
|
||||||
epilog=_build_epilog(),
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--stem",
|
|
||||||
required=True,
|
|
||||||
help="Nome della collection ChromaDB da interrogare.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--top-k",
|
|
||||||
type=int,
|
|
||||||
default=TOP_K,
|
|
||||||
metavar="N",
|
|
||||||
help=f"Numero di chunk da restituire per query (default: {TOP_K} da config.py).",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
print("─── Retrieval puro ──────────────────────────────────────────\n")
|
|
||||||
print(f" Documento : {args.stem}")
|
|
||||||
print(f" Embed model : {EMBED_MODEL}")
|
|
||||||
print(f" Top-K : {args.top_k}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if not CHROMA_DIR.exists():
|
|
||||||
print("❌ chroma_db/ non trovata — esegui prima step-8", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
|
||||||
collections = [c.name for c in client.list_collections()]
|
|
||||||
if args.stem not in collections:
|
|
||||||
print(f"❌ Collection '{args.stem}' non trovata in chroma_db/", file=sys.stderr)
|
|
||||||
print(f" → python step-8/ingest.py --stem {args.stem}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
collection = client.get_collection(args.stem)
|
|
||||||
print(f"✅ Collection '{args.stem}' caricata ({collection.count()} chunk)\n")
|
|
||||||
|
|
||||||
run_loop(collection, args.top_k)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Chunking adattivo
|
|
||||||
|
|
||||||
Divide il Markdown revisionato in chunk semantici pronti per la
|
|
||||||
vettorizzazione. La strategia dipende dal profilo strutturale del documento.
|
|
||||||
|
|
||||||
Input: conversione/<stem>/clean.md + conversione/<stem>/structure_profile.json
|
|
||||||
Output: step-5/<stem>/chunks.json
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
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 --force
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Parametri ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
MIN_CHARS = 200 # sotto questa soglia → accorpa al chunk successivo
|
|
||||||
MAX_CHARS = 800 # sopra questa soglia → spezza su frasi
|
|
||||||
OVERLAP_S = 2 # frasi di overlap tra sotto-chunk dello stesso boundary
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Utilità ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def split_sentences(text: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Divide il testo in frasi senza spezzare abbreviazioni comuni.
|
|
||||||
Split su punteggiatura finale (.!?») seguita da spazio + lettera maiuscola.
|
|
||||||
"""
|
|
||||||
# Split conservativo: solo quando la punteggiatura è seguita da spazio
|
|
||||||
# e la parola successiva inizia in maiuscolo (o è fine stringa).
|
|
||||||
parts = re.split(r'(?<=[.!?»])\s+(?=[A-ZÀÈÉÌÒÙA-Z\"])', text.strip())
|
|
||||||
# Se non trova nulla con maiuscola, usa split semplice
|
|
||||||
if len(parts) <= 1:
|
|
||||||
parts = re.split(r'(?<=[.!?»])\s+', text.strip())
|
|
||||||
return [p.strip() for p in parts if p.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(s: str, max_len: int = 60) -> str:
|
|
||||||
"""Converti una stringa in slug per chunk_id."""
|
|
||||||
s = s.lower()
|
|
||||||
s = re.sub(r'[^\w\s-]', '', s)
|
|
||||||
s = re.sub(r'[\s_-]+', '_', s).strip('_')
|
|
||||||
return s[:max_len] if s else "section"
|
|
||||||
|
|
||||||
|
|
||||||
def make_sub_chunks(
|
|
||||||
body: str,
|
|
||||||
prefix: str,
|
|
||||||
sezione: str,
|
|
||||||
titolo: str,
|
|
||||||
max_chars: int,
|
|
||||||
overlap_s: int,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Suddivide un body in sotto-chunk rispettando max_chars.
|
|
||||||
Aggiunge overlap_s frasi di overlap tra sotto-chunk consecutivi.
|
|
||||||
Non attraversa mai i confini del body.
|
|
||||||
"""
|
|
||||||
sentences = split_sentences(body)
|
|
||||||
if not sentences:
|
|
||||||
return []
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
current: list[str] = []
|
|
||||||
current_len = 0
|
|
||||||
sub_index = 0
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
while i < len(sentences):
|
|
||||||
sent = sentences[i]
|
|
||||||
# +1 per lo spazio di separazione
|
|
||||||
if not current or current_len + len(sent) + 1 <= max_chars:
|
|
||||||
current.append(sent)
|
|
||||||
current_len += len(sent) + (1 if len(current) > 1 else 0)
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
# Flush del chunk corrente
|
|
||||||
chunk_text = prefix + " ".join(current)
|
|
||||||
chunks.append({
|
|
||||||
"chunk_id": f"{slugify(sezione)}__{slugify(titolo)}__s{sub_index}",
|
|
||||||
"text": chunk_text,
|
|
||||||
"sezione": sezione,
|
|
||||||
"titolo": titolo,
|
|
||||||
"sub_index": sub_index,
|
|
||||||
"n_chars": len(chunk_text),
|
|
||||||
})
|
|
||||||
sub_index += 1
|
|
||||||
# Overlap: riparti dalle ultime overlap_s frasi
|
|
||||||
overlap = current[-overlap_s:] if overlap_s and len(current) > overlap_s else []
|
|
||||||
current = overlap[:]
|
|
||||||
current_len = sum(len(s) + 1 for s in current)
|
|
||||||
|
|
||||||
# Flush delle frasi rimanenti
|
|
||||||
if current:
|
|
||||||
chunk_text = prefix + " ".join(current)
|
|
||||||
chunks.append({
|
|
||||||
"chunk_id": f"{slugify(sezione)}__{slugify(titolo)}__s{sub_index}",
|
|
||||||
"text": chunk_text,
|
|
||||||
"sezione": sezione,
|
|
||||||
"titolo": titolo,
|
|
||||||
"sub_index": sub_index,
|
|
||||||
"n_chars": len(chunk_text),
|
|
||||||
})
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Parser Markdown ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def parse_h3_sections(text: str) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Parsa il documento in sezioni (sezione h2, titolo h3, body).
|
|
||||||
Testo prima del primo header viene assegnato a sezione vuota.
|
|
||||||
"""
|
|
||||||
sections = []
|
|
||||||
current_h2 = ""
|
|
||||||
current_h3 = ""
|
|
||||||
current_body_lines: list[str] = []
|
|
||||||
|
|
||||||
def flush():
|
|
||||||
body = "\n".join(current_body_lines).strip()
|
|
||||||
if body:
|
|
||||||
sections.append({
|
|
||||||
"sezione": current_h2,
|
|
||||||
"titolo": current_h3,
|
|
||||||
"body": body,
|
|
||||||
})
|
|
||||||
|
|
||||||
for line in text.splitlines():
|
|
||||||
if re.match(r"^# ", line):
|
|
||||||
# h1 = titolo documento, non crea sezione
|
|
||||||
flush()
|
|
||||||
current_h2 = line[2:].strip()
|
|
||||||
current_h3 = ""
|
|
||||||
current_body_lines = []
|
|
||||||
elif re.match(r"^## ", line):
|
|
||||||
flush()
|
|
||||||
current_h2 = line[3:].strip()
|
|
||||||
current_h3 = ""
|
|
||||||
current_body_lines = []
|
|
||||||
elif re.match(r"^### ", line):
|
|
||||||
flush()
|
|
||||||
current_h3 = line[4:].strip()
|
|
||||||
current_body_lines = []
|
|
||||||
else:
|
|
||||||
current_body_lines.append(line)
|
|
||||||
|
|
||||||
flush()
|
|
||||||
return sections
|
|
||||||
|
|
||||||
|
|
||||||
def parse_h2_sections(text: str) -> list[dict]:
|
|
||||||
"""Parsa il documento in sezioni h2 con il loro testo completo."""
|
|
||||||
sections = []
|
|
||||||
current_h2 = ""
|
|
||||||
current_body_lines: list[str] = []
|
|
||||||
|
|
||||||
def flush():
|
|
||||||
body = "\n".join(current_body_lines).strip()
|
|
||||||
if body:
|
|
||||||
sections.append({"sezione": current_h2, "body": body})
|
|
||||||
|
|
||||||
for line in text.splitlines():
|
|
||||||
if re.match(r"^## ", line):
|
|
||||||
flush()
|
|
||||||
current_h2 = line[3:].strip()
|
|
||||||
current_body_lines = []
|
|
||||||
elif re.match(r"^# ", line):
|
|
||||||
flush()
|
|
||||||
current_h2 = line[2:].strip()
|
|
||||||
current_body_lines = []
|
|
||||||
else:
|
|
||||||
current_body_lines.append(line)
|
|
||||||
|
|
||||||
flush()
|
|
||||||
return sections
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Strategie di chunking ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def chunk_h3_aware(text: str, stem: str) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Strategia h3_aware: boundary su ###.
|
|
||||||
Sezioni piccole (< MIN_CHARS) vengono accorpate alla successiva
|
|
||||||
purché appartengano allo stesso ## padre.
|
|
||||||
Sezioni grandi (> MAX_CHARS) vengono suddivise su frasi.
|
|
||||||
"""
|
|
||||||
sections = parse_h3_sections(text)
|
|
||||||
|
|
||||||
# Merge greedy: accorpa al successivo se stesso h2 e body piccolo
|
|
||||||
merged: list[dict] = []
|
|
||||||
pending: dict | None = None
|
|
||||||
|
|
||||||
for sec in sections:
|
|
||||||
if pending is None:
|
|
||||||
pending = dict(sec)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if (pending["sezione"] == sec["sezione"]
|
|
||||||
and len(pending["body"]) < MIN_CHARS):
|
|
||||||
sep_title = " / ".join(filter(None, [pending["titolo"], sec["titolo"]]))
|
|
||||||
pending = {
|
|
||||||
"sezione": pending["sezione"],
|
|
||||||
"titolo": sep_title or pending["titolo"],
|
|
||||||
"body": pending["body"] + "\n\n" + sec["body"],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
merged.append(pending)
|
|
||||||
pending = dict(sec)
|
|
||||||
|
|
||||||
if pending:
|
|
||||||
merged.append(pending)
|
|
||||||
|
|
||||||
# Genera chunk con eventuale split su frasi
|
|
||||||
chunks = []
|
|
||||||
for sec in merged:
|
|
||||||
sezione = sec["sezione"] or stem
|
|
||||||
titolo = sec["titolo"] or ""
|
|
||||||
body = sec["body"]
|
|
||||||
|
|
||||||
prefix = f"[{sezione} > {titolo}]\n" if titolo else f"[{sezione}]\n"
|
|
||||||
sub = make_sub_chunks(body, prefix, sezione, titolo, MAX_CHARS, OVERLAP_S)
|
|
||||||
chunks.extend(sub)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_h2_paragraph_split(text: str, stem: str) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Strategia h2_paragraph_split: boundary su ##.
|
|
||||||
All'interno di ogni ## i paragrafi vengono usati come sotto-unità.
|
|
||||||
"""
|
|
||||||
sections = parse_h2_sections(text)
|
|
||||||
chunks = []
|
|
||||||
|
|
||||||
for sec in sections:
|
|
||||||
sezione = sec["sezione"] or stem
|
|
||||||
body = sec["body"]
|
|
||||||
prefix = f"[{sezione}]\n"
|
|
||||||
|
|
||||||
# Suddividi in paragrafi interni (righe vuote doppie)
|
|
||||||
paragraphs = [
|
|
||||||
p.strip()
|
|
||||||
for p in re.split(r"\n{2,}", body)
|
|
||||||
if p.strip() and not re.match(r"^#+\s", p.strip())
|
|
||||||
]
|
|
||||||
|
|
||||||
# Merge paragrafi piccoli
|
|
||||||
merged_pars: list[str] = []
|
|
||||||
pending = ""
|
|
||||||
for par in paragraphs:
|
|
||||||
if pending and len(pending) < MIN_CHARS:
|
|
||||||
pending = pending + "\n\n" + par
|
|
||||||
else:
|
|
||||||
if pending:
|
|
||||||
merged_pars.append(pending)
|
|
||||||
pending = par
|
|
||||||
if pending:
|
|
||||||
merged_pars.append(pending)
|
|
||||||
|
|
||||||
for idx, par in enumerate(merged_pars):
|
|
||||||
sub = make_sub_chunks(par, prefix, sezione, f"par{idx}", MAX_CHARS, OVERLAP_S)
|
|
||||||
for c in sub:
|
|
||||||
c["chunk_id"] = f"{slugify(sezione)}__p{idx}__s{c['sub_index']}"
|
|
||||||
chunks.extend(sub)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_paragraph(text: str, stem: str) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Strategia paragraph: boundary su paragrafo (doppia riga vuota).
|
|
||||||
"""
|
|
||||||
paragraphs = [
|
|
||||||
p.strip()
|
|
||||||
for p in re.split(r"\n{2,}", text)
|
|
||||||
if p.strip() and not re.match(r"^#+\s", p.strip())
|
|
||||||
]
|
|
||||||
prefix = f"[Documento: {stem}]\n"
|
|
||||||
|
|
||||||
# Merge paragrafi piccoli
|
|
||||||
merged: list[str] = []
|
|
||||||
pending = ""
|
|
||||||
for par in paragraphs:
|
|
||||||
if pending and len(pending) < MIN_CHARS:
|
|
||||||
pending = pending + "\n\n" + par
|
|
||||||
else:
|
|
||||||
if pending:
|
|
||||||
merged.append(pending)
|
|
||||||
pending = par
|
|
||||||
if pending:
|
|
||||||
merged.append(pending)
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
for idx, par in enumerate(merged):
|
|
||||||
sub = make_sub_chunks(par, prefix, stem, f"par{idx}", MAX_CHARS, OVERLAP_S)
|
|
||||||
for c in sub:
|
|
||||||
c["chunk_id"] = f"para__{idx}__s{c['sub_index']}"
|
|
||||||
chunks.extend(sub)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_sliding_window(text: str, stem: str) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Strategia sliding_window: finestre di MAX_CHARS con OVERLAP_S frasi di overlap.
|
|
||||||
Usata per testi piatti senza struttura (livello 0).
|
|
||||||
"""
|
|
||||||
sentences = split_sentences(text)
|
|
||||||
prefix = f"[Documento: {stem}]\n"
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
i = 0
|
|
||||||
win_idx = 0
|
|
||||||
|
|
||||||
while i < len(sentences):
|
|
||||||
window: list[str] = []
|
|
||||||
cur_len = 0
|
|
||||||
|
|
||||||
j = i
|
|
||||||
while j < len(sentences):
|
|
||||||
s = sentences[j]
|
|
||||||
if window and cur_len + len(s) + 1 > MAX_CHARS:
|
|
||||||
break
|
|
||||||
window.append(s)
|
|
||||||
cur_len += len(s) + (1 if len(window) > 1 else 0)
|
|
||||||
j += 1
|
|
||||||
|
|
||||||
if not window:
|
|
||||||
window = [sentences[i]]
|
|
||||||
j = i + 1
|
|
||||||
|
|
||||||
chunk_text = prefix + " ".join(window)
|
|
||||||
chunks.append({
|
|
||||||
"chunk_id": f"win__{win_idx}",
|
|
||||||
"text": chunk_text,
|
|
||||||
"sezione": stem,
|
|
||||||
"titolo": f"finestra {win_idx}",
|
|
||||||
"sub_index": win_idx,
|
|
||||||
"n_chars": len(chunk_text),
|
|
||||||
})
|
|
||||||
win_idx += 1
|
|
||||||
# Avanza di (window_size - overlap), almeno 1
|
|
||||||
i += max(1, len(window) - OVERLAP_S)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Dispatcher ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_STRATEGIES: dict[str, callable] = {
|
|
||||||
"h3_aware": chunk_h3_aware,
|
|
||||||
"h2_paragraph_split": chunk_h2_paragraph_split,
|
|
||||||
"paragraph": chunk_paragraph,
|
|
||||||
"sliding_window": chunk_sliding_window,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_document(clean_md: Path, profile: dict, stem: str) -> list[dict]:
|
|
||||||
text = clean_md.read_text(encoding="utf-8")
|
|
||||||
strategia = profile.get("strategia_chunking", "paragraph")
|
|
||||||
fn = _STRATEGIES.get(strategia, chunk_paragraph)
|
|
||||||
return fn(text, stem)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Per-document processing ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def process_stem(stem: str, project_root: Path, force: bool) -> bool:
|
|
||||||
conv_dir = project_root / "conversione" / stem
|
|
||||||
out_dir = project_root / "step-5" / stem
|
|
||||||
clean_md = conv_dir / "clean.md"
|
|
||||||
profile_path = conv_dir / "structure_profile.json"
|
|
||||||
out_file = out_dir / "chunks.json"
|
|
||||||
|
|
||||||
print(f"\nDocumento: {stem}")
|
|
||||||
|
|
||||||
if not clean_md.exists():
|
|
||||||
print(f" ✗ clean.md non trovato in conversione/{stem}/ — skip")
|
|
||||||
return False
|
|
||||||
if not profile_path.exists():
|
|
||||||
print(f" ✗ structure_profile.json non trovato in conversione/{stem}/ — skip")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if out_file.exists() and not force:
|
|
||||||
print(f" ⚠️ chunks.json già presente — skip")
|
|
||||||
print(f" (usa --force per rieseguire)")
|
|
||||||
return True
|
|
||||||
|
|
||||||
profile = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
||||||
strategia = profile.get("strategia_chunking", "paragraph")
|
|
||||||
print(f" Strategia: {strategia}")
|
|
||||||
|
|
||||||
chunks = chunk_document(clean_md, profile, stem)
|
|
||||||
|
|
||||||
if not chunks:
|
|
||||||
print(f" ✗ Nessun chunk generato — controlla clean.md")
|
|
||||||
return False
|
|
||||||
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
out_file.write_text(
|
|
||||||
json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
lengths = [c["n_chars"] for c in chunks]
|
|
||||||
min_c = min(lengths)
|
|
||||||
max_c = max(lengths)
|
|
||||||
avg_c = int(sum(lengths) / len(lengths))
|
|
||||||
short = sum(1 for l in lengths if l < MIN_CHARS)
|
|
||||||
long_ = sum(1 for l in lengths if l > MAX_CHARS * 1.5)
|
|
||||||
|
|
||||||
print(f" Chunk totali: {len(chunks)}")
|
|
||||||
print(f" Min: {min_c} char Max: {max_c} char Media: {avg_c} char")
|
|
||||||
if short:
|
|
||||||
print(f" ⚠️ {short} chunk sotto MIN_CHARS ({MIN_CHARS})")
|
|
||||||
if long_:
|
|
||||||
print(f" ⚠️ {long_} chunk sopra MAX_CHARS×1.5 ({int(MAX_CHARS * 1.5)})")
|
|
||||||
print(f" ✅ chunks.json salvato in step-5/{stem}/")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
project_root = Path(__file__).parent.parent
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Chunking adattivo")
|
|
||||||
parser.add_argument("--stem", help="Nome del documento (sottocartella di conversione/)")
|
|
||||||
parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.stem:
|
|
||||||
stems = [args.stem]
|
|
||||||
else:
|
|
||||||
conv_dir = project_root / "conversione"
|
|
||||||
if not conv_dir.exists():
|
|
||||||
print(f"Errore: cartella conversione/ non trovata in {project_root}")
|
|
||||||
sys.exit(1)
|
|
||||||
stems = sorted(p.name for p in conv_dir.iterdir() if p.is_dir() and (p / "clean.md").exists())
|
|
||||||
if not stems:
|
|
||||||
print(f"Errore: nessun documento trovato in conversione/")
|
|
||||||
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 processati")
|
|
||||||
sys.exit(0 if all(results) else 1)
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Step 6 — Fix chunk
|
|
||||||
|
|
||||||
Applica correzioni dirette su step-6/<stem>/chunks.json basandosi sul report.json
|
|
||||||
prodotto da verify_chunks.py. Non tocca clean.md né step-5.
|
|
||||||
|
|
||||||
Fixes applicati:
|
|
||||||
empty → rimuove il chunk
|
|
||||||
incomplete → fonde con il chunk successivo (la frase continua)
|
|
||||||
no_prefix → aggiunge prefisso [sezione > titolo] se mancante
|
|
||||||
too_short → fonde con il chunk adiacente nello stesso sezione
|
|
||||||
too_long → spezza all'ultimo confine di paragrafo/frase entro MAX_CHARS
|
|
||||||
|
|
||||||
Input: step-6/<stem>/chunks.json + step-6/<stem>/report.json
|
|
||||||
Output: step-6/<stem>/chunks.json (sovrascrive)
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
python step-6/fix_chunks.py --stem nietzsche
|
|
||||||
python step-6/fix_chunks.py --stem nietzsche --dry-run
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
MAX_CHARS = 800
|
|
||||||
PUNCT_END = re.compile(r"[.!?»)\]'\u2019\"\u201c\u201d\u2018\u2014\u2013-]$")
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _prefix(chunk: dict) -> str:
|
|
||||||
"""Costruisce il prefisso [sezione > titolo] o [sezione]."""
|
|
||||||
sezione = chunk.get("sezione", "")
|
|
||||||
titolo = chunk.get("titolo", "")
|
|
||||||
if titolo:
|
|
||||||
return f"[{sezione} > {titolo}]"
|
|
||||||
return f"[{sezione}]"
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_prefix(text: str) -> str:
|
|
||||||
"""Rimuove il prefisso [...] iniziale dal testo, se presente."""
|
|
||||||
text = text.lstrip()
|
|
||||||
if text.startswith("["):
|
|
||||||
end = text.find("]")
|
|
||||||
if end != -1:
|
|
||||||
return text[end + 1:].lstrip("\n")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _rebuild_text(chunk: dict, body: str) -> str:
|
|
||||||
"""Ricompone il testo completo: prefisso + corpo."""
|
|
||||||
return f"{_prefix(chunk)}\n{body}"
|
|
||||||
|
|
||||||
|
|
||||||
def _split_at_boundary(text: str, max_chars: int) -> list[str]:
|
|
||||||
"""
|
|
||||||
Spezza 'text' in segmenti di al massimo max_chars caratteri,
|
|
||||||
tagliando al confine più vicino (doppio newline, poi fine frase).
|
|
||||||
Ritorna una lista di segmenti non vuoti.
|
|
||||||
"""
|
|
||||||
if len(text) <= max_chars:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
remaining = text
|
|
||||||
|
|
||||||
while len(remaining) > max_chars:
|
|
||||||
# Cerca l'ultimo \n\n entro max_chars
|
|
||||||
candidate = remaining[:max_chars]
|
|
||||||
split_pos = candidate.rfind("\n\n")
|
|
||||||
|
|
||||||
if split_pos == -1:
|
|
||||||
# Cerca l'ultimo . ? ! entro max_chars
|
|
||||||
m = None
|
|
||||||
for m in re.finditer(r"[.!?»]\s+", candidate):
|
|
||||||
pass
|
|
||||||
split_pos = m.end() if m else None
|
|
||||||
|
|
||||||
if split_pos is None or split_pos == 0:
|
|
||||||
# Nessun punto naturale: taglia sul primo spazio dopo max_chars
|
|
||||||
sp = remaining.find(" ", max_chars)
|
|
||||||
split_pos = sp if sp != -1 else len(remaining)
|
|
||||||
|
|
||||||
parts.append(remaining[:split_pos].rstrip())
|
|
||||||
remaining = remaining[split_pos:].lstrip()
|
|
||||||
|
|
||||||
if remaining:
|
|
||||||
parts.append(remaining)
|
|
||||||
|
|
||||||
return [p for p in parts if p.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Operazioni sui chunk ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def fix_empty(chunks: list[dict], empty_ids: set[str]) -> tuple[list[dict], int]:
|
|
||||||
"""Rimuove i chunk vuoti."""
|
|
||||||
before = len(chunks)
|
|
||||||
chunks = [c for c in chunks if c["chunk_id"] not in empty_ids]
|
|
||||||
return chunks, before - len(chunks)
|
|
||||||
|
|
||||||
|
|
||||||
def fix_no_prefix(chunks: list[dict], no_prefix_ids: set[str]) -> tuple[list[dict], int]:
|
|
||||||
"""Aggiunge il prefisso mancante ai chunk che ne sono privi."""
|
|
||||||
count = 0
|
|
||||||
for c in chunks:
|
|
||||||
if c["chunk_id"] in no_prefix_ids:
|
|
||||||
body = _strip_prefix(c["text"])
|
|
||||||
c["text"] = _rebuild_text(c, body)
|
|
||||||
c["n_chars"] = len(c["text"])
|
|
||||||
count += 1
|
|
||||||
return chunks, count
|
|
||||||
|
|
||||||
|
|
||||||
def fix_incomplete_and_short(chunks: list[dict],
|
|
||||||
problem_ids: set[str]) -> tuple[list[dict], int]:
|
|
||||||
"""
|
|
||||||
Fonde i chunk problematici (incompleti o troppo corti) con il chunk
|
|
||||||
immediatamente successivo che appartiene allo stesso sezione.
|
|
||||||
Usato sia per 'incomplete' che per 'too_short'.
|
|
||||||
"""
|
|
||||||
merged = 0
|
|
||||||
i = 0
|
|
||||||
result: list[dict] = []
|
|
||||||
|
|
||||||
while i < len(chunks):
|
|
||||||
c = chunks[i]
|
|
||||||
if c["chunk_id"] in problem_ids and i + 1 < len(chunks):
|
|
||||||
nxt = chunks[i + 1]
|
|
||||||
# Fonde solo se stesso sezione (o se il successivo è compatibile)
|
|
||||||
body_c = _strip_prefix(c["text"])
|
|
||||||
body_nxt = _strip_prefix(nxt["text"])
|
|
||||||
merged_body = body_c.rstrip() + "\n" + body_nxt.lstrip()
|
|
||||||
nxt["text"] = _rebuild_text(nxt, merged_body)
|
|
||||||
nxt["n_chars"] = len(nxt["text"])
|
|
||||||
# Salta c (è stato assorbito in nxt)
|
|
||||||
merged += 1
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
result.append(c)
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return result, merged
|
|
||||||
|
|
||||||
|
|
||||||
def fix_too_long(chunks: list[dict],
|
|
||||||
too_long_ids: set[str],
|
|
||||||
max_chars: int) -> tuple[list[dict], int]:
|
|
||||||
"""
|
|
||||||
Spezza i chunk troppo lunghi al confine naturale più vicino a max_chars.
|
|
||||||
Ogni sotto-chunk eredita sezione/titolo e riceve un nuovo sub_index.
|
|
||||||
"""
|
|
||||||
result: list[dict] = []
|
|
||||||
split_count = 0
|
|
||||||
|
|
||||||
for c in chunks:
|
|
||||||
if c["chunk_id"] not in too_long_ids:
|
|
||||||
result.append(c)
|
|
||||||
continue
|
|
||||||
|
|
||||||
body = _strip_prefix(c["text"])
|
|
||||||
parts = _split_at_boundary(body, max_chars)
|
|
||||||
|
|
||||||
if len(parts) == 1:
|
|
||||||
# Non spezzabile: lascia invariato
|
|
||||||
result.append(c)
|
|
||||||
continue
|
|
||||||
|
|
||||||
base_id = re.sub(r"__s\d+$", "", c["chunk_id"])
|
|
||||||
base_sub = c.get("sub_index", 0)
|
|
||||||
|
|
||||||
for j, part in enumerate(parts):
|
|
||||||
new_chunk = dict(c)
|
|
||||||
new_chunk["sub_index"] = base_sub + j
|
|
||||||
new_chunk["chunk_id"] = f"{base_id}__s{base_sub + j}"
|
|
||||||
new_chunk["text"] = _rebuild_text(new_chunk, part)
|
|
||||||
new_chunk["n_chars"] = len(new_chunk["text"])
|
|
||||||
result.append(new_chunk)
|
|
||||||
|
|
||||||
split_count += 1
|
|
||||||
|
|
||||||
return result, split_count
|
|
||||||
|
|
||||||
|
|
||||||
def renumber_ids(chunks: list[dict]) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Rinomina i chunk_id per evitare duplicati dopo merge/split.
|
|
||||||
Mantiene il pattern base__sN dove N è il nuovo indice per sezione/titolo.
|
|
||||||
"""
|
|
||||||
seen: dict[str, int] = {}
|
|
||||||
for c in chunks:
|
|
||||||
base = re.sub(r"__s\d+$", "", c["chunk_id"])
|
|
||||||
idx = seen.get(base, 0)
|
|
||||||
c["chunk_id"] = f"{base}__s{idx}"
|
|
||||||
c["sub_index"] = idx
|
|
||||||
seen[base] = idx + 1
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Core ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def fix_stem(stem: str, project_root: Path, max_chars: int, dry_run: bool) -> bool:
|
|
||||||
step6_dir = project_root / "step-6" / stem
|
|
||||||
chunks_path = step6_dir / "chunks.json"
|
|
||||||
report_path = step6_dir / "report.json"
|
|
||||||
|
|
||||||
if not chunks_path.exists():
|
|
||||||
print(f"✗ step-6/{stem}/chunks.json non trovato.")
|
|
||||||
print(f" Esegui prima: python step-6/verify_chunks.py --stem {stem}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not report_path.exists():
|
|
||||||
print(f"✗ step-6/{stem}/report.json non trovato.")
|
|
||||||
print(f" Esegui prima: python step-6/verify_chunks.py --stem {stem}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
chunks: list[dict] = json.loads(chunks_path.read_text(encoding="utf-8"))
|
|
||||||
report: dict = json.loads(report_path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
verdict = report.get("verdict", "ok")
|
|
||||||
print(f"\nDocumento: {stem} (verdict: {verdict})")
|
|
||||||
|
|
||||||
if verdict == "ok":
|
|
||||||
print(" ✅ Nessun problema — nulla da correggere.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Raccogli gli ID per categoria
|
|
||||||
empty_ids = {e["chunk_id"] for e in report.get("blockers", {}).get("empty", [])}
|
|
||||||
no_prefix_ids = {e["chunk_id"] for e in report.get("blockers", {}).get("no_prefix", [])}
|
|
||||||
incomplete_ids = {e["chunk_id"] for e in report.get("blockers", {}).get("incomplete", [])}
|
|
||||||
too_short_ids = {e["chunk_id"] for e in report.get("warnings", {}).get("too_short", [])}
|
|
||||||
too_long_ids = {e["chunk_id"] for e in report.get("warnings", {}).get("too_long", [])}
|
|
||||||
|
|
||||||
# Riepilogo operazioni pianificate
|
|
||||||
ops: list[str] = []
|
|
||||||
if empty_ids:
|
|
||||||
ops.append(f" 🗑 rimuovi {len(empty_ids)} chunk vuoti")
|
|
||||||
if no_prefix_ids:
|
|
||||||
ops.append(f" 🔧 aggiungi prefisso a {len(no_prefix_ids)} chunk")
|
|
||||||
if incomplete_ids:
|
|
||||||
ops.append(f" 🔗 fondi {len(incomplete_ids)} chunk incompleti col successivo")
|
|
||||||
if too_short_ids:
|
|
||||||
ops.append(f" 🔗 fondi {len(too_short_ids)} chunk troppo corti col successivo")
|
|
||||||
if too_long_ids:
|
|
||||||
ops.append(f" ✂️ spezza {len(too_long_ids)} chunk troppo lunghi")
|
|
||||||
|
|
||||||
if not ops:
|
|
||||||
print(" ✅ Nessuna correzione necessaria.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
print("\n Operazioni pianificate:")
|
|
||||||
for op in ops:
|
|
||||||
print(op)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("\n [dry-run] Nessuna modifica applicata.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
n_before = len(chunks)
|
|
||||||
|
|
||||||
# Applica nell'ordine corretto
|
|
||||||
if empty_ids:
|
|
||||||
chunks, n = fix_empty(chunks, empty_ids)
|
|
||||||
print(f"\n 🗑 Rimossi {n} chunk vuoti.")
|
|
||||||
|
|
||||||
if no_prefix_ids:
|
|
||||||
chunks, n = fix_no_prefix(chunks, no_prefix_ids)
|
|
||||||
print(f" 🔧 Aggiunto prefisso a {n} chunk.")
|
|
||||||
|
|
||||||
# incomplete prima di too_short (entrambi usano merge-forward)
|
|
||||||
merge_ids = incomplete_ids | too_short_ids
|
|
||||||
if merge_ids:
|
|
||||||
chunks, n = fix_incomplete_and_short(chunks, merge_ids)
|
|
||||||
print(f" 🔗 Fusi {n} chunk (incompleti + corti).")
|
|
||||||
|
|
||||||
if too_long_ids:
|
|
||||||
# Aggiorna too_long_ids per chunk che potrebbero essere stati rinominati
|
|
||||||
# dopo il merge (usa ancora gli ID originali, il merge non tocca too_long)
|
|
||||||
chunks, n = fix_too_long(chunks, too_long_ids, max_chars)
|
|
||||||
print(f" ✂️ Spezzati {n} chunk lunghi.")
|
|
||||||
|
|
||||||
# Rinumera per evitare duplicati
|
|
||||||
chunks = renumber_ids(chunks)
|
|
||||||
|
|
||||||
n_after = len(chunks)
|
|
||||||
print(f"\n Totale chunk: {n_before} → {n_after}")
|
|
||||||
|
|
||||||
# Salva
|
|
||||||
chunks_path.write_text(
|
|
||||||
json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
||||||
)
|
|
||||||
print(f" ✅ Salvato: step-6/{stem}/chunks.json")
|
|
||||||
print(f"\n Riesegui la verifica:")
|
|
||||||
print(f" python step-6/verify_chunks.py --stem {stem}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
project_root = Path(__file__).parent.parent
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Step 6 — Fix chunk")
|
|
||||||
parser.add_argument("--stem", required=True, help="Nome del documento (sottocartella di step-6/)")
|
|
||||||
parser.add_argument(
|
|
||||||
"--max", type=int, default=MAX_CHARS,
|
|
||||||
help=f"Soglia massima caratteri per lo split (default: {MAX_CHARS})"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dry-run", action="store_true",
|
|
||||||
help="Mostra le operazioni pianificate senza applicarle"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ok = fix_stem(args.stem, project_root, args.max, args.dry_run)
|
|
||||||
sys.exit(0 if ok else 1)
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Step 6 — Verifica chunk
|
|
||||||
|
|
||||||
Analizza step-5/<stem>/chunks.json e segnala ogni anomalia che potrebbe
|
|
||||||
degradare la qualità del retrieval. Non modifica nulla: se ci sono problemi
|
|
||||||
torna allo step 4 (revisione MD) o aggiusta i parametri dello step 5.
|
|
||||||
|
|
||||||
Input: step-5/<stem>/chunks.json
|
|
||||||
Output: report a schermo + step-6/<stem>/report.json + exit code (0 = OK, 1 = problemi)
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
python step-6/verify_chunks.py --stem documento
|
|
||||||
python step-6/verify_chunks.py # tutti i documenti in step-5/
|
|
||||||
python step-6/verify_chunks.py --min 200 --max 800
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Soglie (devono coincidere con quelle usate in chunker.py) ────────────────
|
|
||||||
|
|
||||||
MIN_CHARS = 200
|
|
||||||
MAX_CHARS = 800
|
|
||||||
PUNCT_END = re.compile("[.!?»)\\]'\u2019\"\u201c\u201d\u2018\u2014\u2013\u2026-]$")
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Checks ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def has_prefix(chunk: dict) -> bool:
|
|
||||||
"""Il chunk inizia con il prefisso di contesto '[...'."""
|
|
||||||
return chunk.get("text", "").lstrip().startswith("[")
|
|
||||||
|
|
||||||
|
|
||||||
def is_empty(chunk: dict) -> bool:
|
|
||||||
return not chunk.get("text", "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def is_too_short(chunk: dict, min_chars: int) -> bool:
|
|
||||||
return chunk.get("n_chars", 0) < min_chars
|
|
||||||
|
|
||||||
|
|
||||||
def is_too_long(chunk: dict, max_chars: int) -> bool:
|
|
||||||
return chunk.get("n_chars", 0) > max_chars * 1.5
|
|
||||||
|
|
||||||
|
|
||||||
def ends_incomplete(chunk: dict) -> bool:
|
|
||||||
"""L'ultima riga di testo non termina con punteggiatura di fine frase."""
|
|
||||||
text = chunk.get("text", "").rstrip()
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
# Rimuovi marcatori markdown finali (_ e *) prima di controllare:
|
|
||||||
# pattern come _parola._ o _parola!_ sono frasi complete.
|
|
||||||
text_check = re.sub(r"[_*]+$", "", text).rstrip()
|
|
||||||
if not text_check:
|
|
||||||
return False
|
|
||||||
return not PUNCT_END.search(text_check)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Report ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _fmt_chunk(c: dict) -> str:
|
|
||||||
cid = c.get("chunk_id", "?")
|
|
||||||
n = c.get("n_chars", 0)
|
|
||||||
preview = c.get("text", "")[:60].replace("\n", " ")
|
|
||||||
return f" [{cid}] ({n} char) «{preview}»"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_stem(stem: str, project_root: Path, min_chars: int, max_chars: int) -> bool:
|
|
||||||
"""Verifica i chunk di un documento. Ritorna True se nessun bloccante rilevato."""
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
chunks_path = project_root / "step-6" / stem / "chunks.json"
|
|
||||||
|
|
||||||
print(f"\nDocumento: {stem}")
|
|
||||||
|
|
||||||
if not chunks_path.exists():
|
|
||||||
src = project_root / "step-5" / stem / "chunks.json"
|
|
||||||
if src.exists():
|
|
||||||
chunks_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copy2(src, chunks_path)
|
|
||||||
print(f" → Copiato step-5/{stem}/chunks.json → step-6/{stem}/chunks.json")
|
|
||||||
else:
|
|
||||||
print(f" ✗ chunks.json non trovato in step-5/{stem}/ né in step-6/{stem}/ — skip")
|
|
||||||
return False
|
|
||||||
|
|
||||||
chunks: list[dict] = json.loads(chunks_path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
if not chunks:
|
|
||||||
print(f" ✗ chunks.json è vuoto")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ── Raccogli problemi ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
empty_chunks = [c for c in chunks if is_empty(c)]
|
|
||||||
no_prefix = [c for c in chunks if not is_empty(c) and not has_prefix(c)]
|
|
||||||
too_short = [c for c in chunks if is_too_short(c, min_chars)]
|
|
||||||
too_long = [c for c in chunks if is_too_long(c, max_chars)]
|
|
||||||
incomplete = [c for c in chunks if not is_empty(c) and ends_incomplete(c)]
|
|
||||||
|
|
||||||
# ── Statistiche lunghezze ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
lengths = [c.get("n_chars", 0) for c in chunks]
|
|
||||||
n_total = len(chunks)
|
|
||||||
n_ok = n_total - len(set(
|
|
||||||
c["chunk_id"] for lst in [empty_chunks, no_prefix, too_short, too_long, incomplete]
|
|
||||||
for c in lst
|
|
||||||
))
|
|
||||||
min_l = min(lengths)
|
|
||||||
max_l = max(lengths)
|
|
||||||
avg_l = int(sum(lengths) / n_total)
|
|
||||||
|
|
||||||
# Distribuzione in fasce
|
|
||||||
n_under = sum(1 for l in lengths if l < min_chars)
|
|
||||||
n_normal = sum(1 for l in lengths if min_chars <= l <= max_chars)
|
|
||||||
n_over = sum(1 for l in lengths if l > max_chars)
|
|
||||||
|
|
||||||
# ── Output ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
print(f" Totale chunk: {n_total}")
|
|
||||||
print(f" ✅ OK: {n_ok}")
|
|
||||||
print()
|
|
||||||
print(f" Distribuzione lunghezze:")
|
|
||||||
print(f" Min: {min_l} char")
|
|
||||||
print(f" Max: {max_l} char")
|
|
||||||
print(f" Media: {avg_l} char")
|
|
||||||
print(f" < {min_chars} char (sotto MIN): {n_under}")
|
|
||||||
print(f" {min_chars}–{max_chars} char (ideale): {n_normal}")
|
|
||||||
print(f" > {max_chars} char (sopra MAX): {n_over}")
|
|
||||||
|
|
||||||
has_errors = False
|
|
||||||
|
|
||||||
if empty_chunks:
|
|
||||||
has_errors = True
|
|
||||||
print(f"\n 🔴 {len(empty_chunks)} chunk VUOTI:")
|
|
||||||
for c in empty_chunks[:5]:
|
|
||||||
print(f" [{c.get('chunk_id', '?')}]")
|
|
||||||
if len(empty_chunks) > 5:
|
|
||||||
print(f" ... e altri {len(empty_chunks) - 5}")
|
|
||||||
|
|
||||||
if no_prefix:
|
|
||||||
has_errors = True
|
|
||||||
print(f"\n 🔴 {len(no_prefix)} chunk SENZA PREFISSO DI CONTESTO:")
|
|
||||||
for c in no_prefix[:5]:
|
|
||||||
print(_fmt_chunk(c))
|
|
||||||
if len(no_prefix) > 5:
|
|
||||||
print(f" ... e altri {len(no_prefix) - 5}")
|
|
||||||
print(f" → Causa probabile: header ### mancanti o malformati nel MD (step 4)")
|
|
||||||
|
|
||||||
if too_short:
|
|
||||||
has_errors = True
|
|
||||||
print(f"\n 🟡 {len(too_short)} chunk SOTTO MIN_CHARS ({min_chars}):")
|
|
||||||
for c in too_short[:5]:
|
|
||||||
print(_fmt_chunk(c))
|
|
||||||
if len(too_short) > 5:
|
|
||||||
print(f" ... e altri {len(too_short) - 5}")
|
|
||||||
print(f" → Causa probabile: sezione isolata senza successivo nello stesso ##")
|
|
||||||
print(f" → Soluzione: abbassa MIN_CHARS o revisiona il MD (step 4)")
|
|
||||||
|
|
||||||
if too_long:
|
|
||||||
has_errors = True
|
|
||||||
print(f"\n 🟡 {len(too_long)} chunk SOPRA MAX_CHARS×1.5 ({int(max_chars * 1.5)}):")
|
|
||||||
for c in too_long[:5]:
|
|
||||||
print(_fmt_chunk(c))
|
|
||||||
if len(too_long) > 5:
|
|
||||||
print(f" ... e altri {len(too_long) - 5}")
|
|
||||||
print(f" → Causa probabile: frase singola molto lunga non spezzabile")
|
|
||||||
print(f" → Soluzione: alza MAX_CHARS o verifica il testo nel MD (step 4)")
|
|
||||||
|
|
||||||
if incomplete:
|
|
||||||
has_errors = True
|
|
||||||
print(f"\n 🔴 {len(incomplete)} chunk CHE FINISCONO SENZA PUNTEGGIATURA (frase spezzata):")
|
|
||||||
for c in incomplete[:5]:
|
|
||||||
last_line = c.get("text", "").rstrip().split("\n")[-1][-80:]
|
|
||||||
print(f" [{c.get('chunk_id', '?')}] ...{last_line!r}")
|
|
||||||
if len(incomplete) > 5:
|
|
||||||
print(f" ... e altri {len(incomplete) - 5}")
|
|
||||||
print(f" → Causa probabile: paragrafo spezzato nel MD")
|
|
||||||
print(f" → Soluzione: correggi le righe spezzate in conversione/{stem}/clean.md")
|
|
||||||
|
|
||||||
# ── Costruisci e salva report.json ───────────────────────────────────────
|
|
||||||
|
|
||||||
blockers = empty_chunks + no_prefix + incomplete
|
|
||||||
warnings = too_short + too_long
|
|
||||||
|
|
||||||
def _chunk_entry(c: dict) -> dict:
|
|
||||||
return {
|
|
||||||
"chunk_id": c.get("chunk_id", ""),
|
|
||||||
"sezione": c.get("sezione", ""),
|
|
||||||
"titolo": c.get("titolo", ""),
|
|
||||||
"n_chars": c.get("n_chars", 0),
|
|
||||||
"last_text": c.get("text", "").rstrip().split("\n")[-1][-120:],
|
|
||||||
}
|
|
||||||
|
|
||||||
if not blockers:
|
|
||||||
verdict = "ok" if not warnings else "warnings_only"
|
|
||||||
else:
|
|
||||||
verdict = "blocked"
|
|
||||||
|
|
||||||
report = {
|
|
||||||
"stem": stem,
|
|
||||||
"verdict": verdict,
|
|
||||||
"stats": {
|
|
||||||
"total": n_total,
|
|
||||||
"ok": n_ok,
|
|
||||||
"min_chars": min_l,
|
|
||||||
"max_chars": max_l,
|
|
||||||
"avg_chars": avg_l,
|
|
||||||
},
|
|
||||||
"thresholds": {"min_chars": min_chars, "max_chars": max_chars},
|
|
||||||
"blockers": {
|
|
||||||
"empty": [_chunk_entry(c) for c in empty_chunks],
|
|
||||||
"no_prefix": [_chunk_entry(c) for c in no_prefix],
|
|
||||||
"incomplete": [_chunk_entry(c) for c in incomplete],
|
|
||||||
},
|
|
||||||
"warnings": {
|
|
||||||
"too_short": [_chunk_entry(c) for c in too_short],
|
|
||||||
"too_long": [_chunk_entry(c) for c in too_long],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out_dir = project_root / "step-6" / stem
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(out_dir / "report.json").write_text(
|
|
||||||
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
||||||
)
|
|
||||||
print(f"\n report.json salvato in step-6/{stem}/")
|
|
||||||
|
|
||||||
# ── Prossimi passi ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
print(f"\n {'─' * 50}")
|
|
||||||
print(f" PROSSIMI PASSI")
|
|
||||||
print(f" {'─' * 50}")
|
|
||||||
|
|
||||||
if not blockers and not warnings:
|
|
||||||
print(f" ✅ Tutto OK — procedi alla vettorizzazione:")
|
|
||||||
print(f" python step-8/ingest.py --stem {stem}")
|
|
||||||
|
|
||||||
elif not blockers:
|
|
||||||
# Solo 🟡: si può procedere, i warning sono facoltativi
|
|
||||||
print(f" 🟡 Solo avvisi minori — puoi procedere alla vettorizzazione:")
|
|
||||||
print(f" python step-8/ingest.py --stem {stem}")
|
|
||||||
print()
|
|
||||||
print(f" Oppure, per ottimizzare prima di procedere:")
|
|
||||||
if too_short:
|
|
||||||
pct = int(len(too_short) / n_total * 100)
|
|
||||||
print(f" • {len(too_short)} chunk corti ({pct}% del totale)")
|
|
||||||
if too_long:
|
|
||||||
pct = int(len(too_long) / n_total * 100)
|
|
||||||
print(f" • {len(too_long)} chunk lunghi ({pct}% del totale)")
|
|
||||||
if too_short or too_long:
|
|
||||||
print(f" → Esegui: python step-6/fix_chunks.py --stem {stem} --dry-run")
|
|
||||||
print(f" poi: python step-6/fix_chunks.py --stem {stem}")
|
|
||||||
print(f" poi: python step-6/verify_chunks.py --stem {stem}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Ci sono 🔴: non si procede
|
|
||||||
print(f" 🔴 Problemi bloccanti — correggi prima di procedere:")
|
|
||||||
print()
|
|
||||||
if empty_chunks:
|
|
||||||
print(f" • {len(empty_chunks)} chunk vuoti")
|
|
||||||
print(f" → Controlla conversione/{stem}/clean.md per sezioni prive di testo")
|
|
||||||
if no_prefix:
|
|
||||||
print(f" • {len(no_prefix)} chunk senza prefisso di contesto")
|
|
||||||
print(f" → Controlla che gli header ### siano corretti in conversione/{stem}/clean.md")
|
|
||||||
if incomplete:
|
|
||||||
print(f" • {len(incomplete)} chunk con frase spezzata")
|
|
||||||
print(f" → Esegui: python step-6/fix_chunks.py --stem {stem}")
|
|
||||||
print()
|
|
||||||
print(f" Dopo le correzioni, riesegui nell'ordine:")
|
|
||||||
print(f" python step-5/chunker.py --stem {stem} --force")
|
|
||||||
print(f" python step-6/verify_chunks.py --stem {stem}")
|
|
||||||
print()
|
|
||||||
if warnings:
|
|
||||||
print(f" 🟡 Hai anche {len(warnings)} avvisi minori — affrontali dopo aver risolto i 🔴.")
|
|
||||||
|
|
||||||
return not blockers
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
project_root = Path(__file__).parent.parent
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Step 6 — Verifica chunk")
|
|
||||||
parser.add_argument("--stem", help="Nome del documento (sottocartella di step-5/)")
|
|
||||||
parser.add_argument(
|
|
||||||
"--min", type=int, default=MIN_CHARS,
|
|
||||||
help=f"Soglia minima caratteri (default: {MIN_CHARS})"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max", type=int, default=MAX_CHARS,
|
|
||||||
help=f"Soglia massima caratteri (default: {MAX_CHARS})"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.stem:
|
|
||||||
stems = [args.stem]
|
|
||||||
else:
|
|
||||||
step5_dir = project_root / "step-5"
|
|
||||||
if not step5_dir.exists():
|
|
||||||
print(f"Errore: cartella step-5/ non trovata in {project_root}")
|
|
||||||
sys.exit(1)
|
|
||||||
stems = sorted(
|
|
||||||
p.name for p in step5_dir.iterdir()
|
|
||||||
if p.is_dir() and (p / "chunks.json").exists()
|
|
||||||
)
|
|
||||||
if not stems:
|
|
||||||
print("Errore: nessun chunks.json trovato in step-5/")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
results = [verify_stem(s, project_root, args.min, args.max) for s in stems]
|
|
||||||
|
|
||||||
ok = sum(results)
|
|
||||||
total = len(results)
|
|
||||||
print(f"\n{'✅' if all(results) else '⚠️ '} {ok}/{total} documenti senza problemi")
|
|
||||||
sys.exit(0 if all(results) else 1)
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# Step 8 — Vettorizzazione
|
|
||||||
|
|
||||||
Legge i chunk prodotti da step-6, genera gli embedding tramite Ollama e li
|
|
||||||
salva in ChromaDB (vector store persistente su disco).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisiti
|
|
||||||
|
|
||||||
- Step-6 completato (esiste `step-6/<stem>/chunks.json`)
|
|
||||||
- Ollama attivo con il modello di embedding scaricato
|
|
||||||
- `chromadb` installato (`pip install -r requirements.txt`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configurazione modello
|
|
||||||
|
|
||||||
Il modello di embedding viene letto da **`config.py`**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# config.py
|
|
||||||
EMBED_MODEL = "nomic-embed-text" # ← cambia qui
|
|
||||||
```
|
|
||||||
|
|
||||||
> 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`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Uso
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Vettorizza un singolo documento
|
|
||||||
python step-8/ingest.py --stem <nome>
|
|
||||||
|
|
||||||
# Vettorizza tutti i documenti trovati in step-6/
|
|
||||||
python step-8/ingest.py
|
|
||||||
|
|
||||||
# Sovrascrive una collection già esistente
|
|
||||||
python step-8/ingest.py --stem <nome> --force
|
|
||||||
|
|
||||||
# Override modello (senza modificare config.py)
|
|
||||||
python step-8/ingest.py --stem <nome> --model bge-m3
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
I vettori vengono salvati in `chroma_db/<stem>/` come collection ChromaDB con
|
|
||||||
distanza coseno. La directory è ignorata da git (generata automaticamente).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modelli supportati
|
|
||||||
|
|
||||||
Stessi modelli raccomandati nel [README di ollama](../ollama/README.md).
|
|
||||||
Il modello deve essere scaricato in Ollama prima di eseguire questo script
|
|
||||||
(`ollama pull <modello>`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Regole d'oro per parametri ottimali
|
|
||||||
|
|
||||||
### Modello di embedding
|
|
||||||
|
|
||||||
**Usa un modello multilingue per testi italiani.**
|
|
||||||
I modelli English-first (`nomic-embed-text`, `mxbai-embed-large`, `all-minilm`)
|
|
||||||
producono vettori di qualità inferiore su italiano, con retrieval meno preciso.
|
|
||||||
Prima scelta: `qwen3-embedding:0.6b`.
|
|
||||||
|
|
||||||
**Più dimensioni = retrieval più preciso, ma più spazio su disco.**
|
|
||||||
|
|
||||||
| Dimensioni | Modelli | Quando usarlo |
|
|
||||||
|---|---|---|
|
|
||||||
| 1024 | `qwen3-embedding:0.6b`, `bge-m3` | documenti tecnici, testi lunghi |
|
|
||||||
| 768 | `nomic-embed-text-v2-moe` | buon compromesso |
|
|
||||||
| 384 | `all-minilm` | solo per test rapidi |
|
|
||||||
|
|
||||||
**Usa la stessa famiglia LLM + embedding quando possibile.**
|
|
||||||
`qwen3-embedding` + `qwen3.5` condividono tokenizer e spazio semantico —
|
|
||||||
il retrieval è più coerente rispetto a modelli di famiglie diverse.
|
|
||||||
|
|
||||||
### Coerenza tra ingest e retrieval
|
|
||||||
|
|
||||||
**`EMBED_MODEL` deve essere identico in `ingest.py` e `rag.py`.**
|
|
||||||
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
|
|
||||||
e il retrieval restituisce risultati casuali — senza alcun errore visibile.
|
|
||||||
|
|
||||||
**Dopo aver cambiato `EMBED_MODEL`, riesegui sempre con `--force`.**
|
|
||||||
Senza `--force` lo script salta la collection già esistente — i vecchi vettori
|
|
||||||
(generati col modello precedente) restano e continuano a essere usati da `rag.py`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Cambio modello → ricrea sempre la collection
|
|
||||||
python step-8/ingest.py --stem <nome> --force
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quando usare `--force`
|
|
||||||
|
|
||||||
| Situazione | `--force` necessario? |
|
|
||||||
|---|---|
|
|
||||||
| Prima esecuzione | No |
|
|
||||||
| Hai cambiato `EMBED_MODEL` | **Sì** |
|
|
||||||
| Hai migliorato i chunk in step-6 | **Sì** |
|
|
||||||
| Hai aggiunto nuovi documenti (stem diverso) | No |
|
|
||||||
| Vuoi solo verificare che funzioni | No |
|
|
||||||
|
|
||||||
### Distanza vettoriale
|
|
||||||
|
|
||||||
Lo script usa **distanza coseno** (hardcoded), che è la scelta corretta per
|
|
||||||
embedding testuali — misura l'angolo tra vettori indipendentemente dalla loro
|
|
||||||
lunghezza. Non cambiare questo parametro.
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Step 8 — Vettorizzazione
|
|
||||||
|
|
||||||
Legge i chunk prodotti da step-6, genera gli embedding tramite Ollama
|
|
||||||
e li indicizza in ChromaDB (persistente).
|
|
||||||
|
|
||||||
Il modello di embedding viene letto da config.py (EMBED_MODEL).
|
|
||||||
Puoi sovrascriverlo con --model, ma deve corrispondere al modello che
|
|
||||||
userai in rag.py — altrimenti riesegui con --force dopo aver cambiato.
|
|
||||||
|
|
||||||
Input: step-6/<stem>/chunks.json
|
|
||||||
Output: chroma_db/<stem> (collection ChromaDB)
|
|
||||||
|
|
||||||
Uso:
|
|
||||||
python step-8/ingest.py --stem <nome> # singolo documento
|
|
||||||
python step-8/ingest.py # tutti gli stem trovati
|
|
||||||
python step-8/ingest.py --stem <nome> --force # sovrascrive collection
|
|
||||||
python step-8/ingest.py --model bge-m3 # override modello
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import chromadb
|
|
||||||
|
|
||||||
# ─── Configurazione ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent
|
|
||||||
|
|
||||||
CHUNKS_DIR = project_root / "step-6"
|
|
||||||
CHROMA_DIR = project_root / "chroma_db"
|
|
||||||
|
|
||||||
sys.path.insert(0, str(project_root))
|
|
||||||
from config import EMBED_MODEL, OLLAMA_URL # noqa: E402
|
|
||||||
|
|
||||||
EMBED_ENDPOINT = f"{OLLAMA_URL}/api/embeddings"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Ollama ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def embed(text: str, model: str) -> list[float]:
|
|
||||||
"""Chiama Ollama /api/embeddings e ritorna il vettore."""
|
|
||||||
payload = json.dumps({"model": model, "prompt": text}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
EMBED_ENDPOINT,
|
|
||||||
data=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
return data["embedding"]
|
|
||||||
|
|
||||||
|
|
||||||
def check_ollama(model: str) -> bool:
|
|
||||||
"""Verifica che Ollama sia attivo e che il modello di embedding sia disponibile."""
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(f"{OLLAMA_URL}/api/tags", method="GET")
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
models = [m["name"] for m in data.get("models", [])]
|
|
||||||
found = any(
|
|
||||||
m == model or m.startswith(model + ":")
|
|
||||||
for m in models
|
|
||||||
)
|
|
||||||
if found:
|
|
||||||
print(f"✅ Ollama OK — {model} disponibile")
|
|
||||||
return True
|
|
||||||
print(f"❌ Modello {model} non trovato in Ollama")
|
|
||||||
print(f" → ollama pull {model}")
|
|
||||||
return False
|
|
||||||
except (urllib.error.URLError, OSError):
|
|
||||||
print("❌ Ollama non raggiungibile — assicurati che sia in esecuzione")
|
|
||||||
print(" → ollama serve")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ─── ChromaDB ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_client() -> chromadb.PersistentClient:
|
|
||||||
CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
return chromadb.PersistentClient(path=str(CHROMA_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def collection_exists(client: chromadb.PersistentClient, stem: str) -> bool:
|
|
||||||
return any(c.name == stem for c in client.list_collections())
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Ingestione ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def ingest(stem: str, force: bool, model: str = EMBED_MODEL) -> bool:
|
|
||||||
"""
|
|
||||||
Legge step-6/<stem>/chunks.json, genera embedding e popola ChromaDB.
|
|
||||||
Ritorna True se completato con successo, False altrimenti.
|
|
||||||
"""
|
|
||||||
chunks_path = CHUNKS_DIR / stem / "chunks.json"
|
|
||||||
if not chunks_path.exists():
|
|
||||||
print(f"❌ File non trovato: {chunks_path}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(chunks_path, encoding="utf-8") as f:
|
|
||||||
chunks = json.load(f)
|
|
||||||
|
|
||||||
if not chunks:
|
|
||||||
print(f"⚠️ {stem}: chunks.json è vuoto — skip")
|
|
||||||
return False
|
|
||||||
|
|
||||||
client = get_client()
|
|
||||||
|
|
||||||
if collection_exists(client, stem):
|
|
||||||
if not force:
|
|
||||||
print(f"⚠️ Collection '{stem}' già presente in ChromaDB — skip")
|
|
||||||
print(f" → usa --force per sovrascrivere")
|
|
||||||
return True # non è un errore, è uno skip
|
|
||||||
client.delete_collection(stem)
|
|
||||||
print(f"🗑️ Collection '{stem}' rimossa (--force)")
|
|
||||||
|
|
||||||
collection = client.create_collection(
|
|
||||||
name=stem,
|
|
||||||
metadata={"hnsw:space": "cosine"},
|
|
||||||
)
|
|
||||||
|
|
||||||
total = len(chunks)
|
|
||||||
print(f"📦 {total} chunk da ingestire\n")
|
|
||||||
|
|
||||||
ids = []
|
|
||||||
embeddings = []
|
|
||||||
documents = []
|
|
||||||
metadatas = []
|
|
||||||
|
|
||||||
start = time.monotonic()
|
|
||||||
durations: list[float] = []
|
|
||||||
|
|
||||||
for i, chunk in enumerate(chunks, start=1):
|
|
||||||
t0 = time.monotonic()
|
|
||||||
vector = embed(chunk["text"], model)
|
|
||||||
t1 = time.monotonic()
|
|
||||||
durations.append(t1 - t0)
|
|
||||||
|
|
||||||
ids.append(chunk["chunk_id"])
|
|
||||||
embeddings.append(vector)
|
|
||||||
documents.append(chunk["text"])
|
|
||||||
metadatas.append({
|
|
||||||
"sezione": chunk.get("sezione", ""),
|
|
||||||
"titolo": chunk.get("titolo", ""),
|
|
||||||
"sub_index": chunk.get("sub_index", 0),
|
|
||||||
})
|
|
||||||
|
|
||||||
avg = sum(durations) / len(durations)
|
|
||||||
eta = int(avg * (total - i))
|
|
||||||
done = f"[{i:>{len(str(total))}}/{total}]"
|
|
||||||
cid = chunk["chunk_id"][:50]
|
|
||||||
line = f" {done} ✓ {cid:<50} ETA: {eta}s"
|
|
||||||
print(f"{line:<80}", end="\r", flush=True)
|
|
||||||
|
|
||||||
# Upsert in batch da 100 per non sovraccaricare la memoria
|
|
||||||
if len(ids) == 100:
|
|
||||||
collection.add(
|
|
||||||
ids=ids,
|
|
||||||
embeddings=embeddings,
|
|
||||||
documents=documents,
|
|
||||||
metadatas=metadatas,
|
|
||||||
)
|
|
||||||
ids, embeddings, documents, metadatas = [], [], [], []
|
|
||||||
|
|
||||||
# Upsert dei rimanenti
|
|
||||||
if ids:
|
|
||||||
collection.add(
|
|
||||||
ids=ids,
|
|
||||||
embeddings=embeddings,
|
|
||||||
documents=documents,
|
|
||||||
metadatas=metadatas,
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed = int(time.monotonic() - start)
|
|
||||||
print() # nuova riga dopo il \r
|
|
||||||
print(f"\n✅ Ingestione completata in {elapsed}s — {total}/{total} chunk salvati")
|
|
||||||
print(f" Collection '{stem}' in {CHROMA_DIR}/")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def find_stems() -> list[str]:
|
|
||||||
"""Ritorna tutti gli stem che hanno un chunks.json in step-6/."""
|
|
||||||
return sorted(
|
|
||||||
p.parent.name
|
|
||||||
for p in CHUNKS_DIR.glob("*/chunks.json")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Step 8 — Vettorizzazione chunk in ChromaDB"
|
|
||||||
)
|
|
||||||
parser.add_argument("--stem", help="Nome del documento (senza --stem = tutti)")
|
|
||||||
parser.add_argument("--force", action="store_true",
|
|
||||||
help="Sovrascrive la collection se già esistente")
|
|
||||||
parser.add_argument("--model", default=EMBED_MODEL,
|
|
||||||
help=f"Modello embedding Ollama (default da config.py: {EMBED_MODEL})")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
print("─── Step 8 — Vettorizzazione ─────────────────────────────────────────\n")
|
|
||||||
|
|
||||||
if not check_ollama(args.model):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
stems = [args.stem] if args.stem else find_stems()
|
|
||||||
if not stems:
|
|
||||||
print("❌ Nessun chunks.json trovato in step-6/")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print()
|
|
||||||
results = []
|
|
||||||
for stem in stems:
|
|
||||||
if len(stems) > 1:
|
|
||||||
print(f"── {stem} ──")
|
|
||||||
results.append(ingest(stem, force=args.force, model=args.model))
|
|
||||||
if len(stems) > 1:
|
|
||||||
print()
|
|
||||||
|
|
||||||
return 0 if all(results) else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user