30 Commits

Author SHA1 Message Date
davide 444942dc8f feat: demota #→## quando il documento usa h1 per sezioni principali
Aggiunge _t_demote_h1 in _headers.py: se il documento contiene ≥5
header # con contenuto testuale (lettere iniziali), i # vengono
demotati a ## creando la gerarchia ## (parti) → ### (sezioni)
invece di # → ###.

Utile per manuali strutturati in parti principali (h1) con sezioni
(h3) senza livello intermedio h2. La soglia di 5 evita falsi positivi
su documenti con un solo titolo h1 o h1 da artefatti di encoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:21:02 +02:00
davide 3f4689e8fd feat: rileva note bibliografiche e raccolte multi-articolo in pipeline
Risolve la conversione errata di note a piè di pagina accademiche in
header Markdown nei testi giuridici (es. dirittopubblico: da 424 h2
errati → 27 h2 semanticamente corretti).

- _BIB_MARKERS_RE: aggiunge ibid., cfr., op. cit., cit., ivi
- _FOOTNOTE_AUTHOR_RE: nuovo pattern per "A. COGNOME" (es. G. GUZZETTA)
- _num_repl / _aphorism_repl / _list_section_repl: usano entrambi i
  guard per non convertire note bibliografiche in sezioni
- _t_promote_chapter_headers: usa max-count ≥ 3 per distinguere
  raccolte multi-articolo (non promuovere) da libri con capitoli
  sequenziali (promuovere); preserva il comportamento corretto su anatomia
- _t_remove_page_markers / _t_remove_page_numbers / _t_remove_separators:
  nuove transform per page marker PDF, numeri isolati, separatori underscore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:12:50 +02:00
davide 2c0b7a462e feat: migliora pipeline PDF→MD per RAG — frontmatter e page marker
- extract.py: aggiunge extract_metadata() — title, author, year, pages via fitz
- extract.py: aggiunge markdown_page_separator con <!-- page: N --> tra pagine
- extract.py: aggiunge replace_invalid_chars=" " per testo più pulito
- runner.py: prepend YAML frontmatter (source/title/author/year/pages) al clean.md
- runner.py: mostra title e author rilevati durante validazione

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 14:58:09 +02:00
davide 6e755c0b6c fix(clear.sh): esclude _pipeline/ dal batch e supporta stem singolo
- Aggiunge argomento opzionale <stem>: cancella solo quella cartella
- Esclude dal batch le dir che iniziano con _ o __ (es. _pipeline/)
- Rimuove flag -f non documentato: la modalità singolo stem non chiede conferma

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 14:53:17 +02:00
davide 9598209f12 chore: aggiorna .gitignore — esclude __pycache__ e rimuove riferimento a transforms/
Aggiunge esclusione esplicita di _pipeline/__pycache__/ per compensare
la regola di negazione !conversione/_pipeline/**. Rimuove dall'indice
tutti i file .pyc precedentemente tracciati per errore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 14:44:40 +02:00
davide 64dc403e80 refactor: ottimizza pipeline PDF→Markdown — struttura piatta e verbosità
- Unifica deps.py + checker.py + converter.py in extract.py (fronte PDF)
- Sposta transforms/ in _pipeline/ (struttura piatta, no sottocartelle)
- Aggiunge spinner animato (thread) durante conversione opendataloader-pdf
- Aggiunge progresso step-by-step [i/37] per apply_transforms via callback
- Mostra punteggio qualità (score/100 grade) a fine elaborazione
- Fix: _DOTLEADER_RE spostata in _constants.py (non più definita inline)
- Fix: report.py importa regex da _constants invece di ridefinirle
- Fix: _t_remove_urls ora conta e ritorna le rimozioni effettive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 14:30:41 +02:00
davide afbf29514d Aggiorna CLAUDE.md 2026-05-07 13:51:55 +02:00
davide ab4036591f temp 2026-04-30 15:26:52 +02:00
davide e41fcae248 refactor: modularizza pipeline in conversione/_pipeline/
Sostituisce i file monolitici pipeline.py e validate.py con il package
_pipeline/ a responsabilità separate. Entry point unificato in __main__.py
(convert + validate dallo stesso comando).

Moduli aggiunti:
- __main__.py       — CLI unificata (--stem, --force, validate, --detail)
- _pipeline/__init__.py — re-export pubblico
- _pipeline/checker.py  — validazione PDF
- _pipeline/deps.py     — verifica dipendenze Java + opendataloader
- _pipeline/structure.py — analyze() + strategia chunking

Moduli già committati in precedenza:
- _pipeline/converter.py, transforms.py, report.py, runner.py, validator.py

Aggiornamenti collaterali:
- .gitignore: exception !conversione/_pipeline/** per tracciare il package
- CLAUDE.md: documentazione aggiornata alla nuova architettura; fix riferimenti
  obsoleti a conversione/pipeline.py → conversione/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:59:55 +02:00
davide faa8acae84 feat(pipeline): ottimizzazione completa PDF→Markdown senza revisione manuale
- converter: parametri adattivi (use_struct_tree per PDF taggati, table_method=cluster, content_safety_off)
- transforms: +20 PUA bracket TeX U+F8EB-F8FE (290 simboli corretti su analisi1)
- transforms: _t_math_header_demotion — demota header ##/### che sono enunciati esercizi o formule
- report: metrica formula_headers_residui con esempi
- validator: penalità formula_headers (−3/cad, cap −15), colonna fhdr nel report tabellare

Risultato su analisi1: voto 92/A, PUA residui 0, formula-hdr residui 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:58:15 +02:00
davide a158634378 refactor: riduci repo alla sola fase di conversione PDF → Markdown
Rimossi chunks/, step-8/, ollama/, chroma_db/, rag.py, retrieve.py,
config.py e chromadb da requirements. Aggiornati README e CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:20:00 +02:00
davide fe0ecc24ad feat(chunks): sentence-boundary flush, math incomplete detection, structure profile export
- chunker: estrai _flush_chunk() con estensione al confine di frase (max 120%)
- verify: rileva chunk matematici incompleti come warning, gestisci hash hex e URL
- conversione: esporta structure_profile.json nell'output dir
2026-04-20 12:28:03 +02:00
davide 995a8be735 chore: pulisci .gitignore — rimuovi step-2..6, aggiungi chunks/ 2026-04-20 12:25:04 +02:00
davide c87a7cb3eb refactor: rimuovi step-5/ e step-6/, sostituiti da chunks/ 2026-04-20 12:22:21 +02:00
davide 4c0e0db2a5 feat(chunks): aggiungi pipeline chunking consolidata
Nuova cartella chunks/ con chunker.py (step 5), verify_chunks.py e
fix_chunks.py (step 6). Tutto l'I/O va in chunks/<stem>/ invece di
step-5/ e step-6/ separati. Input: conversione/<stem>/clean.md
2026-04-20 11:36:24 +02:00
davide 5215f53ad0 docs: compatta README — rimuovi sezioni verbose, mantieni l'essenziale 2026-04-20 11:21:01 +02:00
davide 4f28358ec1 feat: pipeline RAG consolidata — conversione unificata, refactor struttura, CLAUDE.md minimale
Branch ollama introduce:
- Pipeline conversione PDF → Markdown unificata (conversione/pipeline.py) con 30+ transform
  che sostituisce i vecchi step-0..step-4
- Ambiente Ollama (ollama/) con check_env.py e test_ollama.py
- Rimozione cartelle step-0..step-4, step-7, step-9 obsolete
- Consolidamento script alla root
- CLAUDE.md riscritto: path reali, istruzioni minime, agnostico alla struttura step-X
- validate.py con scoring orientato a chunking/vettorizzazione
- README riallineato alla struttura reale del repo
2026-04-20 11:06:18 +02:00
davide 6f8785d90a docs(CLAUDE.md): semplifica istruzioni, rimuovi path step-X hardcoded 2026-04-20 11:05:20 +02:00
davide c8167d4f01 fix: aggiorna path step-4/ → conversione/ e riferimenti step-X
- chunker.py: input da conversione/<stem>/ (era step-4/, non esistente)
- verify_chunks.py: messaggi errore aggiornati a conversione/
- config.py: commenti step-8 → ingest.py
2026-04-19 00:03:43 +02:00
davide e4dc0856bb refactor: pulizia files 2026-04-17 18:52:37 +02:00
davide af9ffc0559 docs(README): riscrittura per struttura reale del progetto
Sostituisce la struttura step-0…step-10 con la pipeline
effettiva: conversione/, revisione /prepare-md, chunking,
verifica, ollama/, vettorizzazione, interrogazione
2026-04-17 18:51:15 +02:00
davide e02e3496a3 chore(requirements): rimuovi commenti step-X obsoleti 2026-04-17 18:50:53 +02:00
davide 12effa1a51 refactor: elimina step-7 e step-9, consolida script alla root
- step-9/: config.py, rag.py, retrieve.py → root;
  test_ollama.py → ollama/
- step-7/: eliminata, già coperta da ollama/
- sys.path aggiornati in rag.py, retrieve.py, ingest.py,
  check_env.py (step-7 e ollama)
- Riferimenti step-9/config.py → config.py in tutti i file
2026-04-17 18:50:36 +02:00
davide fc457e8525 feat(ollama): aggiungi step 7 — verifica ambiente Ollama
Script check_env.py e README per controllare prerequisiti prima
della vettorizzazione: ollama nel PATH, modelli embedding e LLM
disponibili, chromadb importabile
2026-04-17 18:16:40 +02:00
davide 610d4db348 feat(conversione): pipeline unificata PDF → Markdown, sostituisce step-0..4
Consolida in un unico modulo (conversione/pipeline.py) l'intera catena di
conversione che prima era distribuita tra step-0 (validazione PDF), step-1
(ispezione qualità), step-2 (conversione pymupdf4llm), step-3 (rilevamento
struttura) e step-4 (revisione euristica).

Miglioramenti principali rispetto agli step separati:
- libreria di conversione XY-Cut++ (ordine di lettura più preciso)
- 30+ trasformazioni euristiche vs le 7 originali di step-4
- validazione, struttura e profilo chunking prodotti in un solo passaggio
- validate.py con scoring orientato a chunking/vettorizzazione
2026-04-17 16:05:20 +02:00
davide 82f205faa2 chore: rimuovi cartelle step-0..step-4 ora obsolete
La logica è consolidata in conversione/pipeline.py.
2026-04-17 16:04:59 +02:00
davide 368530bc25 refactor(docs): skill prepare-md sostituisce step4-review, CLAUDE.md senza step-X 2026-04-17 13:44:41 +02:00
davide cdb2d4cab9 fix(conversione): PUA Symbol, garbage headers, merge+bib guard, math EN 2026-04-17 13:44:30 +02:00
davide ef8f56fdba fix(conversione): 5 fix robustezza e precisione transform
- _t_remove_footnotes: rimuove marcatori superscript inline e righe
  corpo-nota (¹ testo, [N] testo) — nuovo transform in posizione early
- _t_numbered_sections: esclude voci bibliografiche (anno, pp., vol.,
  DOI, ISBN) dalla promozione a ### header
- _t_remove_toc: intercetta voci con numero pagina finale nel contesto
  TOC — rimosso _t_remove_toc_page_list standalone
- _t_remove_frontmatter: limitata alle prime ~20% sezioni del documento
- _t_remove_recurring_lines: soglia 3->5, Counter spostato a top-level
2026-04-17 12:06:25 +02:00
davide 0a8d98279c feat(conversione): robustezza e 7 nuovi transform
- check_pdf: file < 1KB, campione esteso 15pp, MemoryError
- convert_pdf: validazione output ≥ 100 char
- analyze: rilevamento gerarchia invertita h3 > h2
- _detect_language: supporto FR/DE/ES
- 7 nuovi transform: fix_math_symbols, remove_recurring_lines,
  normalize_numbered_headings, remove_toc_page_list,
  restore_poetry_lines, demote_verse_headers, remove_watermarks
- bug fix: tabelle MD, garbage headers lowercase, empty headers
- run(): MemoryError / UnicodeDecodeError / PermissionError
2026-04-17 11:53:42 +02:00
42 changed files with 2542 additions and 5968 deletions
+199
View File
@@ -0,0 +1,199 @@
---
description: Legge un file Markdown, individua tutti i problemi che compromettono il chunking (artefatti, sillabazione, header malformati, paragrafi spezzati, gerarchia incoerente, sezioni vuote) e applica le correzioni direttamente sul file senza chiedere conferma per i casi chiari.
allowed-tools: Read Bash Grep Edit
argument-hint: <path/to/clean.md oppure stem>
---
Risolvi il percorso del file da preparare:
!`python3 -c "
import sys, json, re
from pathlib import Path
arg = '$ARGUMENTS'.strip()
root = Path('.')
candidates = [
Path(arg),
root / arg,
root / 'conversione' / arg / 'clean.md',
root / 'step-4' / arg / 'clean.md',
]
md_path = None
for p in candidates:
if p.exists() and p.suffix == '.md':
md_path = p
break
if not md_path:
print('ERRORE: file non trovato per:', arg)
sys.exit(1)
print('MD_PATH=' + str(md_path))
# Cerca profilo strutturale (report.json o structure_profile.json)
stem = md_path.parent.name
profile_candidates = [
md_path.parent / 'report.json',
md_path.parent / 'structure_profile.json',
root / 'step-4' / stem / 'structure_profile.json',
root / 'conversione' / stem / 'report.json',
]
for sp in profile_candidates:
if sp.exists():
try:
d = json.load(open(sp))
st = d.get('structure', d)
print(f'STRATEGIA={st.get(\"strategia_chunking\",\"?\")}')
print(f'LINGUA={st.get(\"lingua_rilevata\",\"?\")}')
print(f'H1={st.get(\"n_h1\",0)} H2={st.get(\"n_h2\",0)} H3={st.get(\"n_h3\",0)}')
for a in st.get('avvertenze', []):
print(f'AVVISO: {a}')
except Exception:
pass
break
# Statistiche file
text = md_path.read_text(encoding='utf-8')
lines = text.split('\n')
pua = len(re.findall(r'[\ue000-\uf8ff]', text))
print(f'RIGHE={len(lines)} CHARS={len(text)}')
if pua:
print(f'PUA_RESIDUI={pua}')
" 2>/dev/null`
Se l'output contiene `ERRORE`, comunica il percorso non trovato e fermati.
---
Leggi il file completo identificato da `MD_PATH` nell'output sopra. Poi esegui **tutti** i controlli e applica le correzioni nell'ordine indicato.
I parametri di riferimento per il chunking sono: **MIN_CHARS=200, MAX_CHARS=800**.
---
## Controllo 1 — Sillabazione residua
Cerca blocchi di testo (non header) dove una riga termina con `-` e la successiva inizia con lettera minuscola: è un'interruzione di parola non risolta da PDF.
Esempio da correggere:
```
...il meccanismo di decen-
tralizzazione permette...
```
`...il meccanismo di decentralizzazione permette...`
**Applica** ogni fusione con Edit. Se la parola ricomposta sembra errata, segnala invece di correggere.
---
## Controllo 2 — Artefatti di pagina
Righe standalone che sono esclusivamente:
- Un numero intero isolato (numero di pagina)
- Titolo del libro / nome autore che si ripete identico 3+ volte nel documento
- Intestazioni di capitolo che si ripetono (es. `## 3. Termodinamica` appare sia come header legittimo che come riga di testo duplicata)
**Applica** la rimozione con Edit per le ripetizioni chiaramente decorative. Segnala i casi ambigui.
---
## Controllo 3 — Numeri di pagina in header
Header che terminano con ` | N` o ` N` dove N è un numero isolato (residuo di indice non rimosso):
- `### 16. Link vari | 109``### 16. Link vari`
- `## Capitolo 3 42``## Capitolo 3`
**Applica** con Edit.
---
## Controllo 4 — Header malformati
Per ogni header (`#`, `##`, `###`):
**a) ALL-CAPS non convertito:**
`## TERMODINAMICA DEI PROCESSI``## Termodinamica dei processi`
Usa sentence case (prima lettera maiuscola, resto minuscolo salvo nomi propri evidenti).
**Applica**.
**b) Livello h4/h5/h6:**
`#### Sottosezione``### Sottosezione`
**Applica**.
**c) Testo troppo lungo (> 120 char):**
Probabilmente non è un header ma testo estratto erroneamente. Rimuovi i `#` iniziali lasciando il testo come paragrafo normale.
**Applica** se chiaramente non è un titolo. Segnala se ambiguo.
**d) Header duplicati:**
Se lo stesso header appare due volte, rimuovi la seconda occorrenza (o la prima se è quella fuori contesto).
**Applica**.
---
## Controllo 5 — Paragrafi spezzati
Blocchi di testo (non header, non liste) che terminano senza punteggiatura finale (`.?!»)`).
Se il blocco successivo non inizia con lettera maiuscola e non è un header/lista, i due blocchi sono parte della stessa frase spezzata da un salto pagina PDF.
**Applica** la fusione solo quando sei certo (la congiunzione è evidente: inizia con congiunzione, continua la frase in modo inequivocabile). Segnala i casi dubbi invece di correggere.
---
## Controllo 6 — Sezioni quasi-vuote o vuote
Sezione (header + corpo) con corpo < 100 caratteri:
- Se il contenuto è evidentemente una sottosezione o introduzione di ciò che segue (e non ha senso da solo), rimuovi l'header e unisci il testo alla sezione precedente o successiva.
- Se è un header di capitolo che introduce legittime sottosezioni (`##` seguito da `###`), lascia invariato.
**Applica** le fusioni sicure. Segnala quelle ambigue.
---
## Controllo 7 — Gerarchia heading
Verifica che la gerarchia sia coerente. Problemi da correggere:
- Più di un `# ` (h1) nel documento → il secondo e successivi diventano `## ` salvo che siano chiaramente titoli di parti distinte
- `### ` prima del primo `## ` → abbassa il `###` a `## ` o aggiungi un `## ` genitore appropriato
- `## ` prima del primo `# ` in documenti con h1 → lascia invariato (alcuni documenti non hanno h1)
**Applica** solo le correzioni di livello sicure. Segnala le ristrutturazioni che richiedono giudizio.
---
## Controllo 8 — Sezioni troppo lunghe senza struttura
Sezione (## o ###) con corpo > 3000 caratteri e nessun header figlio al suo interno: il chunker la spezzerà su frasi in modo meccanico, perdendo coerenza semantica.
Se il testo contiene chiari cambio-argomento (paragrafi separati da riga vuota, con transizioni come "Inoltre...", "In secondo luogo...", "Un altro aspetto..."), considera di aggiungere un `### ` per suddividere semanticamente.
**Non aggiungere header inventati.** Segnala le sezioni candidate e proponi i titoli: applica solo su risposta affermativa.
---
## Report finale
Dopo aver applicato tutte le correzioni automatiche, mostra:
```
File: <path>
Correzioni applicate: N totali
Sillabazione risolta: N
Artefatti pagina rimossi: N
Numeri pagina in header: N
Header normalizzati: N (ALL-CAPS, livello, lunghezza, duplicati)
Paragrafi fusi: N
Sezioni quasi-vuote risolte:N
Gerarchia corretta: N
Problemi aperti (richiedono giudizio manuale):
[riga N] <descrizione precisa>
...
```
Se non ci sono problemi aperti: **"Markdown pronto per il chunking."**
Se ci sono problemi aperti: elencali e chiedi quali applicare.
-115
View File
@@ -1,115 +0,0 @@
---
description: Revisione qualitativa del clean.md dopo il pre-processing automatico (step 4). Trova artefatti residui, paragrafi spezzati e header errati, poi propone le correzioni.
allowed-tools: Read Bash Grep Edit
argument-hint: <stem>
---
Esegui la revisione qualitativa di `step-4/$ARGUMENTS/clean.md`.
**Cosa è già stato fatto automaticamente (revision_log):**
!`grep -A 12 "^## $ARGUMENTS" step-4/revision_log.md 2>/dev/null || echo "(nessun log trovato per questo stem)"`
**Profilo strutturale attuale:**
!`python3 -c "
import json, sys
try:
d = json.load(open('step-4/$ARGUMENTS/structure_profile.json'))
print(f'Livello: {d[\"livello_struttura\"]} Strategia: {d[\"strategia_chunking\"]}')
print(f'h1={d[\"n_h1\"]} h2={d[\"n_h2\"]} h3={d[\"n_h3\"]} paragrafi={d[\"n_paragrafi\"]}')
print(f'Lunghezza media sezione: {d[\"lunghezza_media_sezione\"]} char')
for a in d.get('avvertenze', []): print(f' ⚠️ {a}')
except Exception as e: print(f'ERRORE: {e}')
" 2>/dev/null`
---
Analizza `step-4/$ARGUMENTS/clean.md` eseguendo i grep seguenti e ragionando sui risultati. Per ogni check: esegui il grep, conta i risultati, riporta i casi concreti (max 5 esempi con numero di riga).
## Check 1 — Sillabazione residua
Righe che terminano con trattino seguito da testo nella riga successiva (artefatto PDF non risolto):
```bash
grep -n "\-$" step-4/$ARGUMENTS/clean.md | head -20
```
Segnala se presenti: numero di riga, testo della riga e della riga successiva.
## Check 2 — Righe orfane (artefatti PDF)
Righe standalone (non header `#`, non vuote) di meno di 60 caratteri che sembrano artefatti:
```bash
grep -n "^[^#\-\*\|].\{1,59\}$" step-4/$ARGUMENTS/clean.md | grep -v "^\s*$" | head -30
```
Valuta ogni riga: è testo normale breve (legittimo) o artefatto (numero di pagina, nome autore isolato, riga di intestazione ripetuta)?
## Check 3 — Paragrafi con frase spezzata
Blocchi di testo che terminano senza punteggiatura di fine frase (`.?!»)`):
```bash
grep -n "[^.!?»)\]\'\"]$" step-4/$ARGUMENTS/clean.md | grep -v "^[0-9]*:#" | grep -v "^[0-9]*:\s*$" | grep -v "^\s*[-\*]" | head -20
```
Riporta i casi più sospetti (righe brevi che finiscono a metà concetto).
## Check 4 — Header sospetti
```bash
grep -n "^##\? " step-4/$ARGUMENTS/clean.md | head -40
```
Verifica:
- `##` o `###` con contenuto interamente MAIUSCOLO non convertito → segnala
- Header duplicati (stesso testo che appare due volte) → segnala
- `##` con testo > 80 caratteri (probabile testo che non è un header) → segnala
- Salti di livello anomali (es. `###` senza un `##` padre) → segnala
## Check 5 — Sezioni quasi vuote
```bash
python3 -c "
import re, sys
text = open('step-4/$ARGUMENTS/clean.md').read()
sections = re.split(r'^(#{1,3} .+)$', text, flags=re.MULTILINE)
for i in range(1, len(sections)-1, 2):
header = sections[i].strip()
body = sections[i+1].strip() if i+1 < len(sections) else ''
if len(body) < 80 and body:
print(f'{header!r} → {len(body)} char: {body[:60]!r}')
elif not body:
print(f'{header!r} → VUOTA')
" 2>/dev/null | head -20
```
Sezioni con body < 80 char o vuote compromettono il chunking. Segnala quelle che non hanno senso come sezione autonoma.
## Check 6 — Gerarchia strutturale
```bash
grep -n "^#\{1,3\} " step-4/$ARGUMENTS/clean.md | head -50
```
Verifica che la gerarchia sia coerente: `# → ## → ###`. Segnala se ci sono `###` prima del primo `##`, o `##` prima del primo `#`, o `#` multipli (più di un h1).
---
## Report finale
```
🔴 BLOCCANTI (compromettono il chunking o il retrieval)
[riga N] descrizione precisa del problema
...
🟡 MINORI (artefatti visibili, non bloccanti)
[riga N] descrizione
...
🟢 OK — nessun problema rilevato in questa categoria
```
Poi chiedi: **"Applico le correzioni per i 🔴? E per i 🟡?"**
Applica solo ciò che viene esplicitamente approvato. Usa Edit per ogni modifica — mai riscrivere l'intero file.
+7 -20
View File
@@ -26,26 +26,13 @@ __pycache__/
.DS_Store
Thumbs.db
# Report generati dagli script
step-0/*_step0_report.txt
step-1/*_step1_report.txt
# Output step-2 — MD grezzo generato da marker
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
# Output conversione/ — generati dagli script
conversione/*/
!conversione/_pipeline/
!conversione/_pipeline/**
conversione/_pipeline/__pycache__/
# Output chunks/ — generati da chunks/chunker.py e chunks/verify_chunks.py
chunks/*/
+142 -61
View File
@@ -1,86 +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
- **Lingua:** Rispondi sempre in italiano.
- **Venv obbligatorio:** Usa `.venv/bin/python` o attiva con `source .venv/bin/activate`. Mai `pip`/`python` di sistema.
- **Non modificare `raw.md`:** `step-2/<stem>/raw.md` è immutabile. La copia di lavoro è `step-4/<stem>/clean.md`.
- **Venv:** Usa `.venv/bin/python` o `source .venv/bin/activate`. Mai `pip`/`python` di sistema.
- **`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 (ordine obbligatorio)
## Setup
```
PDF (sources/) → step-0 → step-1 → step-2 → step-3
→ step-4 (CRITICO: revisione manuale clean.md)
→ step-5 → step-6 → step-7 (Ollama) → step-8 → step-9
```
Il parametro `--stem` identifica il documento (nome PDF senza `.pdf`). Lo stem è anche il nome della collection ChromaDB.
Comandi tipici:
```bash
python -m venv .venv
source .venv/bin/activate
python step-4/revise.py --stem <stem>
python step-5/chunker.py --stem <stem>
python step-6/verify_chunks.py --stem <stem>
python step-8/ingest.py --stem <stem>
python step-9/rag.py --stem <stem>
pip install -r requirements.txt
# Java 11+ richiesto da opendataloader-pdf
sudo apt install default-jdk # Ubuntu/Debian/WSL
java -version
```
---
## File critici
## Comandi
| File | Ruolo |
|---|---|
| `step-9/config.py` | Fonte di verità: `EMBED_MODEL`, `OLLAMA_MODEL`, `TOP_K`, `TEMPERATURE`, `SYSTEM_PROMPT` |
| `step-5/chunker.py` | Chunking adattivo — `MIN_CHARS=200`, `MAX_CHARS=800`, `OVERLAP_S=2` |
| `step-6/verify_chunks.py` | Verifica chunk — stesse soglie di `chunker.py` |
| `step-6/fix_chunks.py` | Fix automatici su chunk anomali |
| `step-4/revise.py` | Pre-processing MD automatico (8 trasformazioni euristiche) |
| `step-8/ingest.py` | Vettorizzazione ChromaDB — legge `EMBED_MODEL` da `config.py` |
| `step-9/rag.py` | Pipeline RAG interattiva |
---
## Regole di assistenza
**Modifica `EMBED_MODEL` in `step-9/config.py`:**
Avvisa sempre che serve rieseguire la vettorizzazione:
```bash
python step-8/ingest.py --stem <stem> --force
# 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>
```
`ingest.py` importa `EMBED_MODEL` direttamente da `config.py` — la coerenza è critica: se violata non produce errori ma restituisce risultati insensati.
**Modifica soglie chunking (`MIN_CHARS`, `MAX_CHARS`, `OVERLAP_S`):**
I valori compaiono in tre file che vanno sincronizzati manualmente:
1. `step-5/chunker.py`
2. `step-6/verify_chunks.py`
3. `step-6/fix_chunks.py`
`--stem` = nome file PDF senza estensione.
**Step 4 — revisione clean.md:**
`revise.py` applica trasformazioni automatiche, ma il risultato va sempre revisionato a mano. La qualità del RAG dipende da `clean.md` più di qualsiasi parametro tecnico. Suggerisci sempre `/step4-review <stem>` dopo `revise.py`.
---
**Step 6 — verifica chunk:**
Dopo `verify_chunks.py`, usa `/step6-fix <stem>` prima di passare a step-8.
## Architettura
Il codice è organizzato in `conversione/__main__.py` (entry point) e il package `conversione/_pipeline/` (logica modulare).
```
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.)
```
### `__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 0100 (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 |
---
## Cosa rende un Markdown perfetto per la vettorizzazione
- **Struttura semantica:** header Markdown = confini naturali dei chunk; ogni sezione è un'unità concettuale.
- **Testo pulito:** nessun backtick, dot-leader, footnote superscript, carattere PUA, `<br>`.
- **Paragrafi interi:** nessuna frase troncata da salto pagina PDF.
- **Formule e simboli:** lettere greche e operatori in Unicode standard, non in font-encoding privato.
- **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
- `/step4-review <stem>` — Revisione qualitativa `clean.md`: artefatti, paragrafi spezzati, header errati.
- `/step6-fix <stem>` — Dry-run e applicazione fix chunk tramite `fix_chunks.py`.
---
## Struttura directory per stem
```
step-2/<stem>/raw.md ← immutabile
step-4/<stem>/clean.md ← copia di lavoro
step-4/<stem>/structure_profile.json
step-5/<stem>/chunks.json
step-6/<stem>/report.json
chroma_db/<stem>/ ← collection ChromaDB
```
- `/prepare-md <path|stem>` — corregge `clean.md` quando la pipeline non basta: sillabazione, artefatti residui, header malformati, gerarchia incoerente.
+40 -757
View File
@@ -1,785 +1,68 @@
# RAG from Scratch — Singolo PDF Generico
# PDF → Markdown
Sistema RAG (Retrieval-Augmented Generation) costruito da zero, senza framework di alto livello.
Funziona su qualsiasi PDF digitale. Gira interamente in locale, senza GPU, senza cloud.
Converte PDF digitali in Markdown strutturato e pulito.
**Stack:** Python · Ollama · nomic-embed-text · Qwen3.5 · ChromaDB
**Compatibile con:** Linux · macOS · Windows (WSL2) · CPU Only · ~8 GB RAM libera
**Stack:** Python · opendataloader-pdf (XY-Cut++) · Java 11+
**Compatibile con:** Linux · macOS · Windows (WSL2)
---
## Indice
- [Panoramica](#panoramica)
- [Struttura del progetto](#struttura-del-progetto)
- [Gli step](#gli-step)
- [Step 0 — Scegli il PDF](#step-0--scegli-il-pdf)
- [Step 1 — Ispezione automatica](#step-1--ispezione-automatica)
- [Step 2 — Conversione in Markdown grezzo](#step-2--conversione-in-markdown-grezzo)
- [Step 3 — Rilevamento struttura](#step-3--rilevamento-struttura)
- [Step 4 — Revisione manuale](#step-4--revisione-manuale)
- [Step 5 — Chunking adattivo](#step-5--chunking-adattivo)
- [Step 6 — Verifica chunk](#step-6--verifica-chunk)
- [Step 7 — Installazione ambiente](#step-7--installazione-ambiente)
- [Step 8 — Vettorizzazione](#step-8--vettorizzazione)
- [Step 9 — Pipeline RAG](#step-9--pipeline-rag)
- [Step 10 — Test automatici](#step-10--test-automatici)
- [Principi di progettazione](#principi-di-progettazione)
---
## Panoramica
```
PDF
└─► STEP 1 Ispezione automatica
└─► STEP 2 Conversione in Markdown grezzo
└─► STEP 3 Rilevamento struttura
└─► STEP 4 Revisione manuale ← step più importante
└─► STEP 5 Chunking adattivo
└─► STEP 6 Verifica chunk
└─► STEP 8 Vettorizzazione
└─► STEP 9 Pipeline RAG
└─► STEP 10 Test automatici
STEP 0 Prerequisito iniziale (PDF adatto)
STEP 7 Prerequisito tecnico (ambiente locale)
```
### Dove si concentra il rischio
| Step | Rischio | Motivo |
|---|---|---|
| Step 0 | 🔴 Alto | Un PDF inadatto invalida tutto il lavoro successivo |
| Step 1 | 🟢 Basso | Automatico, solo osservazione |
| Step 2 | 🟢 Basso | Automatico, tool maturo |
| Step 3 | 🟢 Basso | Automatico, solo analisi |
| Step 4 | 🔴 Alto | Manuale — la qualità del MD determina la qualità del RAG |
| Step 5 | 🟡 Medio | Logica adattiva, dipende dalla qualità del MD |
| Step 6 | 🟢 Basso | Automatico, solo verifica |
| Step 7 | 🟢 Basso | Installazione standard |
| Step 8 | 🟢 Basso | Meccanico, lento ma affidabile |
| Step 9 | 🟡 Medio | Qualità del prompt |
| Step 10 | 🟢 Basso | Test automatici |
---
## Struttura del progetto
```
rag-from-scratch/
├── sources/ # PDF originali — non modificare mai
│ └── documento.pdf
├── step-0/
│ └── check_pdf.py # Verifica requisiti del PDF
├── step-1/
│ └── inspect_pdf.py # Ispezione automatica del PDF
├── step-2/
│ ├── convert_pdf.py # Conversione PDF → Markdown grezzo
│ └── <stem>/
│ └── raw.md # MD grezzo (non toccare)
├── step-3/
│ ├── detect_structure.py # Rilevamento struttura MD
│ └── <stem>/
│ └── structure_profile.json # Profilo struttura
├── step-4/
│ ├── revise.py # Pre-processing automatico MD
│ ├── revision_log.md # Log modifiche manuali
│ └── <stem>/
│ ├── clean.md # MD revisionato
│ └── structure_profile.json # Profilo aggiornato
├── step-5/
│ ├── chunker.py # Chunking adattivo
│ └── <stem>/
│ └── chunks.json # Chunk pronti per la vettorizzazione
├── step-6/
│ ├── verify_chunks.py # Verifica chunk
│ ├── fix_chunks.py # Fix chunk problematici
│ └── <stem>/
│ └── chunks.json # Chunk verificati
├── step-7/
│ ├── check_env.py # Verifica ambiente locale
│ └── README.md # Guida installazione Ollama e dipendenze
├── step-8/
│ └── ingest.py # Vettorizzazione → ChromaDB
├── step-9/
│ ├── config.py # Configurazione pipeline RAG ← modifica qui
│ ├── rag.py # Pipeline RAG interattiva
│ ├── test_ollama.py # Test chat Ollama senza RAG
│ └── README.md
├── chroma_db/ # Vector store — generato da step-8
├── requirements.txt
├── .gitignore
└── README.md
```
---
## Gli step
---
### Step 0 — Scegli il PDF
**Tipo:** prerequisito manuale
**Input:** nessuno
**Output:** un PDF adatto al sistema
Il PDF deve soddisfare requisiti minimi prima di qualsiasi elaborazione.
Un PDF inadatto rende tutto il lavoro successivo inutile.
**Criteri obbligatori:**
- Il testo è selezionabile nel PDF reader — se non riesci a copiare una parola,
pdfplumber non la leggerà
- Non è protetto da password
- È generato digitalmente, non scansionato — una foto di un libro non è un PDF di testo
- Il contenuto importante è nel testo, non nelle immagini
**Criteri desiderabili:**
- Ha una struttura logica riconoscibile: capitoli, sezioni, paragrafi
- Le sezioni hanno titoli espliciti
- Non ha layout a colonne multiple
- È in una lingua sola o prevalentemente una
**Come verificarlo:**
Apri il PDF nel tuo reader, seleziona del testo da pagine diverse e copialo.
Se il testo copiato è leggibile e nell'ordine giusto, il PDF è adatto.
Se ottieni caratteri strani o testo nell'ordine sbagliato, il PDF ha problemi.
---
### Step 1 — Ispezione automatica
**Tipo:** automatico
**Input:** tutti i PDF in `sources/`
**Output:** `step-1/<stem>_step1_report.txt`
**Script:** `step-1/inspect_pdf.py`
```bash
python step-1/inspect_pdf.py
```
Lo script scansiona automaticamente tutti i PDF in `sources/`, analizza ogni documento pagina per pagina e produce un report.
Serve per capire la qualità del documento e mappare i problemi
prima di affrontare la revisione manuale.
**Cosa rileva:**
- Testo non estraibile (pagine con sole immagini)
- Sillabazioni a fine riga
- Layout a colonne (righe molto corte e numerose)
- Intestazioni e piè di pagina ripetitivi
- Caratteri Unicode anomali
- Pagine vuote
**Output del report:**
```
Score: 87/100
Pagine totali: 243
Pagine con problemi: 12
Pagina 14: sillabazione rilevata (3 occorrenze)
Pagina 67: possibile layout a colonne
Pagina 201: caratteri Unicode anomali
PROSSIMI PASSI:
→ conversione con marker funzionerà bene
→ attenzione alle pagine 14 e 67 nella revisione manuale
```
**Decisione:**
| Score | Azione |
|---|---|
| ≥ 70 | Procedi allo step 2 |
| 4070 | Procedi con cautela, revisione estesa necessaria |
| < 40 | Valuta una fonte PDF migliore |
---
### Step 2 — Conversione in Markdown grezzo
**Tipo:** automatico
**Input:** tutti i PDF in `sources/` (o uno solo con `--pdf`)
**Output:** `step-2/<stem>/raw.md` + `step-2/<stem>/clean.md`
**Script:** `step-2/convert_pdf.py`
```bash
python step-2/convert_pdf.py # tutti i PDF in sources/
python step-2/convert_pdf.py --pdf sources/doc.pdf # un solo PDF
```
Converte il PDF in Markdown usando `pymupdf4llm`. Il risultato non è perfetto — è la base
su cui lavorerai nello step 4.
Lo script crea due file:
- `raw.md` — conversione grezza, **non modificare mai**. È il punto di partenza di riferimento.
- `clean.md` — copia di lavoro che verrà modificata negli step successivi.
**Cosa produce la conversione:**
- Titoli riconosciuti e convertiti in `#` `##` `###`
- Paragrafi separati da righe vuote
- Sillabazione parzialmente risolta
**Cosa non produce:**
- Rimozione intestazioni e piè di pagina
- Correzione completa del layout a colonne
- Descrizione del contenuto delle immagini
---
### Step 3 — Rilevamento struttura
**Tipo:** automatico
**Input:** `step-2/<stem>/`
**Output:** `step-3/<stem>/structure_profile.json`
**Script:** `step-3/detect_structure.py`
```bash
python step-3/detect_structure.py # tutti i documenti in step-2/
python step-3/detect_structure.py --stem <nome> # un solo documento
python step-3/detect_structure.py --force # riesegui anche se già presente
```
Copia `raw.md` e `clean.md` da `step-2/<stem>/` e analizza la struttura del Markdown senza modificarla.
Il profilo prodotto guida sia la revisione manuale che il chunker.
**I quattro livelli strutturali:**
```
Livello 3 — struttura ricca
Il documento ha ### con regolarità.
Ogni ### è un'unità semantica chiara.
Esempi: opere filosofiche, manuali tecnici, leggi.
Strategia chunking: boundary su ###
Livello 2 — struttura parziale
Il documento ha ## ma pochi o nessun ###.
Le sezioni sono i capitoli, non le sottosezioni.
Esempi: articoli scientifici, report, saggi.
Strategia chunking: boundary su ##, split interno su paragrafi
Livello 1 — solo paragrafi
Il documento non ha titoli significativi.
La struttura è data dalle righe vuote.
Esempi: testi narrativi, lettere, trascrizioni.
Strategia chunking: boundary su paragrafo
Livello 0 — testo piatto
Un blocco continuo senza struttura riconoscibile.
Esempi: PDF mal convertiti, testi antichi.
Strategia chunking: sliding window su frasi
```
**Profilo prodotto:**
```json
{
"livello_struttura": 3,
"n_h1": 1,
"n_h2": 9,
"n_h3": 296,
"n_paragrafi": 312,
"boundary_primario": "h3",
"lingua_rilevata": "it",
"lunghezza_media_sezione": 420,
"strategia_chunking": "h3_aware",
"avvertenze": [
"14 sezioni sotto i 200 caratteri — verranno accorpate",
"8 sezioni sopra i 800 caratteri — verranno divise"
]
}
```
---
### Step 4 — Revisione manuale
**Tipo:** manuale (con pre-processing automatico)
**Input:** `step-3/<stem>/clean.md` + `step-3/<stem>/structure_profile.json`
**Output:** `step-4/<stem>/clean.md` — MD revisionato
**Script:** `step-4/revise.py`
> Questo è lo step più importante dell'intera pipeline.
> La qualità del RAG dipende da questo step più di qualsiasi
> parametro tecnico o scelta di modello.
#### Pre-processing automatico
Prima di qualsiasi revisione manuale, esegui lo script di revisione automatica:
```bash
python step-4/revise.py --stem documento
```
Lo script applica le seguenti trasformazioni euristiche, valide per qualsiasi documento:
| Trasformazione | Descrizione |
|---|---|
| Rimozione TOC | Righe che iniziano con `INDICE`, `INDEX`, `CONTENTS`, ecc. |
| ALL-CAPS → `##` | Righe standalone in maiuscolo convertite in header section-case |
| `N. testo``### N.` | Sezioni numerate (con 1+ spazio dopo il punto) convertite in h3 |
| Unione paragrafi | Blocchi spezzati da salti pagina PDF uniti automaticamente |
| Whitespace | Spazi multipli normalizzati, righe vuote ridotte |
Il profilo strutturale aggiornato viene salvato in `step-4/<stem>/structure_profile.json`.
#### Revisione assistita da Claude Code
Dopo il pre-processing, usa la skill integrata per una revisione qualitativa:
```
/step4-review documento
```
La skill analizza `step-4/<stem>/clean.md` e produce un report strutturato:
```
🔴 BLOCCANTI — problemi che compromettono il chunking
🟡 MINORI — artefatti visibili ma non bloccanti
🟢 OK — categorie senza problemi
```
Poi propone le correzioni e le applica solo su tua approvazione.
#### Revisione manuale (senza Claude Code)
Se non usi Claude Code, esegui questi 6 check dal terminale.
In tutti i comandi sostituisci `<stem>` con il nome reale del documento.
**Check 1 — Sillabazione residua**
Parole spezzate a fine riga con trattino (artefatto da PDF non risolto):
```bash
grep -n "\-$" step-4/<stem>/clean.md | head -20
```
Se trovi risultati: unisci la riga con la successiva eliminando il trattino
e il ritorno a capo.
**Check 2 — Righe orfane**
Righe brevi (<60 char) isolate che sembrano numeri di pagina, autori, intestazioni:
```bash
grep -n "^[^#\-\*\|].\{1,59\}$" step-4/<stem>/clean.md | grep -v "^\s*$" | head -30
```
Per ogni riga: valuta se è testo legittimo (frase breve) o artefatto
(numero di pagina, nome autore ripetuto, intestazione PDF). Gli artefatti vanno eliminati.
**Check 3 — Frasi spezzate**
Paragrafi che terminano senza punteggiatura di fine frase:
```bash
grep -n "[^.!?»)\]\'\"]$" step-4/<stem>/clean.md \
| grep -v "^[0-9]*:#" \
| grep -v "^[0-9]*:\s*$" \
| grep -v "^\s*[-\*]" \
| head -20
```
Segnala le righe brevi che finiscono a metà concetto. Uniscile alla riga successiva.
**Check 4 — Header sospetti**
```bash
grep -n "^##\? " step-4/<stem>/clean.md | head -40
```
Verifica:
- Header con testo >80 caratteri → probabilmente è testo normale, non un header
- Header in MAIUSCOLO non convertito → cambia in formato sentence-case
- Header duplicati (stesso testo due volte) → valuta se unire o rinominare
- `###` senza un `##` padre → salto di gerarchia anomalo
**Check 5 — Sezioni quasi vuote**
```bash
python3 -c "
import re
text = open('step-4/<stem>/clean.md').read()
sections = re.split(r'^(#{1,3} .+)$', text, flags=re.MULTILINE)
for i in range(1, len(sections)-1, 2):
header = sections[i].strip()
body = sections[i+1].strip() if i+1 < len(sections) else ''
if not body:
print(f'VUOTA: {header!r}')
elif len(body) < 80:
print(f'CORTA ({len(body)} char): {header!r} → {body[:60]!r}')
"
```
Le sezioni vuote generano chunk inutili. Eliminale o accorpale alla sezione precedente.
**Check 6 — Gerarchia strutturale**
```bash
grep -n "^#\{1,3\} " step-4/<stem>/clean.md | head -50
```
Deve esserci un solo `# h1` all'inizio. Poi `## h2` e opzionalmente `### h3`.
Segnala `###` prima del primo `##`, o più di un `#`.
---
**Struttura target dopo la revisione:**
```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.
---
### Step 5 — Chunking adattivo
**Tipo:** automatico
**Input:** `step-4/<stem>/clean.md` + `step-4/<stem>/structure_profile.json`
**Output:** `step-5/<stem>/chunks.json`
**Script:** `step-5/chunker.py`
```bash
python step-5/chunker.py --stem documento
```
Divide il Markdown pulito in chunk. Usa il profilo strutturale
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:**
| 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.
---
### Step 6 — Verifica e fix 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 step si articola in un ciclo: verifica → fix automatico → ri-verifica. Non si va allo step 8 finché non ci sono 🔴.
**Workflow completo:**
```
1. Verifica
python step-6/verify_chunks.py --stem documento
2a. Se ✅ OK o solo 🟡 → vai allo step 8
2b. Se ci sono 🔴 → prova il fix automatico:
python step-6/fix_chunks.py --stem documento --dry-run # anteprima
python step-6/fix_chunks.py --stem documento # applica
3. Ri-verifica dopo il fix:
python step-6/verify_chunks.py --stem documento
4. Se rimangono 🔴 → torna allo step 4 e correggi clean.md,
poi riesegui dall'inizio:
python step-5/chunker.py --stem documento --force
python step-6/verify_chunks.py --stem documento
```
> **Shortcut con Claude:** usa `/step6-fix <stem>` — esegue dry-run, spiega le operazioni, chiede conferma e ri-verifica automaticamente.
#### Senza Claude Code — come leggere l'output e decidere
**1. Leggi l'output di `verify_chunks.py`**
L'output termina con una delle tre condizioni:
| Condizione | Significato | Cosa fare |
|---|---|---|
| `✅ N/N documenti senza problemi` | Nessun problema | Vai allo step 8 |
| `🟡 Solo avvisi minori` | Chunk corti o lunghi, non bloccanti | Puoi andare allo step 8 oppure ottimizzare con `fix_chunks.py` |
| `⚠️ 0/N documenti senza problemi` + 🔴 | Frasi spezzate o chunk vuoti | Esegui `fix_chunks.py`, poi ri-verifica |
**2. Prima di applicare il fix: leggi il dry-run**
```bash
python step-6/fix_chunks.py --stem <stem> --dry-run
```
L'output elenca le operazioni pianificate. Significato:
| Operazione | Cosa fa | Sicurezza |
|---|---|---|
| `fondi N chunk incompleti` | Unisce il chunk troncato col successivo | Sempre sicura |
| `fondi N chunk troppo corti` | Unisce chunk <200 char col successivo | Sicura se il risultato non supera MAX×1.5 |
| `spezza N chunk troppo lunghi` | Divide chunk >1200 char su frasi | Sicura solo se esistono frasi naturali dove spezzare |
| `rimuovi N chunk vuoti` | Elimina chunk senza testo | Sempre sicura |
**3. Se i 🔴 persistono dopo il fix**
`fix_chunks.py` non riesce ad autocorreggersi quando il problema
è nella struttura del testo sorgente. I casi tipici e la soluzione in `clean.md`:
| Sintomo nel report | Causa in `clean.md` | Correzione |
|---|---|---|
| Chunk finisce con `:` | Intro di un elenco separata dall'elenco da una riga vuota | Rimuovi la riga vuota tra l'intro e la lista |
| Chunk finisce a metà parola | Salto di pagina PDF con numero di pagina nel mezzo | Trova e rimuovi il numero di pagina, unisci le righe |
| Chunk con testo artefatto (URL, watermark) | Artefatto non rimosso allo step 4 | Elimina la sezione in `clean.md` |
| Chunk con frase enorme non spezzabile | Singolo paragrafo >MAX_CHARS senza frasi intermedie | Spezza manualmente il paragrafo su frasi logiche |
Dopo ogni correzione in `clean.md` riesegui dall'inizio dello step 5:
```bash
python step-5/chunker.py --stem <stem> --force
rm -f step-6/<stem>/chunks.json # forza la rilettura da step-5
python step-6/verify_chunks.py --stem <stem>
```
**Cosa verifica `verify_chunks.py`:**
- Nessun chunk è sotto `MIN_CHARS` 🟡
- Nessun chunk è sopra `MAX_CHARS × 1.5` 🟡
- Ogni chunk finisce con punteggiatura di fine frase 🔴
**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` |
**Tabella diagnosi — problemi non risolvibili con fix_chunks:**
| Sintomo | Causa probabile | Soluzione |
|---|---|---|
| Molti chunk corti dopo il fix | `MIN_CHARS` troppo alto o testo frammentato nel MD | Abbassa `MIN_CHARS` o correggi step 4 |
| Chunk spezzato creato dal fix stesso | Frase singola > `MAX_CHARS` non spezzabile | Spezza manualmente il paragrafo in step 4 |
| Chunk che finisce a metà frase non risolvibile | Salto di pagina PDF non sanato nel MD | Correggi la riga spezzata in `clean.md` |
**Output se tutto ok:**
```
Totale chunk: 301
✅ OK: 301
Distribuzione lunghezze:
Min: 187 char
Max: 923 char
Media: 401 char
✅ 1/1 documenti senza problemi
```
---
### Step 7 — Installazione ambiente
**Tipo:** manuale (una volta sola)
**Input:** nessuno
**Output:** ambiente locale funzionante
**Script:** `step-7/check_env.py`
Installa Ollama, scarica i modelli e verifica l'ambiente. Si esegue una volta sola.
Vedi [`step-7/README.md`](step-7/README.md) per istruzioni dettagliate e scelta dei modelli.
```bash
python step-7/check_env.py
```
---
### Step 8 — Vettorizzazione
**Tipo:** automatico (lento)
**Input:** `step-6/<stem>/chunks.json`
**Output:** `chroma_db/` popolato
**Script:** `step-8/ingest.py`
## Setup
```bash
python -m venv .venv
source .venv/bin/activate
python step-8/ingest.py --stem <nome>
pip install -r requirements.txt
```
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à. Senza `--force`, se la collection è presente lo step viene saltato |
**Quando usare `--force`:**
Se hai modificato i chunk (es. hai rieseguito step-6 dopo correzioni), la collection in ChromaDB
contiene ancora i vecchi vettori. `--force` la cancella e la ricrea da zero con i chunk aggiornati.
**Cosa succede per ogni chunk:**
```
testo del chunk
▼ Ollama (nomic-embed-text)
vettore di 768 numeri
[0.23, -0.41, 0.87, 0.12, ...]
▼ ChromaDB
salva: testo + vettore + metadati (sezione, titolo, sub_index)
```
**Perché 768 numeri:**
Ogni numero rappresenta una dimensione semantica.
Testi con significato simile producono vettori simili —
i loro numeri sono vicini nello spazio a 768 dimensioni.
Questo è ciò che permette il retrieval semantico.
**Output durante l'esecuzione:**
```
✅ Ollama OK — nomic-embed-text disponibile
📦 872 chunk da ingestire
[ 1/872] ✓ sezione_1__sotto_1__s0 ETA: 870s
[ 2/872] ✓ sezione_1__sotto_2__s0 ETA: 867s
...
[872/872] ✓ sezione_9__sotto_42__s0 ETA: 0s
✅ Ingestione completata in 718s — 872/872 chunk salvati
Collection 'nietzsche' in chroma_db/
```
`chroma_db/` contiene ora tutti i vettori su disco.
Non è necessario ripetere questo step a meno che il documento cambi.
---
### Step 9 — Pipeline RAG
**Tipo:** interattivo
**Input:** `chroma_db/` + domanda dell'utente
**Output:** risposta basata sul documento
**Script:** `step-9/rag.py`
**Java 11+** richiesto:
```bash
source .venv/bin/activate
python step-9/rag.py --stem <nome>
sudo apt install default-jdk # Ubuntu/Debian/WSL
java -version
```
Loop interattivo che risponde a domande sul documento. Configura i parametri in `step-9/config.py` prima di avviare.
Vedi [`step-9/README.md`](step-9/README.md) per la configurazione completa.
---
### Step 10 — Test automatici
**Tipo:** automatico
**Input:** sistema completo
**Output:** tutti i test verdi
**Script:** `step-10/test_pipeline.py` *(da implementare)*
## Utilizzo
```bash
python step-10/test_pipeline.py --stem <nome>
# Singolo PDF
python conversione/pipeline.py --stem <nome>
# Tutti i PDF in sources/
python conversione/pipeline.py
# Forza riesecuzione
python conversione/pipeline.py --stem <nome> --force
```
Verifica ogni componente in isolamento e poi nel sistema completo.
I test non dipendono dal contenuto del documento — usano dati
fittizi creati e distrutti in memoria.
**Struttura dei test:**
```
Test unitari — ogni componente isolato
✓ split_sentences non spezza le frasi
✓ parse_markdown rileva la struttura corretta
✓ chunk_sezione rispetta i boundary
✓ il prefisso è sempre presente in ogni chunk
Test integrazione — i componenti parlano tra loro
✓ Ollama è raggiungibile
✓ i modelli sono disponibili
✓ l'embedding produce 768 dimensioni
✓ testi diversi producono vettori diversi
✓ ChromaDB scrive e legge correttamente
Test qualità — il sistema si comporta bene
✓ il retrieval trova il chunk pertinente
✓ il retrieval non trova il chunk non pertinente
✓ il LLM usa il contesto fornito
✓ il LLM ammette quando la risposta non è nel contesto
```
`--stem` = nome file PDF senza estensione.
Esempio: `sources/analisi1.pdf``--stem analisi1`
---
## Principi di progettazione
## Output
**Atomico**
Ogni step 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.
Per ogni stem in `conversione/<stem>/`:
**Verificabile**
Ogni step ha un criterio di completamento oggettivo.
Non si passa allo step successivo finché il precedente non è verificato.
| File | Descrizione |
|------|-------------|
| `raw.md` | Markdown grezzo — **non modificare** |
| `clean.md` | Markdown pulito — copia di lavoro |
| `structure_profile.json` | Struttura rilevata e metriche |
| `report.json` | Statistiche complete della conversione |
**Reversibile**
Puoi tornare indietro senza perdere il lavoro degli altri step.
Cambi il MD? Riesegui solo step 5 e 8.
Cambi i parametri del chunker? Riesegui solo step 5 e 8.
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.
## Validazione batch
**Tutto locale**
Nessuna chiamata a API esterne.
Nessun dato trasmesso fuori dalla macchina.
Nessun costo di utilizzo.
```bash
python conversione/validate.py
```
Stampa una tabella di stato su tutti gli stem convertiti.
---
Vedi [`conversione/README.md`](conversione/README.md) per dettagli sulla pipeline e i tipi di documento supportati.
+111
View File
@@ -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()
+17
View File
@@ -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",
]
+110
View File
@@ -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
+127
View File
@@ -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
+169
View File
@@ -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+F8EBF8FE) → 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*$")
+45
View File
@@ -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
+116
View File
@@ -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
+127
View File
@@ -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
+153
View File
@@ -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
+248
View File
@@ -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 150, 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)
+109
View File
@@ -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
+177
View File
@@ -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
+135
View 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
+193
View File
@@ -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: {'' 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
+141
View File
@@ -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,
}
+152
View File
@@ -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
View File
@@ -4,10 +4,30 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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
echo "Nessuna cartella da cancellare."
echo "Nessuna cartella di output da cancellare."
exit 0
fi
@@ -16,10 +36,8 @@ for d in "${dirs[@]}"; do
echo " $d"
done
if [[ "${1:-}" != "-f" ]]; then
read -r -p "Confermi? [s/N] " answer
[[ "$answer" =~ ^[sS]$ ]] || { echo "Annullato."; exit 0; }
fi
read -r -p "Confermi? [s/N] " answer
[[ "$answer" =~ ^[sS]$ ]] || { echo "Annullato."; exit 0; }
for d in "${dirs[@]}"; do
rm -rf "$d"
File diff suppressed because it is too large Load Diff
-208
View File
@@ -1,208 +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")
# ── 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)
-9
View File
@@ -1,12 +1,3 @@
# Step 0-1 — Ispezione e verifica PDF
pdfplumber==0.11.9
# Step 2 — Conversione PDF → Markdown
pymupdf4llm
# conversione/ — Pipeline automatica PDF → clean Markdown (alternativa a step 0+1+2+3+4)
# Richiede anche Java 11+ sul PATH: https://adoptium.net/
opendataloader-pdf
# Step 8 — Vettorizzazione
chromadb
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Step 0 Verifica idoneità PDF
Legge tutti i PDF in sources/ e salva un report per ognuno in step-0/.
Uso:
python step-0/check_pdf.py
Output:
step-0/<nome_pdf>_step0_report.txt
"""
import sys
import statistics
from datetime import datetime
from pathlib import Path
def check_pdf(pdf_path: str, save: bool = True) -> None:
try:
import pdfplumber
except ImportError:
print("Errore: pdfplumber non è installato.")
print(" pip install pdfplumber")
sys.exit(1)
path = Path(pdf_path)
if not path.exists():
print(f"Errore: file non trovato — {pdf_path}")
sys.exit(1)
if path.suffix.lower() != ".pdf":
print(f"Errore: il file non è un PDF — {pdf_path}")
sys.exit(1)
lines = [] # righe del report
results = [] # (etichetta, stato, messaggio)
def out(text=""):
lines.append(text)
print(text)
out(f"Step 0 — Verifica idoneità PDF")
out(f"File: {path.name}")
out(f"Data: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
out("=" * 50)
# ------------------------------------------------------------------ #
# Criterio 1 — Non protetto da password
# ------------------------------------------------------------------ #
try:
with pdfplumber.open(path) as pdf:
n_pages = len(pdf.pages)
results.append(("Non protetto da password", "PASS", f"{n_pages} pagine"))
except Exception as e:
msg = str(e).lower()
if "password" in msg or "encrypted" in msg or "decrypt" in msg:
results.append(("Non protetto da password", "FAIL",
"Il PDF è cifrato — non può essere elaborato"))
else:
results.append(("Non protetto da password", "FAIL",
f"Impossibile aprire il file: {e}"))
_render_results(results, out)
_maybe_save(lines, path, save)
return
# ------------------------------------------------------------------ #
# Lettura pagine — una sola passata
# ------------------------------------------------------------------ #
char_counts = []
line_lengths = []
all_text = ""
empty_pages = 0
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
all_text += text + "\n"
chars = len(text.strip())
char_counts.append(chars)
if chars == 0:
empty_pages += 1
for line in text.splitlines():
stripped = line.strip()
if stripped:
line_lengths.append(len(stripped))
total_pages = len(char_counts)
pages_with_text = sum(1 for c in char_counts if c > 50)
text_coverage = pages_with_text / total_pages if total_pages > 0 else 0
# ------------------------------------------------------------------ #
# Criterio 2 — Testo estraibile
# ------------------------------------------------------------------ #
if text_coverage >= 0.7:
results.append(("Testo estraibile", "PASS",
f"{pages_with_text}/{total_pages} pagine con testo ({text_coverage:.0%})"))
elif text_coverage >= 0.4:
results.append(("Testo estraibile", "WARN",
f"Solo {pages_with_text}/{total_pages} pagine con testo — revisione estesa necessaria"))
else:
results.append(("Testo estraibile", "FAIL",
f"Solo {pages_with_text}/{total_pages} pagine con testo — probabilmente scansionato"))
# ------------------------------------------------------------------ #
# Criterio 3 — Generato digitalmente (non scansionato)
# ------------------------------------------------------------------ #
pages_text_only = [c for c in char_counts if c > 0]
avg_chars = statistics.mean(pages_text_only) if pages_text_only else 0
if avg_chars >= 300:
results.append(("Generato digitalmente (non scansionato)", "PASS",
f"Media {avg_chars:.0f} char/pagina"))
elif avg_chars >= 100:
results.append(("Generato digitalmente (non scansionato)", "WARN",
f"Media bassa: {avg_chars:.0f} char/pagina — alcune pagine potrebbero essere immagini"))
else:
results.append(("Generato digitalmente (non scansionato)", "FAIL",
f"Media molto bassa: {avg_chars:.0f} char/pagina — il PDF sembra scansionato"))
# ------------------------------------------------------------------ #
# Criterio 4 — Pagine vuote
# ------------------------------------------------------------------ #
if empty_pages == 0:
results.append(("Pagine vuote", "PASS", "Nessuna pagina vuota"))
elif empty_pages <= total_pages * 0.05:
results.append(("Pagine vuote", "WARN",
f"{empty_pages} pagine vuote (≤ 5%) — probabilmente copertine o separatori"))
else:
results.append(("Pagine vuote", "WARN",
f"{empty_pages} pagine vuote ({empty_pages/total_pages:.0%}) — controllare"))
# ------------------------------------------------------------------ #
# Criterio desiderabile — Layout a colonne singola
# ------------------------------------------------------------------ #
if line_lengths:
median_len = statistics.median(line_lengths)
short_lines = sum(1 for l in line_lengths if l < median_len * 0.4)
short_ratio = short_lines / len(line_lengths)
if short_ratio < 0.15:
results.append(("Layout a colonne singola (desiderabile)", "PASS",
f"Righe corte: {short_ratio:.0%} — struttura lineare"))
elif short_ratio < 0.35:
results.append(("Layout a colonne singola (desiderabile)", "WARN",
f"Righe corte: {short_ratio:.0%} — possibile layout a colonne parziale"))
else:
results.append(("Layout a colonne singola (desiderabile)", "WARN",
f"Righe corte: {short_ratio:.0%} — probabile layout a colonne multiple"))
else:
results.append(("Layout a colonne singola (desiderabile)", "WARN",
"Impossibile analizzare (nessuna riga estratta)"))
# ------------------------------------------------------------------ #
# Criterio desiderabile — Struttura logica (titoli)
# ------------------------------------------------------------------ #
candidate_headings = [
line.strip() for line in all_text.splitlines()
if 3 <= len(line.strip()) <= 80
and line.strip()[0].isupper()
and not line.strip().endswith(".")
and not line.strip().endswith(",")
and len(line.strip().split()) <= 10
]
heading_density = len(candidate_headings) / total_pages if total_pages > 0 else 0
if heading_density >= 1.0:
results.append(("Struttura logica riconoscibile (desiderabile)", "PASS",
f"~{len(candidate_headings)} possibili titoli rilevati ({heading_density:.1f}/pagina)"))
elif heading_density >= 0.3:
results.append(("Struttura logica riconoscibile (desiderabile)", "WARN",
f"~{len(candidate_headings)} possibili titoli ({heading_density:.1f}/pagina) — struttura parziale"))
else:
results.append(("Struttura logica riconoscibile (desiderabile)", "WARN",
"Pochi titoli rilevati — testo narrativo o struttura non standard"))
_render_results(results, out)
_maybe_save(lines, path, save)
def _render_results(results: list, out) -> None:
icons = {"PASS": "", "WARN": "⚠️ ", "FAIL": ""}
out()
for label, status, message in results:
icon = icons.get(status, " ")
out(f" {icon} {label}")
out(f" {message}")
out()
fails = [r for r in results if r[1] == "FAIL"]
warns = [r for r in results if r[1] == "WARN"]
if fails:
out("ESITO: ❌ PDF NON IDONEO")
out(" Criteri obbligatori non soddisfatti — scegli un PDF diverso.")
elif warns:
out("ESITO: ⚠️ PDF ACCETTABILE CON CAUTELA")
out(" Procedi, ma aspettati più lavoro nella revisione manuale (step 4).")
else:
out("ESITO: ✅ PDF IDONEO")
out(" Tutti i criteri soddisfatti — procedi con lo step 1.")
out()
def _maybe_save(lines: list, pdf_path: Path, save: bool) -> None:
if not save:
return
script_dir = Path(__file__).parent
out_file = script_dir / f"{pdf_path.stem}_step0_report.txt"
out_file.write_text("\n".join(lines), encoding="utf-8")
print(f"Report salvato in: {out_file}")
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
sources_dir = project_root / "sources"
if not sources_dir.exists():
print(f"Errore: cartella sources/ non trovata in {project_root}")
sys.exit(1)
pdfs = sorted(sources_dir.glob("*.pdf"))
if not pdfs:
print(f"Errore: nessun PDF trovato in {sources_dir}")
sys.exit(1)
for pdf in pdfs:
check_pdf(str(pdf), save=True)
if len(pdfs) > 1:
print("-" * 50)
-199
View File
@@ -1,199 +0,0 @@
#!/usr/bin/env python3
"""
Step 1 Ispezione automatica PDF
Analizza il PDF pagina per pagina e produce un report con score (0100)
e lista dei problemi per pagina. Serve per capire la qualità del documento
e mappare i problemi prima della revisione manuale (step 4).
Uso:
python step1/inspect.py
Output:
step1/<nome_pdf>_step1_report.txt
"""
import re
import sys
import statistics
from collections import Counter
from datetime import datetime
from pathlib import Path
# ── Penalità per il calcolo dello score ───────────────────────────────────
SYLLABIF_PENALTY = 0.3 # per occorrenza di sillabazione
COLUMN_PENALTY = 3.0 # per pagina con layout a colonne
UNICODE_PENALTY = 1.5 # per pagina con caratteri anomali
EMPTY_PENALTY = 1.0 # per pagina vuota
HEADER_FOOTER_PEN = 5.0 # fisso se intestazioni/piè ripetitivi rilevati
def inspect_pdf(pdf_path: str, save: bool = True) -> None:
try:
import pdfplumber
except ImportError:
print("Errore: pdfplumber non è installato.")
print(" pip install pdfplumber")
sys.exit(1)
path = Path(pdf_path)
if not path.exists():
print(f"Errore: file non trovato — {pdf_path}")
sys.exit(1)
lines = []
def out(text=""):
lines.append(text)
print(text)
out("Step 1 — Ispezione automatica PDF")
out(f"File: {path.name}")
out(f"Data: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
out("=" * 50)
# ── Lettura pagine ─────────────────────────────────────────────────────
with pdfplumber.open(path) as pdf:
n_pages = len(pdf.pages)
pages_text = [page.extract_text() or "" for page in pdf.pages]
# ── Analisi per pagina ─────────────────────────────────────────────────
issues = [] # (page_num, descrizione) — page_num=0 → problema globale
deductions = 0.0
first_lines = [] # prima riga significativa di ogni pagina (per header)
last_lines = [] # ultima riga significativa di ogni pagina (per footer)
for i, text in enumerate(pages_text):
page_num = i + 1
stripped = text.strip()
# 1. Pagina vuota
if len(stripped) < 50:
issues.append((page_num, "pagina vuota"))
deductions += EMPTY_PENALTY
continue
page_lines = text.splitlines()
nonempty = [l.strip() for l in page_lines if l.strip()]
# Raccogli prima/ultima riga per il controllo header/footer
if nonempty:
first_lines.append(nonempty[0])
last_lines.append(nonempty[-1])
# 2. Sillabazione a fine riga (es. "estra-" + a capo)
syllabif = sum(
1 for line in page_lines
if re.search(r'\b\w{2,}-$', line.rstrip())
)
if syllabif:
label = "occorrenza" if syllabif == 1 else "occorrenze"
issues.append((page_num, f"sillabazione rilevata ({syllabif} {label})"))
deductions += syllabif * SYLLABIF_PENALTY
# 3. Layout a colonne (righe molto corte e numerose)
if len(nonempty) >= 10:
median_len = statistics.median(len(l) for l in nonempty)
short_ratio = sum(1 for l in nonempty if len(l) < median_len * 0.4) / len(nonempty)
if short_ratio > 0.35:
issues.append((page_num, f"possibile layout a colonne ({short_ratio:.0%} righe corte)"))
deductions += COLUMN_PENALTY
# 4. Caratteri Unicode anomali
# (control chars esclusi \n \t \r, replacement char, PUA block)
anomalies = re.findall(
r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ufffd\ue000-\uf8ff]', text
)
if anomalies:
issues.append((page_num, f"caratteri Unicode anomali ({len(anomalies)} trovati)"))
deductions += UNICODE_PENALTY
# ── Intestazioni e piè di pagina ripetitivi ────────────────────────────
def _check_repetition(line_list: list, label: str) -> None:
nonlocal deductions
if not line_list:
return
threshold = max(3, len(line_list) * 0.25)
repeated = [
(txt, cnt) for txt, cnt in Counter(line_list).items()
if cnt >= threshold and len(txt) > 3
]
if repeated:
deductions += HEADER_FOOTER_PEN
for txt, cnt in repeated[:3]:
issues.append((0, f"{label} ripetitivo: \"{txt[:45]}\" ({cnt} volte)"))
_check_repetition(first_lines, "intestazione")
_check_repetition(last_lines, "piè di pagina")
# ── Score ──────────────────────────────────────────────────────────────
score = max(0, round(100 - deductions))
# ── Riepilogo ──────────────────────────────────────────────────────────
pages_with_issues = len({p for p, _ in issues if p > 0})
out()
out(f"Score: {score}/100")
out(f"Pagine totali: {n_pages}")
out(f"Pagine con problemi: {pages_with_issues}")
out()
if issues:
global_issues = [(p, d) for p, d in issues if p == 0]
page_issues = sorted([(p, d) for p, d in issues if p > 0])
for _, desc in global_issues:
out(f" ⚠️ {desc}")
for page_num, desc in page_issues:
out(f" Pagina {page_num:>4}: {desc}")
else:
out(" Nessun problema rilevato.")
out()
# ── Prossimi passi ─────────────────────────────────────────────────────
out("PROSSIMI PASSI:")
if score >= 70:
out(" → conversione con marker funzionerà bene")
elif score >= 40:
out(" → conversione possibile, attendi più errori nella revisione")
else:
out(" → qualità bassa — valuta una fonte PDF migliore")
attention_pages = sorted({p for p, _ in issues if p > 0})
if attention_pages:
sample = ", ".join(str(p) for p in attention_pages[:10])
if len(attention_pages) > 10:
sample += f" … e altre {len(attention_pages) - 10}"
out(f" → attenzione alle pagine {sample} nella revisione manuale")
out()
_maybe_save(lines, path, save)
def _maybe_save(lines: list, pdf_path: Path, save: bool) -> None:
if not save:
return
script_dir = Path(__file__).parent
out_file = script_dir / f"{pdf_path.stem}_step1_report.txt"
out_file.write_text("\n".join(lines), encoding="utf-8")
print(f"Report salvato in: {out_file}")
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
sources_dir = project_root / "sources"
if not sources_dir.exists():
print(f"Errore: cartella sources/ non trovata in {project_root}")
sys.exit(1)
pdfs = sorted(sources_dir.glob("*.pdf"))
if not pdfs:
print(f"Errore: nessun PDF trovato in {sources_dir}")
sys.exit(1)
for pdf in pdfs:
inspect_pdf(str(pdf), save=True)
if len(pdfs) > 1:
print("-" * 50)
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""
Step 2 Conversione PDF Markdown grezzo
Usa pymupdf4llm (PyMuPDF puro C, zero modelli ML, ~30-50 MB RAM)
per convertire ogni PDF in sources/ e organizza l'output in:
step-2/<stem>/raw.md MD grezzo, non modificare mai
step-2/<stem>/clean.md copia di lavoro per lo step 4
Uso:
python step-2/convert_pdf.py # tutti i PDF in sources/
python step-2/convert_pdf.py --pdf sources/doc.pdf # un solo PDF
"""
import argparse
import shutil
import sys
from pathlib import Path
import pymupdf4llm
def convert_pdf(pdf_path: Path, project_root: Path) -> bool:
stem = pdf_path.stem
out_dir = project_root / "step-2" / stem
raw_md = out_dir / "raw.md"
clean_md = out_dir / "clean.md"
print(f"\nConversione: {pdf_path.name}")
print(f" Output: step-2/{stem}/")
if raw_md.exists():
print(f" ⚠️ raw.md già presente — skip")
print(f" (elimina {raw_md} per riconvertire)")
return True
out_dir.mkdir(parents=True, exist_ok=True)
print(f" Conversione in corso...")
md_text = pymupdf4llm.to_markdown(str(pdf_path))
raw_md.write_text(md_text, encoding="utf-8")
shutil.copy2(raw_md, clean_md)
size_kb = raw_md.stat().st_size // 1024
print(f" ✅ raw.md salvato ({size_kb} KB)")
print(f" ✅ clean.md creato (copia di lavoro per step 4)")
return True
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(description="Step 2 — Conversione PDF → Markdown")
parser.add_argument("--pdf", help="Percorso di un singolo PDF da convertire")
args = parser.parse_args()
if args.pdf:
pdf_path = Path(args.pdf)
if not pdf_path.exists():
print(f"Errore: file non trovato — {args.pdf}")
sys.exit(1)
pdfs = [pdf_path]
else:
sources_dir = project_root / "sources"
if not sources_dir.exists():
print(f"Errore: cartella sources/ non trovata in {project_root}")
sys.exit(1)
pdfs = sorted(sources_dir.glob("*.pdf"))
if not pdfs:
print(f"Errore: nessun PDF trovato in {sources_dir}")
sys.exit(1)
results = [convert_pdf(p, project_root) for p in pdfs]
ok_count = sum(results)
total = len(results)
print(f"\n{'' if all(results) else '⚠️ '} {ok_count}/{total} PDF convertiti")
sys.exit(0 if all(results) else 1)
-223
View File
@@ -1,223 +0,0 @@
#!/usr/bin/env python3
"""
Step 3 Rilevamento struttura Markdown
Analizza il Markdown grezzo prodotto dallo step 2 senza modificarlo.
Copia i file da step-2/<stem>/ e produce structure_profile.json che
guida la revisione manuale (step 4) e il chunker adattivo (step 5).
Output in step-3/<stem>/:
raw.md copia da step-2 (non modificare mai)
clean.md copia da step-2 (da revisionare nello step 4)
structure_profile.json profilo strutturale
Uso:
python step-3/detect_structure.py # tutti i documenti in step-2/
python step-3/detect_structure.py --stem nietzsche # un solo documento
python step-3/detect_structure.py --force # riesegui anche se già presente
"""
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
# ─── Language detection ───────────────────────────────────────────────────────
_IT_WORDS = frozenset([
"il", "la", "di", "e", "che", "non", "per", "un", "una", "si",
"con", "da", "del", "della", "dei", "in", "ma", "se", "lo", "le",
"gli", "al", "alla", "ai", "alle", "sono", "ha", "hanno", "era",
"erano", "nel", "nella", "nei", "nelle", "questo", "questa", "così",
])
_EN_WORDS = frozenset([
"the", "of", "and", "to", "in", "is", "that", "it", "was", "for",
"on", "are", "as", "with", "his", "they", "at", "be", "this", "have",
"from", "or", "an", "but", "not", "by", "he", "she", "we", "you",
"which", "their", "been", "has", "would", "there", "when", "will",
])
def detect_language(text: str) -> str:
words = re.findall(r'\b[a-zA-Z]{2,}\b', text.lower())
sample = words[:2000]
it = sum(1 for w in sample if w in _IT_WORDS)
en = sum(1 for w in sample if w in _EN_WORDS)
if it == 0 and en == 0:
return "unknown"
return "it" if it >= en else "en"
# ─── Markdown parsing ─────────────────────────────────────────────────────────
def split_sections(text: str, header_level: int) -> list[str]:
"""
Split text on headers of the given level (1=h1, 2=h2, 3=h3).
Returns list of body texts for each matching section.
"""
prefix = "#" * header_level + " "
parts = re.split(rf'(?m)^{re.escape(prefix)}.+', text)
# parts[0] is preamble, rest are section bodies
return [p for p in parts[1:] if p.strip()]
def count_headers(text: str, level: int) -> int:
prefix = "#" * level + " "
return len(re.findall(rf'(?m)^{re.escape(prefix)}', text))
def count_paragraphs(text: str) -> int:
"""Count non-empty, non-header paragraph blocks."""
blocks = re.split(r'\n{2,}', text)
return sum(1 for b in blocks if b.strip() and not re.match(r'^#+\s', b.strip()))
# ─── Core analysis ────────────────────────────────────────────────────────────
def analyze(raw_md_path: Path) -> dict:
text = raw_md_path.read_text(encoding="utf-8")
n_h1 = count_headers(text, 1)
n_h2 = count_headers(text, 2)
n_h3 = count_headers(text, 3)
n_paragrafi = count_paragraphs(text)
# Determine structural level and primary boundary
if n_h3 >= 5:
livello = 3
boundary = "h3"
strategia = "h3_aware"
section_bodies = split_sections(text, 3)
elif n_h2 >= 3:
livello = 2
boundary = "h2"
strategia = "h2_paragraph_split"
section_bodies = split_sections(text, 2)
elif n_h1 + n_h2 + n_h3 >= 1:
livello = 1
boundary = "paragrafo"
strategia = "paragraph"
section_bodies = [b for b in re.split(r'\n{2,}', text) if b.strip()]
else:
if n_paragrafi >= 3:
livello = 1
boundary = "paragrafo"
strategia = "paragraph"
section_bodies = [b for b in re.split(r'\n{2,}', text) if b.strip()]
else:
livello = 0
boundary = "nessuno"
strategia = "sliding_window"
section_bodies = [text] if text.strip() else []
lengths = [len(b) for b in section_bodies if b.strip()]
lunghezza_media = int(sum(lengths) / len(lengths)) if lengths else 0
lingua = detect_language(text)
avvertenze = []
short = sum(1 for l in lengths if l < 200)
long_ = sum(1 for l in lengths if l > 800)
if short:
avvertenze.append(f"{short} sezioni sotto i 200 caratteri — verranno accorpate")
if long_:
avvertenze.append(f"{long_} sezioni sopra i 800 caratteri — verranno divise")
return {
"livello_struttura": livello,
"n_h1": n_h1,
"n_h2": n_h2,
"n_h3": n_h3,
"n_paragrafi": n_paragrafi,
"boundary_primario": boundary,
"lingua_rilevata": lingua,
"lunghezza_media_sezione": lunghezza_media,
"strategia_chunking": strategia,
"avvertenze": avvertenze,
}
# ─── Per-document processing ─────────────────────────────────────────────────
def process_stem(stem: str, project_root: Path, force: bool) -> bool:
src_dir = project_root / "step-2" / stem
out_dir = project_root / "step-3" / stem
raw_src = src_dir / "raw.md"
clean_src = src_dir / "clean.md"
profile_out = out_dir / "structure_profile.json"
print(f"\nDocumento: {stem}")
if not raw_src.exists():
print(f" ✗ raw.md non trovato in step-2/{stem}/ — skip")
return False
if profile_out.exists() and not force:
print(f" ⚠️ structure_profile.json già presente — skip")
print(f" (usa --force per rieseguire)")
return True
out_dir.mkdir(parents=True, exist_ok=True)
# Copy files from step-2
shutil.copy2(raw_src, out_dir / "raw.md")
if clean_src.exists():
shutil.copy2(clean_src, out_dir / "clean.md")
print(f" Copiati raw.md e clean.md da step-2/{stem}/")
# Analyze
print(f" Analisi struttura in corso...")
profile = analyze(out_dir / "raw.md")
profile_out.write_text(json.dumps(profile, ensure_ascii=False, indent=2), encoding="utf-8")
# Report
_LIVELLO_DESC = {
3: "struttura ricca (###)",
2: "struttura parziale (##)",
1: "solo paragrafi",
0: "testo piatto",
}
print(f" ✅ Livello {profile['livello_struttura']}{_LIVELLO_DESC[profile['livello_struttura']]}")
print(f" h1={profile['n_h1']} h2={profile['n_h2']} h3={profile['n_h3']} paragrafi={profile['n_paragrafi']}")
print(f" Boundary: {profile['boundary_primario']} | Strategia: {profile['strategia_chunking']}")
print(f" Lingua: {profile['lingua_rilevata']} | Lunghezza media sezione: {profile['lunghezza_media_sezione']} char")
for w in profile["avvertenze"]:
print(f" ⚠️ {w}")
print(f" ✅ structure_profile.json salvato")
return True
# ─── Entry point ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(description="Step 3 — Rilevamento struttura Markdown")
parser.add_argument("--stem", help="Nome del documento (sottocartella di step-2/)")
parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente")
args = parser.parse_args()
if args.stem:
stems = [args.stem]
else:
step2_dir = project_root / "step-2"
if not step2_dir.exists():
print(f"Errore: cartella step-2/ non trovata in {project_root}")
sys.exit(1)
stems = sorted(p.name for p in step2_dir.iterdir() if p.is_dir())
if not stems:
print(f"Errore: nessun documento trovato in step-2/")
sys.exit(1)
results = [process_stem(s, project_root, args.force) for s in stems]
ok = sum(results)
total = len(results)
print(f"\n{'' if all(results) else '⚠️ '} {ok}/{total} documenti analizzati")
sys.exit(0 if all(results) else 1)
-433
View File
@@ -1,433 +0,0 @@
#!/usr/bin/env python3
"""
Step 4 Revisione automatica del Markdown
Trasforma clean.md da step-3 rivelando la struttura latente del documento.
Le trasformazioni sono euristiche universali che funzionano su qualsiasi PDF:
- Normalizza whitespace multiplo (artefatto PDF)
- Riduce righe vuote multiple
- Rimuove marcatori **bold** nelle intestazioni esistenti
- Converte righe ALL-CAPS standalone ## header (euristico, qualsiasi lingua)
- Converte sezioni numerate "N. testo" ### N. (qualsiasi numerazione)
- Rimuove blocchi TOC (righe che iniziano con parole-chiave indice)
Per ogni documento viene ricalcolato il profilo strutturale: il livello può
salire (es. livello 1 3) se le strutture latenti vengono rilevate.
Output in step-4/<stem>/:
raw.md copia da step-3 (non modificare mai)
clean.md MD revisionato
structure_profile.json profilo aggiornato dopo la revisione
Uso:
python step-4/revise.py # tutti i documenti in step-3/
python step-4/revise.py --stem nietzsche # un solo documento
python step-4/revise.py --force # riesegui anche se già presente
"""
import argparse
import json
import re
import shutil
import sys
from datetime import date
from pathlib import Path
# Riusa la funzione analyze() già scritta nello step 3
sys.path.insert(0, str(Path(__file__).parent.parent / "step-3"))
from detect_structure import analyze # noqa: E402
# ─── Costanti ─────────────────────────────────────────────────────────────────
# Parole-chiave che identificano blocchi TOC (da rimuovere)
_TOC_KEYWORDS = frozenset([
"indice", "index", "contents", "table of contents",
"sommario", "inhaltsverzeichnis", "inhalt",
])
# Preposizioni/articoli da non capitalizzare nel title-case
_STOP_IT_EN = frozenset([
# italiano
"di", "del", "della", "dei", "delle", "da", "in", "e", "il", "la",
"lo", "le", "gli", "un", "una", "per", "a", "al", "alla", "ai",
"alle", "con", "su", "sul", "sulla", "che", "o",
# inglese
"of", "the", "a", "an", "and", "or", "but", "in", "on", "at",
"to", "for", "with", "by", "from", "as",
])
# Ordinali italiani → romani (per titoli come "CAPITOLO PRIMO")
_ORDINALS_IT = {
"PRIMO": "I", "SECONDO": "II", "TERZO": "III", "QUARTO": "IV",
"QUINTO": "V", "SESTO": "VI", "SETTIMO": "VII", "OTTAVO": "VIII",
"NONO": "IX", "DECIMO": "X",
}
# Ordinali inglesi → arabici (per "CHAPTER ONE")
_ORDINALS_EN = {
"ONE": "1", "TWO": "2", "THREE": "3", "FOUR": "4", "FIVE": "5",
"SIX": "6", "SEVEN": "7", "EIGHT": "8", "NINE": "9", "TEN": "10",
}
# ─── Utilità ──────────────────────────────────────────────────────────────────
def _sentence_case(s: str) -> str:
"""
Sentence-case: prima lettera maiuscola, resto minuscolo.
Corretto per l'italiano e accettabile per l'inglese accademico.
"""
if not s:
return s
lower = s.lower()
return lower[0].upper() + lower[1:]
def _is_allcaps_line(line: str) -> bool:
"""
True se la riga è una candidata per conversione a ## header.
Criterio: tutti i caratteri alfabetici sono maiuscoli, lunghezza >= 3.
"""
stripped = line.strip()
letters = [c for c in stripped if c.isalpha()]
return (
len(letters) >= 3
and all(c.isupper() for c in letters)
and not stripped.startswith("#")
)
def _allcaps_to_header(raw_line: str) -> str:
"""
Converte una riga ALL-CAPS in un ## header title-case.
Riconosce pattern specifici (CAPITOLO ORDINE, CHAPTER N) come bonus,
ma funziona in modalità generica su qualsiasi testo.
"""
text = raw_line.strip().rstrip('.').rstrip('?').strip()
# ── Pattern italiano: "CAPITOLO PRIMO. TITOLO DEL CAPITOLO"
_ORD_IT_PAT = "|".join(_ORDINALS_IT.keys())
m = re.match(rf'^CAPITOLO ({_ORD_IT_PAT})\. (.+)', text)
if m:
roman = _ORDINALS_IT[m.group(1)]
titolo = m.group(2).rstrip('.').rstrip('?').strip()
return f"## Capitolo {roman}{_sentence_case(titolo)}"
# ── Pattern inglese: "CHAPTER ONE. TITLE" o "CHAPTER 1. TITLE"
_ORD_EN_PAT = "|".join(_ORDINALS_EN.keys())
m = re.match(rf'^CHAPTER ({_ORD_EN_PAT}|\d+)\.? (.+)', text)
if m:
n = _ORDINALS_EN.get(m.group(1), m.group(1))
titolo = m.group(2).rstrip('.').rstrip('?').strip()
return f"## Chapter {n}{_sentence_case(titolo)}"
# ── Pattern generico con numerazione romana o arabica nel prefisso
m = re.match(r'^([IVXLCDM]+|[0-9]+)\. (.+)', text)
if m:
n = m.group(1)
titolo = m.group(2).rstrip('.').strip()
return f"## {n}. {_sentence_case(titolo)}"
# ── Caso generico: tutto maiuscolo senza pattern riconoscibile
return f"## {_sentence_case(text)}"
def _is_toc_line(line: str) -> bool:
"""True se la riga è l'intestazione di un blocco indice/TOC."""
first_word = line.strip().split('.')[0].strip().lower()
return first_word in _TOC_KEYWORDS
# ─── Trasformazioni ────────────────────────────────────────────────────────────
def apply_transforms(text: str) -> tuple[str, dict]:
"""
Applica tutte le trasformazioni strutturali al testo MD.
Restituisce (testo_modificato, statistiche).
"""
stats = {
"toc_rimosso": False,
"n_header_allcaps": 0,
"n_sezioni_numerate": 0,
"n_paragrafi_uniti": 0,
}
# ── 1. Rimuovi marcatori **bold** nelle intestazioni esistenti
# ## **Titolo** → ## Titolo
text = re.sub(
r'^(#{1,6})\s+\*\*(.+?)\*\*\s*$',
r'\1 \2',
text, flags=re.MULTILINE,
)
# ── 1b. Normalizza header esistenti con contenuto ALL-CAPS → sentence-case
# ## AL DI LA' DEL BENE E DEL MALE → ## Al di la' del bene e del male
def _norm_allcaps_header(m: re.Match) -> str:
hashes = m.group(1)
content = m.group(2).strip()
letters = [c for c in content if c.isalpha()]
if letters and all(c.isupper() for c in letters):
return f"{hashes} {_sentence_case(content)}"
return m.group(0)
text = re.sub(
r'^(#{1,6}) (.+)$',
_norm_allcaps_header,
text, flags=re.MULTILINE,
)
# ── 2. Rimuovi blocco TOC (riga indice + contenuto inline sulla stessa riga)
# "INDICE. Capitolo 1 Capitolo 2 ..." → rimossa
lines = text.split('\n')
new_lines = []
for line in lines:
if _is_toc_line(line):
stats["toc_rimosso"] = True
else:
new_lines.append(line)
text = '\n'.join(new_lines)
# ── 3. Converti righe ALL-CAPS standalone → ## header
# Una riga è "standalone" se è preceduta/seguita da riga vuota
# oppure si trova all'inizio/fine del documento.
blocks = text.split('\n\n')
new_blocks = []
for block in blocks:
stripped = block.strip()
# Blocco standalone = un'unica riga (nessun \n interno rilevante)
if '\n' not in stripped and _is_allcaps_line(stripped):
new_blocks.append(_allcaps_to_header(stripped))
stats["n_header_allcaps"] += 1
else:
# Controlla riga per riga per righe ALL-CAPS seguite da altri contenuti
sub_lines = block.split('\n')
converted = []
for ln in sub_lines:
if _is_allcaps_line(ln) and len(ln.strip()) > 3:
converted.append(_allcaps_to_header(ln))
stats["n_header_allcaps"] += 1
else:
converted.append(ln)
new_blocks.append('\n'.join(converted))
text = '\n\n'.join(new_blocks)
# ── 4. Converti sezioni numerate "N. testo" → "### N.\n\ntesto"
# Riconosce: "1. Testo", "42. Testo" (due o più spazi dopo il punto)
def _num_repl(m: re.Match) -> str:
num = m.group(1)
testo = m.group(2).strip()
stats["n_sezioni_numerate"] += 1
return f"### {num}.\n\n{testo}"
# Pattern standard: "1. testo" o "1. testo"
text = re.sub(
r'^(\d+)\.\s+(.+)$',
_num_repl,
text, flags=re.MULTILINE,
)
# Pattern con lettera-suffisso: "65 a. testo" o "65a. testo"
def _num_letter_repl(m: re.Match) -> str:
num = m.group(1) + m.group(2)
testo = m.group(3).strip()
stats["n_sezioni_numerate"] += 1
return f"### {num}.\n\n{testo}"
text = re.sub(
r'^(\d+)\s*([a-z])\.\s+(.+)$',
_num_letter_repl,
text, flags=re.MULTILINE,
)
# ── 5. Unisci paragrafi spezzati da salti pagina PDF
# Criterio: blocco A non finisce con punteggiatura di fine frase,
# blocco B non inizia con maiuscola "di sezione" né è un header.
# Unione sicura: mai attraverso confini ###/##.
_SENTENCE_END = set('.?!»)\'"')
blocks = text.split('\n\n')
merged = []
i = 0
while i < len(blocks):
b = blocks[i]
stripped = b.strip()
# Prova a unire con il successivo se la frase è spezzata
while (
i + 1 < len(blocks)
and stripped
and not stripped.startswith('#')
and stripped[-1] not in _SENTENCE_END
):
nxt = blocks[i + 1].strip()
# Non unire se il successivo è un header o è vuoto
if not nxt or nxt.startswith('#'):
break
# Non unire se il successivo inizia con una cifra seguita da punto
# (sarebbe l'inizio di un nuovo aforisma non ancora convertito)
if re.match(r'^\d+\.', nxt):
break
b = stripped + ' ' + nxt
stripped = b.strip()
stats["n_paragrafi_uniti"] += 1
i += 1
merged.append(b)
i += 1
text = '\n\n'.join(merged)
# ── 6. Normalizza whitespace multiplo interno alle righe
# "parola parola" → "parola parola" (inclusi gli header)
lines = text.split('\n')
normalized = []
for line in lines:
if not line.strip():
normalized.append(line)
else:
normalized.append(re.sub(r' +', ' ', line))
text = '\n'.join(normalized)
# ── 7. Riduci righe vuote multiple a doppie
text = re.sub(r'\n{3,}', '\n\n', text)
return text, stats
# ─── Aggiornamento revision log ────────────────────────────────────────────────
def update_revision_log(
log_path: Path,
stem: str,
profile_before: dict,
profile_after: dict,
t_stats: dict,
) -> None:
header_exists = log_path.exists() and log_path.stat().st_size > 0
avv = profile_after.get("avvertenze", [])
avv_str = "; ".join(avv) if avv else "nessuna"
entry = f"""
## {stem} — {date.today().isoformat()}
**Trasformazioni automatiche:**
- Normalizzazione whitespace multiplo e righe vuote
- Blocco TOC rimosso: {'' if t_stats['toc_rimosso'] else 'no'}
- Righe ALL-CAPS ## header: {t_stats['n_header_allcaps']}
- Sezioni numerate ### header: {t_stats['n_sezioni_numerate']}
- Paragrafi uniti (salti pagina PDF): {t_stats['n_paragrafi_uniti']}
- Livello struttura: {profile_before.get('livello_struttura', '?')} {profile_after.get('livello_struttura', '?')}
**Avvertenze residue:** {avv_str}
**Revisioni manuali pendenti:**
- [ ] Verificare conversioni ALL-CAPS errate
- [ ] Controllare sezioni troppo corte o troppo lunghe
"""
if not header_exists:
log_path.write_text("# Revision log\n" + entry, encoding="utf-8")
else:
existing = log_path.read_text(encoding="utf-8")
log_path.write_text(existing + entry, encoding="utf-8")
# ─── Per-document processing ─────────────────────────────────────────────────
def process_stem(stem: str, project_root: Path, force: bool) -> bool:
src_dir = project_root / "step-3" / stem
out_dir = project_root / "step-4" / stem
raw_src = src_dir / "raw.md"
clean_src = src_dir / "clean.md"
profile_src = src_dir / "structure_profile.json"
clean_out = out_dir / "clean.md"
profile_out = out_dir / "structure_profile.json"
print(f"\nDocumento: {stem}")
if not clean_src.exists():
print(f" ✗ clean.md non trovato in step-3/{stem}/ — skip")
return False
if clean_out.exists() and not force:
print(f" ⚠️ clean.md già presente — skip")
print(f" (usa --force per rieseguire)")
return True
out_dir.mkdir(parents=True, exist_ok=True)
# Copia raw.md immutabile (riferimento)
if raw_src.exists():
shutil.copy2(raw_src, out_dir / "raw.md")
print(f" Copiato raw.md da step-3/{stem}/")
# Leggi profilo step-3 (per confronto nel report)
profile_before: dict = {}
if profile_src.exists():
profile_before = json.loads(profile_src.read_text(encoding="utf-8"))
# Applica trasformazioni
print(f" Applicazione trasformazioni strutturali...")
text = clean_src.read_text(encoding="utf-8")
text_revised, t_stats = apply_transforms(text)
# Salva clean.md revisionato
clean_out.write_text(text_revised, encoding="utf-8")
# Ricalcola profilo sul nuovo clean.md
profile_after = analyze(clean_out)
profile_out.write_text(
json.dumps(profile_after, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Report
lv_b = profile_before.get("livello_struttura", "?")
lv_a = profile_after["livello_struttura"]
_STRAT = {3: "h3_aware", 2: "h2_paragraph_split", 1: "paragraph", 0: "sliding_window"}
print(f" ✅ Livello struttura: {lv_b}{lv_a} ({_STRAT.get(lv_a, '?')})")
print(f" h2: {profile_before.get('n_h2','?')}{profile_after['n_h2']}")
print(f" h3: {profile_before.get('n_h3','?')}{profile_after['n_h3']}")
print(f" TOC rimosso: {'' if t_stats['toc_rimosso'] else 'no'}")
print(f" Righe ALL-CAPS → ##: {t_stats['n_header_allcaps']}")
print(f" Sezioni numerate → ###: {t_stats['n_sezioni_numerate']}")
print(f" Paragrafi uniti (salti pagina): {t_stats['n_paragrafi_uniti']}")
for w in profile_after["avvertenze"]:
print(f" ⚠️ {w}")
# Aggiorna revision log (direttamente in step-4/, non in sottocartella)
log_path = project_root / "step-4" / "revision_log.md"
update_revision_log(log_path, stem, profile_before, profile_after, t_stats)
print(f" ✅ step-4/revision_log.md aggiornato")
print(f" ✅ structure_profile.json salvato")
return True
# ─── Entry point ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(description="Step 4 — Revisione automatica Markdown")
parser.add_argument("--stem", help="Nome del documento (sottocartella di step-3/)")
parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente")
args = parser.parse_args()
if args.stem:
stems = [args.stem]
else:
step3_dir = project_root / "step-3"
if not step3_dir.exists():
print(f"Errore: cartella step-3/ non trovata in {project_root}")
sys.exit(1)
stems = sorted(p.name for p in step3_dir.iterdir() if p.is_dir())
if not stems:
print(f"Errore: nessun documento trovato in step-3/")
sys.exit(1)
results = [process_stem(s, project_root, args.force) for s in stems]
ok = sum(results)
total = len(results)
print(f"\n{'' if all(results) else '⚠️ '} {ok}/{total} documenti revisionati")
sys.exit(0 if all(results) else 1)
-457
View File
@@ -1,457 +0,0 @@
#!/usr/bin/env python3
"""
Step 5 Chunking adattivo
Divide il Markdown revisionato (step 4) in chunk semantici pronti per la
vettorizzazione. La strategia dipende dal profilo strutturale del documento.
Input: step-4/<stem>/clean.md + step-4/<stem>/structure_profile.json
Output: step-5/<stem>/chunks.json
Uso:
python step-5/chunker.py # tutti i documenti in step-4/
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:
step4_dir = project_root / "step-4" / stem
out_dir = project_root / "step-5" / stem
clean_md = step4_dir / "clean.md"
profile_path = step4_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 step-4/{stem}/ — skip")
return False
if not profile_path.exists():
print(f" ✗ structure_profile.json non trovato in step-4/{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="Step 5 — Chunking adattivo")
parser.add_argument("--stem", help="Nome del documento (sottocartella di step-4/)")
parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente")
args = parser.parse_args()
if args.stem:
stems = [args.stem]
else:
step4_dir = project_root / "step-4"
if not step4_dir.exists():
print(f"Errore: cartella step-4/ non trovata in {project_root}")
sys.exit(1)
stems = sorted(p.name for p in step4_dir.iterdir() if p.is_dir())
if not stems:
print(f"Errore: nessun documento trovato in step-4/")
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)
-320
View File
@@ -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 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)
-321
View File
@@ -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 (step 4)")
print(f" → Soluzione: correggi le righe spezzate in step-4/{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 step-4/{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 step-4/{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)
-147
View File
@@ -1,147 +0,0 @@
# Step 7 — Verifica ambiente
Prima di procedere con la vettorizzazione (step 8) devi avere installato:
- **Ollama** — server locale per LLM e embedding
- un **modello di embedding** (es. `nomic-embed-text`, `bge-m3`)
- un **modello LLM** (es. `qwen3.5:4b`, `qwen3:4b`)
- **chromadb** — libreria Python per il vector store
---
## 1. Installa Ollama
```bash
curl -fsSL https://ollama.com/install.sh | sh
```
Verifica che il servizio sia attivo:
```bash
ollama list
```
### Disinstalla Ollama
```bash
# Ferma e rimuovi il servizio systemd
sudo systemctl stop ollama
sudo systemctl disable ollama
sudo rm /etc/systemd/system/ollama.service
sudo systemctl daemon-reload
# Rimuovi il binario
sudo rm /usr/local/bin/ollama
# Rimuovi modelli e dati (opzionale — occupa spazio su disco)
# I modelli sono salvati sotto l'utente di sistema "ollama", non nella tua home
sudo rm -rf /usr/share/ollama
# Rimuovi l'utente e il gruppo di sistema creati dall'installer (opzionale)
sudo userdel ollama
sudo groupdel ollama
```
---
## 2. Scarica i modelli
### Modello di embedding
Per testi in italiano serve un modello multilingue — i modelli English-first producono embeddings di qualità inferiore su lingue diverse dall'inglese, con retrieval meno preciso.
Prima scelta consigliata:
```bash
ollama pull qwen3-embedding:0.6b
```
Stessa famiglia del LLM in uso (`qwen3.5`), multilingue, recente, gira comodamente in CPU.
| Modello | Dim | Dimensione | Lingue | Consigliato |
|---|---|---|---|---|
| `qwen3-embedding:0.6b` | 1024 | ~522 MB | multilingue | ✅ prima scelta |
| `nomic-embed-text-v2-moe` | 768 | ~523 MB | multilingue | ✅ seconda scelta |
| `bge-m3` | 1024 | ~1.2 GB | 100+ lingue incl. IT | ✅ terza scelta |
| `nomic-embed-text` | 768 | ~274 MB | principalmente EN | ⚠️ default corrente |
| `mxbai-embed-large` | 1024 | ~670 MB | principalmente EN | ❌ |
| `paraphrase-multilingual` | 768 | ~278 MB | multilingue | ❌ obsoleto |
| `all-minilm` | 384 | ~46 MB | principalmente EN | ❌ troppo piccolo |
Se cambi modello rispetto a quello usato in step-8, devi rieseguire la vettorizzazione con `--force` e aggiornare `EMBED_MODEL` in `step-9/config.py`.
### Modello LLM
Per RAG su testi italiani servono: buon instruction following, supporto multilingue e context window ampia (i prompt RAG includono più chunk).
Prima scelta consigliata per 8 GB RAM:
```bash
ollama pull qwen3.5:4b
```
Il progetto è pensato per la famiglia **Qwen3.5** — stessa famiglia dell'embedding consigliato (`qwen3-embedding`), context window 256K, ottimo italiano. Altri modelli sono compatibili ma non testati.
| Modello | RAM | Note |
|---|---|---|
| `qwen3.5:0.8b` | ≥ 1 GB | minimo assoluto |
| `qwen3.5:2b` | ≥ 3 GB | leggero |
| `qwen3.5:4b` | ≥ 5 GB | **consigliato per 8 GB** |
| `qwen3.5:9b` | ≥ 8 GB | lento su CPU, meglio con GPU |
Se usi un modello diverso da `qwen3.5:4b`, aggiorna `OLLAMA_MODEL` in `step-9/config.py`.
### Disinstalla un modello
```bash
ollama rm qwen3.5:4b
ollama rm nomic-embed-text
```
Per vedere tutti i modelli installati:
```bash
ollama list
```
---
## 3. Installa le dipendenze nel venv
Assicurati di avere `chromadb` installato nel `.venv`:
```bash
source .venv/bin/activate
pip install -r requirements.txt
```
---
## 4. Verifica tutto
```bash
source .venv/bin/activate
python step-7/check_env.py
```
Output atteso se tutto è a posto:
```
✅ ollama trovato nel PATH
✅ ollama risponde correttamente
✅ modello embedding trovato: nomic-embed-text:latest
✅ modello LLM trovato: qwen3.5:4b
✅ chromadb importabile
✅ Ambiente pronto — procedi con la vettorizzazione:
python step-8/ingest.py --stem <nome>
```
---
## Prossimo step
```bash
python step-8/ingest.py --stem <nome>
```
-208
View File
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""
Step 7 Verifica ambiente
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 step-7/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 README step-7)
EMBED_MODELS = [
"qwen3-embedding",
"nomic-embed-text-v2-moe",
"bge-m3",
"nomic-embed-text",
"mxbai-embed-large",
"paraphrase-multilingual",
"all-minilm",
]
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 any(base == e or base.startswith(e) for e in EMBED_MODELS) or "embed" in base
# ─── Modelli configurati in step-9/config.py ─────────────────────────────────
# Per spostare config.py alla root: cambia solo la riga qui sotto.
sys.path.insert(0, str(Path(__file__).parent.parent / "step-9"))
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 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(" → Avvia il servizio con: ollama serve")
return None
lines = result.stdout.strip().splitlines()
models = []
for line in lines[1:]: # salta l'header
parts = line.split()
if parts:
models.append(parts[0])
print("✅ ollama risponde correttamente")
return models
except FileNotFoundError:
print("❌ ollama non trovato (FileNotFoundError)")
return None
except subprocess.TimeoutExpired:
print("❌ ollama non risponde (timeout)")
print(" → Avvia il servizio con: ollama serve")
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_embed_model(available: list[str]) -> bool:
"""Verifica che il modello di embedding configurato sia disponibile."""
if CONFIGURED_EMBED:
print(f" modello configurato (step-9/config.py): {CONFIGURED_EMBED}")
found = _match(CONFIGURED_EMBED, available)
if found:
print(f"✅ embedding disponibile: {found}")
return True
print(f"{CONFIGURED_EMBED} non trovato in Ollama")
print(f" → ollama pull {CONFIGURED_EMBED}")
return False
# fallback: config.py non leggibile
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 qwen3-embedding:0.6b")
return False
def check_llm_model(available: list[str]) -> bool:
"""Verifica che il modello LLM configurato sia disponibile."""
if CONFIGURED_LLM:
print(f" modello configurato (step-9/config.py): {CONFIGURED_LLM}")
found = _match(CONFIGURED_LLM, available)
if found:
print(f"✅ LLM disponibile: {found}")
return True
print(f"{CONFIGURED_LLM} non trovato in Ollama")
print(f" → ollama pull {CONFIGURED_LLM}")
return False
# fallback: config.py non leggibile
llm_candidates = [m for m in available if not _is_embed(m)]
if llm_candidates:
print(f"✅ modello LLM trovato: {llm_candidates[0]}")
return True
print("❌ nessun modello LLM trovato")
print(f" → Consigliato per 8 GB RAM: ollama pull qwen3.5:4b")
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("─── Step 7 — Verifica ambiente ───────────────────────────────────────\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)
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 — procedi con la vettorizzazione:")
print(" python step-8/ingest.py --stem <nome>")
else:
n_fail = sum(1 for r in results if not r)
print(f"⚠️ {n_fail} problema/i rilevato/i — risolvi prima di procedere con step-8.")
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())
-114
View File
@@ -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 **`step-9/config.py`**:
```python
# step-9/config.py
EMBED_MODEL = "nomic-embed-text" # ← cambia qui
```
> Il modello scelto qui deve corrispondere a quello usato in step-9.
> 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 step-7](../step-7/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 step-8 e step-9
**`EMBED_MODEL` deve essere identico in step-8 e step-9.**
ChromaDB memorizza i vettori generati con un certo modello. Se step-9 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 step-9.
```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.
-234
View File
@@ -1,234 +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 step-9/config.py (EMBED_MODEL).
Puoi sovrascriverlo con --model, ma deve corrispondere al modello che
userai in step-9 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"
# Legge EMBED_MODEL e OLLAMA_URL da step-9/config.py (fonte di verità).
# Per spostare config.py alla root: cambia solo la riga qui sotto.
sys.path.insert(0, str(project_root / "step-9"))
from config import EMBED_MODEL, OLLAMA_URL # noqa: E402
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 step-9/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())
-109
View File
@@ -1,109 +0,0 @@
# Step 9 — Interrogazione del documento
Due modalità di interrogazione, entrambe con loop interattivo:
| Script | Modalità | Quando usarlo |
|---|---|---|
| `rag.py` | Retrieval + generazione LLM | Risposta in linguaggio naturale |
| `retrieve.py` | Solo retrieval (no LLM) | Debug, verifica chunk, ricerca semantica |
---
## Prerequisiti
- Step 8 completato (`chroma_db/` popolata)
- Ollama attivo con il modello di embedding scaricato
- Per `rag.py`: anche il modello LLM scaricato
---
## rag.py — Risposta in linguaggio naturale
```bash
source .venv/bin/activate
python step-9/rag.py --stem <nome>
```
Per ogni domanda: vettorizza la query, recupera i chunk più rilevanti da ChromaDB e genera la risposta tramite Ollama.
```
── Loop RAG ─────────────────────────────────────── (exit per uscire)
Domanda:
```
| Sintassi | Comportamento |
|---|---|
| `<testo>` | Risposta basata sul documento |
| `<testo> -v` | Risposta + chunk recuperati con score di similarità |
| `exit` | Esce dal programma |
Flusso interno:
```
domanda
▼ embed (EMBED_MODEL, Ollama)
vettore N-dim
▼ query ChromaDB — similarità coseno, top-K
chunk rilevanti
▼ build_prompt (SYSTEM_PROMPT + contesti + domanda)
▼ generate (OLLAMA_MODEL, Ollama)
risposta
```
Il LLM risponde esclusivamente dal contesto fornito. Se il contesto è irrilevante rispetto alla domanda, lo dichiara esplicitamente.
---
## retrieve.py — Retrieval puro (senza LLM)
```bash
source .venv/bin/activate
python step-9/retrieve.py --stem <nome>
```
Vettorizza la query e restituisce i chunk più simili con score di similarità — senza chiamare Ollama per la generation. Utile per verificare la qualità del retrieval e diagnosticare risposte sbagliate.
```
── Loop retrieval ──────────────────────── (exit per uscire, -f per testo completo)
Query:
```
| Sintassi | Comportamento |
|---|---|
| `<testo>` | Chunk più simili con score di similarità (testo troncato a 200 car.) |
| `<testo> -f` | Chunk più simili con testo completo |
| `exit` | Esce dal programma |
Accetta `--top-k N` per sovrascrivere il valore di `config.py` per quella sessione.
---
## Configurazione (`config.py`)
| Parametro | Default | Descrizione |
|---|---|---|
| `TOP_K` | `6` | Chunk recuperati per ogni domanda. Valori consigliati: `3``10` |
| `TEMPERATURE` | `0.0` | Deterministico a `0.0`, creativo verso `1.0`. Per RAG consigliato `0.0` |
| `NO_THINK` | `True` | Disabilita il chain-of-thought interno dei modelli Qwen3/Qwen3.5. `True` = risposta diretta, più veloce |
| `EMBED_MODEL` | `"nomic-embed-text"` | Deve corrispondere al modello usato in step-8. Se cambiato, rieseguire step-8 con `--force` |
| `OLLAMA_URL` | `"http://localhost:11434"` | Modifica solo se Ollama gira su porta o host diversi |
| `OLLAMA_MODEL` | `"qwen3.5:0.8b"` | Modello LLM. Vedi `step-7/README.md` per la scelta |
| `SYSTEM_PROMPT` | *(vedi file)* | Istruzioni di comportamento inviate al LLM. Modifica per cambiare tono, lingua o condizione di fallback |
---
## Test senza RAG
Per verificare che Ollama risponda correttamente prima di interrogare il documento:
```bash
python step-9/test_ollama.py
```
Chat diretta con il modello, senza ChromaDB. Usa gli stessi parametri di `config.py`.
-54
View File
@@ -1,54 +0,0 @@
# ─── Step 9 — Configurazione RAG ─────────────────────────────────────────────
#
# Modifica questo file per cambiare i parametri della pipeline.
#
# Uso:
# python step-9/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 (step-8).
# Se cambi questo, devi rieseguire step-8 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.\""
)
-252
View File
@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""
Step 9 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 step-9/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.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 step-9/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=(
"Step 9 — 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("─── Step 9 — 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())
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python3
"""
Step 9 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 step-9/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.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 step-9/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=(
"Step 9 — 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("─── Step 9 — 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())
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""
Test chat locale Ollama senza RAG, senza ChromaDB.
Uso: python step-9/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))
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())