6 Commits

Author SHA1 Message Date
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
42 changed files with 572 additions and 971 deletions
+2 -2
View File
@@ -27,11 +27,11 @@ __pycache__/
Thumbs.db
# Output conversione/ — generati da conversione/pipeline.py
# Output conversione/ — generati dagli script
conversione/*/
!conversione/_pipeline/
!conversione/_pipeline/transforms
!conversione/_pipeline/**
conversione/_pipeline/__pycache__/
# Output chunks/ — generati da chunks/chunker.py e chunks/verify_chunks.py
chunks/*/
+28 -19
View File
@@ -69,33 +69,41 @@ conversione/
├── clear.sh # Rimuove output di uno stem
└── _pipeline/
├── __init__.py # Re-export pubblico
├── deps.py # _check_deps() verifica opendataloader-pdf e Java
├── checker.py # check_pdf() — validazione PDF
├── converter.py # convert_pdf() — wrapper opendataloader
├── transforms.py # apply_transforms() + tutte le _t_* (~920 righe)
├── 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
├── runner.py # run() — orchestrazione 4 fasi
── validator.py # validate() + _score() + _grade()
├── 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/transforms.py` — cuore della pipeline
### `_pipeline/extract.py` — fronte PDF
Contiene ~35 trasformazioni atomiche (`_t_*`) e l'orchestratore `apply_transforms(text) -> (text, stats)`. Le trasformazioni sono tenute in un unico file perché hanno dipendenze incrociate dense e il loro **ordine è semantico** — non modificarlo senza capire le dipendenze.
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`.
Ordine logico dei gruppi (non separare):
1. **Encoding** — PUA font Symbol, accenti backtick LaTeX, moltiplicazione, micro
2. **Pulizia artefatti** — immagini, `<br>`, footnote superscript, URL, box symbol, righe ricorrenti, watermark
3. **Struttura header** — fix header+body concatenati, Capitolo inline, normalizzazione livelli numerati, `####``###`, bold, ALL-CAPS
4. **Costruzione struttura** — TOC rimosso, ALL-CAPS→`##`, sezioni numerata→`###`, ambienti matematici, articoli
5. **Testo** — merge paragrafi spezzati, whitespace, blank lines, poesia, versi
6. **Rifinitura** — header vuoti, garbage header, merge titoli isolati, frontmatter
### `_pipeline/_apply.py` — cuore della pipeline
Costanti di modulo (compilate una volta): `_SYMBOL_PUA_MAP`, `_SYMBOL_PUA_RE`, `_TABSEP_RE`, `_FM_RE`, `_VERSE_NUM_RE`, `_NUMBERED_HDR_RE`, `_BIB_MARKERS_RE`, `_WATERMARK_RE`, `_SUPERSCRIPT_RE`.
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).
@@ -146,10 +154,11 @@ Assegna un voto 0100 (A/B/C/D/F) leggendo `report.json`. Penalità principali
Quando si aggiunge una trasformazione in `apply_transforms()`:
- Ogni `_t_*` deve restituire `(testo, n_modifiche)` — il contatore alimenta `report.json`.
- Aggiungere la coppia `("stat_key", _t_nuova)` nella lista `_transforms` nel punto logicamente corretto (rispettare i gruppi sopra).
- Compilare i pattern regex a livello di modulo come costanti, non dentro la funzione.
- 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` (`_scan`), poi implementare la `_t_*` che lo rimuove.
- 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.
---
+4 -6
View File
@@ -1,15 +1,13 @@
from .deps import _check_deps
from .checker import check_pdf
from .converter import convert_pdf
from .transforms import apply_transforms
from .structure import analyze
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",
"check_pdf",
"validate_pdf",
"convert_pdf",
"apply_transforms",
"analyze",
+109
View File
@@ -0,0 +1,109 @@
"""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,
)
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_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
@@ -3,7 +3,8 @@ import re
from collections import Counter
from ._constants import (
_WATERMARK_RE, _TABSEP_RE, _SUPERSCRIPT_RE, _FOOTNOTE_BODY_RE,
_WATERMARK_RE, _TABSEP_RE, _SUPERSCRIPT_RE, _FOOTNOTE_BODY_RE, _DOTLEADER_RE,
_PAGE_MARKER_RE, _STANDALONE_NUM_RE, _UNDERSCORE_SEP_RE,
)
@@ -47,15 +48,9 @@ def _t_remove_formula_labels(text: str) -> tuple[str, int]:
def _t_remove_dotleaders(text: str) -> tuple[str, int]:
_DOTLEADER_RE = r"^[^\n]*(?:(?:\. ){3,}|\.{4,})[^\n]*$"
n = len(re.findall(_DOTLEADER_RE, text, re.MULTILINE))
text = re.sub(_DOTLEADER_RE, "", text, flags=re.MULTILINE)
text = re.sub(
r"(?m)^(i{1,3}|iv|vi{0,3}|ix|xi{0,2}|x)$",
"",
text,
flags=re.IGNORECASE,
)
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
@@ -103,4 +98,30 @@ def _t_remove_watermarks(text: str) -> tuple[str, int]:
def _t_remove_urls(text: str) -> tuple[str, int]:
return re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text), 0
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
@@ -123,15 +123,19 @@ _NUMBERED_HDR_RE = re.compile(
)
_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(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|"
@@ -159,3 +163,7 @@ _TOC_ITEM_RE = re.compile(
_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*$")
@@ -2,7 +2,7 @@
import re
from ._constants import (
_TOC_KEYWORDS, _BIB_MARKERS_RE,
_TOC_KEYWORDS, _BIB_MARKERS_RE, _FOOTNOTE_AUTHOR_RE,
_TOC_ITEM_RE, _TOC_HDR_WITH_PAGE_RE,
)
from ._helpers import (
@@ -28,6 +28,9 @@ def _t_remove_toc(text: str) -> tuple[str, int]:
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)
@@ -118,10 +121,23 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
content = m.group(2).strip()
if content.endswith(".") and len(content) > 40:
return m.group(0)
if _BIB_MARKERS_RE.search(content):
# 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
return f"### {m.group(1)}.\n\n{content}"
# 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)
@@ -136,13 +152,22 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
def _aphorism_repl(m: re.Match) -> str:
nonlocal count
content = m.group(2).strip()
if _BIB_MARKERS_RE.search(content):
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
return m.group(0)
count += 1
return f"\n\n### {m.group(1)}.\n\n{content}"
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"^-\s+(\d{1,3})\.\s+(.{10,})$",
r"^-[ \t]+(\d{1,3})\.[ \t]+(.{10,})$",
_aphorism_repl,
text,
flags=re.MULTILINE,
@@ -152,7 +177,7 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
nonlocal count
num = m.group(1)
content = m.group(2).strip()
if _BIB_MARKERS_RE.search(content):
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
return m.group(0)
count += 1
split = re.search(
@@ -176,6 +201,45 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
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)
-51
View File
@@ -1,51 +0,0 @@
from pathlib import Path
def check_pdf(pdf_path: Path) -> tuple[bool, str]:
"""Validazione rapida: esistenza, leggibilità, testo 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}"
-62
View File
@@ -1,62 +0,0 @@
from pathlib import Path
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.
Scrive il file nella out_dir e restituisce il percorso.
Parametri scelti per output RAG-ottimale:
- keep_line_breaks=False → testo fluente, no hard-wrap PDF
- reading_order="xycut" → corregge ordine multi-colonna (XY-Cut++)
- sanitize=False → preserva il testo originale
- image_output="off" → nessuna immagine estratta né referenziata
- table_method="cluster" → rileva tabelle senza bordi visibili
- content_safety_off → evita filtraggio di footnote (tiny) e layer OCG
- use_struct_tree → attivo solo se il PDF è taggato (Word/InDesign)
"""
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,
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
-23
View File
@@ -1,23 +0,0 @@
import subprocess
import sys
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)
+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
+4 -13
View File
@@ -1,10 +1,10 @@
import json
import re
from collections import Counter
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(
@@ -59,26 +59,17 @@ def build_report(
break
return hits
_math_sym_scan = re.compile(
r"[=+∈∀∃≤≥∞∑∫∂→↔⊂⊃∩∪αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ]"
)
_ex_trigger_scan = 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,
)
def _scan_formula_headers(max_n: int = 10) -> list[dict]:
hits = []
for i, line in enumerate(text_lines):
m = re.match(r"^(#{2,3})\s+(.+)$", line)
m = _MATH_HDR_RE.match(line)
if not m:
continue
body = m.group(2)
if len(body) <= 100:
continue
has_math = len(_math_sym_scan.findall(body)) >= 3
has_ex = bool(_ex_trigger_scan.search(body))
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:
+112 -30
View File
@@ -1,17 +1,66 @@
import json
import sys
import tempfile
import threading
import time
from pathlib import Path
from .checker import check_pdf
from .converter import convert_pdf
from .transforms import apply_transforms
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
@@ -27,57 +76,87 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
print(f" (usa --force per rieseguire)")
return True
# [1] Validazione
# [1] Validazione + metadati
print(" [1/4] Validazione PDF...")
ok, msg = check_pdf(pdf_path)
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 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
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")
print(f" ✅ Markdown grezzo: {size_kb} KB, {n_lines} righe ({elapsed:.1f}s)")
# [3] Pulizia strutturale
print(" [3/4] Pulizia strutturale...")
clean_text, t = apply_transforms(raw_text)
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"Simboli PUA corretti: {t['n_simboli_pua_corretti']}")
print(f" Immagini rimosse: {t['n_immagini_rimosse']}")
print(f" Note rimosse: {t['n_note_rimosse']}")
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" Articoli → ###: {t['n_articoli_estratti']}")
print(f" Ambienti matematici: {t['n_ambienti_matematici']}")
print(f" Titoli header uniti: {t['n_titoli_uniti']}")
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" Versi poesia riprist.: {t['n_versi_ripristinati']}")
print(f" Header verso demotati: {t['n_header_verso_demotati']}")
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
@@ -104,8 +183,11 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
for w in profile["avvertenze"]:
print(f" ⚠️ {w}")
build_report(stem, out_dir, clean_text, t, profile, reduction)
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
@@ -1,4 +0,0 @@
"""Package transforms: pipeline di pulizia strutturale per Markdown RAG."""
from ._apply import apply_transforms
__all__ = ["apply_transforms"]
@@ -1,96 +0,0 @@
"""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,
)
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,
)
from ._structure import (
_t_remove_toc, _t_remove_orphan_toc, _t_allcaps_to_headers,
_t_numbered_sections, _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) -> 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.
"""
_has_ex = bool(re.search(r"\b(Esercizi|Exercises|Problems|Homework)\b", text, re.IGNORECASE))
_transforms: list[tuple[str | None, object]] = [
# 1. Encoding
("n_simboli_pua_corretti", _t_fix_symbol_font),
("n_accenti_corretti", _t_fix_accents),
("n_moltiplicazioni_corrette", _t_fix_multiplication),
("n_micro_corretti", _t_fix_micro),
# 2. Pulizia artefatti
("n_immagini_rimosse", _t_remove_images),
("n_br_rimossi", _t_fix_br),
("n_tabsep_rimossi", _t_fix_tabsep),
("n_note_rimosse", _t_remove_footnotes),
("n_simboli_math_rimossi", _t_fix_math_symbols),
("n_formule_rimossi", _t_remove_formula_labels),
("n_dotleader_rimossi", _t_remove_dotleaders),
("n_righe_ricorrenti_rimosse", _t_remove_recurring_lines),
# 3. Struttura header
("n_header_concat_fixati", _t_fix_header_concat),
(None, _t_extract_capitolo),
("n_header_numerati_normalizzati", _t_normalize_numbered_headings),
(None, _t_normalize_header_levels),
(None, _t_remove_header_bold),
(None, _t_normalize_allcaps_headers),
# 4. Costruzione struttura
("toc_rimosso", _t_remove_toc),
("n_toc_orfani_rimossi", _t_remove_orphan_toc),
("n_header_allcaps", _t_allcaps_to_headers),
("n_sezioni_numerate", partial(_t_numbered_sections, has_exercises=_has_ex)),
("n_ambienti_matematici", _t_extract_math),
("n_articoli_estratti", _t_extract_articles),
# 5. Testo
("n_paragrafi_uniti", _t_merge_paragraphs),
(None, _t_normalize_whitespace),
(None, _t_collapse_blank_lines),
("n_versi_ripristinati", _t_restore_poetry_lines),
("n_header_verso_demotati", _t_demote_verse_headers),
(None, _t_remove_urls),
# 6. Rifinitura
(None, _t_remove_empty_headers),
("n_titoli_uniti", _t_merge_title_headers),
(None, lambda t: (re.sub(r"(?m)^(#{1,6}.+?)\s*\|\s*\d{1,3}\s*$", r"\1", t), 0)),
("n_garbage_headers_rimossi", _t_remove_garbage_headers),
("n_formula_headers_demotati", _t_math_header_demotion),
("n_frontmatter_rimossi", _t_remove_frontmatter),
("n_watermark_rimossi", _t_remove_watermarks),
]
stats: dict = {}
for stat_key, fn in _transforms:
text, n = fn(text)
if stat_key:
stats[stat_key] = stats.get(stat_key, 0) + n
stats["toc_rimosso"] = bool(stats.get("toc_rimosso", 0))
return text, stats
+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"
@@ -1,560 +0,0 @@
# Pipeline ottimizzazione PDF→Markdown — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminare la necessità di revisione manuale del `clean.md` ottimizzando i parametri di opendataloader-pdf e aggiungendo trasformazioni mirate per tutti i tipi di PDF.
**Architecture:** Quattro file modificati: `converter.py` (parametri adattivi + rilevamento PDF taggato), `transforms.py` (PUA bracket TeX + demozione header-formula), `report.py` (nuova metrica residua), `validator.py` (nuova penalità). Nessun cambio all'API pubblica di `_pipeline`.
**Tech Stack:** Python 3.12, opendataloader-pdf (Java), PyMuPDF (fitz), regex
---
## File modificati
| File | Tipo | Responsabilità |
|------|------|----------------|
| `conversione/_pipeline/converter.py` | Modify | `_is_tagged_pdf()` + nuovi parametri convert |
| `conversione/_pipeline/transforms.py` | Modify | PUA bracket TeX + `_t_math_header_demotion` |
| `conversione/_pipeline/report.py` | Modify | `formula_headers_residui` nella sezione residui |
| `conversione/_pipeline/validator.py` | Modify | Penalità formula headers |
---
## Task 1: Converter adattivo — `_is_tagged_pdf()` + nuovi parametri
**Files:**
- Modify: `conversione/_pipeline/converter.py`
- [ ] **Step 1: Leggi il file attuale**
```bash
cat conversione/_pipeline/converter.py
```
- [ ] **Step 2: Sostituisci interamente il contenuto**
Il nuovo `converter.py` aggiunge `_is_tagged_pdf()` (usa fitz per controllare `StructTreeRoot` nel catalog del PDF) e passa i nuovi parametri a `opendataloader_pdf.convert()`:
- `table_method="cluster"` — sempre attivo, migliora tabelle senza bordi
- `content_safety_off=["tiny", "hidden-ocg"]` — evita filtraggio di footnote e layer OCG
- `use_struct_tree=tagged` — attivo solo se PDF è taggato
```python
from pathlib import Path
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.
Scrive il file nella out_dir e restituisce il percorso.
Parametri scelti per output RAG-ottimale:
- keep_line_breaks=False → testo fluente, no hard-wrap PDF
- reading_order="xycut" → corregge ordine multi-colonna (XY-Cut++)
- sanitize=False → preserva il testo originale
- image_output="off" → nessuna immagine estratta né referenziata
- table_method="cluster" → rileva tabelle senza bordi visibili
- content_safety_off → evita filtraggio di footnote e layer OCG
- use_struct_tree → attivo se PDF è taggato (Word/InDesign)
"""
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,
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
```
- [ ] **Step 3: Verifica sintattica**
```bash
.venv/bin/python -c "from conversione._pipeline.converter import convert_pdf, _is_tagged_pdf; print('OK')"
```
Atteso: `OK`
- [ ] **Step 4: Commit**
```bash
git add conversione/_pipeline/converter.py
git commit -m "feat(converter): parametri adattivi — use_struct_tree, cluster tables, content-safety"
```
---
## Task 2: Aggiunta PUA bracket TeX (U+F8EBU+F8FE)
**Files:**
- Modify: `conversione/_pipeline/transforms.py` (sezione `_SYMBOL_PUA_MAP`, righe ~28127)
Questi codepoint sono pezzi di parentesi/bracket grandi del font Computer Modern (TeX), non ricostruibili come singolo simbolo → mappati a `""`.
- [ ] **Step 1: Aggiungi le entries mancanti alla fine di `_SYMBOL_PUA_MAP`**
Individua la riga `"": "", # bracket extension piece (non ricostruibile)` (circa riga 122) e aggiungi **dopo** l'ultima entry esistente della mappa (prima della `}`):
```python
"": "", # TeX large paren left
"": "", # TeX large paren extension
"": "", # TeX large paren right
"": "", # TeX large paren right extension
"": "", # TeX large bracket left
"": "", # TeX large bracket extension
"": "", # TeX brace top-left
"": "", # TeX brace mid
"": "", # TeX brace mid-right
"": "", # TeX brace extension
"": "", # TeX brace right
"": "", # TeX bracket right large
"": "", # TeX bracket right extension
"": "", # TeX bracket right close
"": "", # TeX integral large
"": "", # TeX integral extension
"": "", # TeX integral top
"": "", # TeX radical top
"": "", # TeX radical extension
"": "", # TeX arrowhead
```
- [ ] **Step 2: Verifica che _SYMBOL_PUA_RE si aggiorni automaticamente**
```bash
.venv/bin/python -c "
from conversione._pipeline.transforms import _SYMBOL_PUA_MAP, _SYMBOL_PUA_RE
pua_chars = ['', '', '', '']
for c in pua_chars:
assert c in _SYMBOL_PUA_MAP, f'Manca {repr(c)}'
assert _SYMBOL_PUA_RE.search(c), f'Regex non cattura {repr(c)}'
print(f'OK — {len(_SYMBOL_PUA_MAP)} PUA chars mappati')
"
```
Atteso: `OK — N PUA chars mappati` (N > 90)
- [ ] **Step 3: Verifica sostituzione su testo di esempio**
```bash
.venv/bin/python -c "
from conversione._pipeline.transforms import apply_transforms
testo = 'Sia x = f(n) e n la parentesi grande.'
pulito, stats = apply_transforms(testo)
assert '' not in pulito
assert '' not in pulito
print('Testo pulito:', repr(pulito))
print('PUA corretti:', stats['n_simboli_pua_corretti'])
"
```
Atteso: nessun PUA nel testo pulito, `n_simboli_pua_corretti` > 0.
- [ ] **Step 4: Commit**
```bash
git add conversione/_pipeline/transforms.py
git commit -m "feat(transforms): aggiungi PUA bracket TeX U+F8EB-F8FE alla mappa simboli"
```
---
## Task 3: Nuova trasformazione `_t_math_header_demotion`
**Files:**
- Modify: `conversione/_pipeline/transforms.py`
Demota a testo semplice gli header `##`/`###` che sono enunciati di esercizi o formule lunghe (non titoli di sezione reali).
**Criteri di demozione** (almeno uno tra math e exercise deve valere):
- Livello `##` o `###`
- Lunghezza testo (senza `#`) > 100 caratteri
- `math`: ≥ 3 simboli matematici nell'header (da set: `=`, `+`, `∈`, `∀`, `∃`, `≤`, `≥`, `∞`, `∑`, `∫`, `∂`, `→`, `↔`, `⊂`, `⊃`, `∩`, ``, lettere greche Unicode U+03B1U+03C9 e U+0391U+03A9)
- `exercise`: matcha pattern traccia (`\b(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show that|Compute|Calculate|Dimostrare|Verificare)\b`)
**Output**: rimuove `#+ `. Se la riga inizia con `N. ` (numero + punto), converte in `**N.** resto`. Altrimenti testo plain.
- [ ] **Step 1: Aggiungi costante regex a livello di modulo** (dopo le costanti esistenti, prima di `_SYMBOL_PUA_MAP`)
Trova la riga `_VERSE_NUM_RE = re.compile(` (circa riga 160) e aggiungi **dopo**:
```python
_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)
```
- [ ] **Step 2: Aggiungi la funzione `_t_math_header_demotion`** (prima dell'orchestratore `apply_transforms`)
Trova la riga `# ─── Orchestratore` e aggiungi **prima**:
```python
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
```
- [ ] **Step 3: Registra la trasformazione in `_transforms`**
Nell'orchestratore `apply_transforms`, trova la riga:
```python
("n_garbage_headers_rimossi", _t_remove_garbage_headers),
```
e aggiungi **dopo**:
```python
("n_formula_headers_demotati", _t_math_header_demotion),
```
- [ ] **Step 4: Aggiungi la stat key al print in `runner.py`**
Trova in `conversione/_pipeline/runner.py` il blocco di print delle statistiche (dopo `apply_transforms`) e aggiungi:
```python
print(f" Formula-hdr demotati: {t['n_formula_headers_demotati']}")
```
- [ ] **Step 5: Verifica su caso sintetico**
```bash
.venv/bin/python -c "
from conversione._pipeline.transforms import apply_transforms
# Caso 1: header esercizio lungo → deve essere demotato
testo = '### 3. Si dimostri la formula per le equazioni di secondo grado ax^2 + bx + c = 0 e si analizzi il segno del discriminante b^2 - 4ac per tutti i valori reali.'
pulito, stats = apply_transforms(testo)
assert '###' not in pulito, f'Header non demotato: {pulito!r}'
print('Caso 1 OK:', pulito[:80])
# Caso 2: header titolo corto → NON deve essere demotato
testo2 = '### Teorema di Cauchy'
pulito2, _ = apply_transforms(testo2)
assert '###' in pulito2, f'Header legittimo demotato: {pulito2!r}'
print('Caso 2 OK:', pulito2)
# Caso 3: header con molti simboli math + lungo → demotato
testo3 = '### Sia f: R→R tale che ∀x∈R si abbia f(x) = ∑_{n=0}^{∞} aₙxⁿ con ∫f dx = g(x) + C per ogni x∈[a,b].'
pulito3, stats3 = apply_transforms(testo3)
print('Caso 3:', '###' not in pulito3, stats3.get('n_formula_headers_demotati'))
print('Stats:', stats.get('n_formula_headers_demotati'))
"
```
Atteso: Caso 1 e 3 demotati, Caso 2 intatto.
- [ ] **Step 6: Commit**
```bash
git add conversione/_pipeline/transforms.py conversione/_pipeline/runner.py
git commit -m "feat(transforms): aggiungi _t_math_header_demotion per header esercizi e formule"
```
---
## Task 4: `report.py` — metrica `formula_headers_residui`
**Files:**
- Modify: `conversione/_pipeline/report.py`
- [ ] **Step 1: Aggiungi funzione di scan formula-header e integrala nel report**
Nella funzione `build_report()`, dopo la definizione di `_scan()` (circa riga 53), aggiungi:
```python
def _scan_formula_headers(max_n: int = 10) -> list[dict]:
_math_sym = re.compile(
r"[=+∈∀∃≤≥∞∑∫∂→↔⊂⊃∩∪αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ]"
)
_ex_trigger = 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,
)
hits = []
for i, line in enumerate(text_lines):
m = re.match(r"^(#{2,3})\s+(.+)$", line)
if not m:
continue
body = m.group(2)
if len(body) <= 100:
continue
has_math = len(_math_sym.findall(body)) >= 3
has_ex = bool(_ex_trigger.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
```
- [ ] **Step 2: Aggiungi la metrica ai `residui`**
Trova nel dict `residui` la riga:
```python
"pua_markers": _scan(r'[-]'),
```
e aggiungi **dopo**:
```python
"formula_headers": _scan_formula_headers(),
```
Poi nel dict principale `report["residui"]`, trova la riga:
```python
"pua_markers_esempi": residui["pua_markers"],
```
e aggiungi **dopo**:
```python
"formula_headers": len(residui["formula_headers"]),
"formula_headers_esempi": residui["formula_headers"],
```
- [ ] **Step 3: Verifica**
```bash
.venv/bin/python -c "
import json
from pathlib import Path
from conversione._pipeline.report import build_report
from conversione._pipeline.transforms import apply_transforms
testo = open('conversione/analisi1/raw.md').read()
clean, t = apply_transforms(testo)
from conversione._pipeline.structure import analyze
tmp = Path('/tmp/test_report')
tmp.mkdir(exist_ok=True)
(tmp / 'clean.md').write_text(clean)
profile = analyze(tmp / 'clean.md')
rp = build_report('test', tmp, clean, t, profile, 5.0)
r = json.loads(rp.read_text())
print('formula_headers residui:', r['residui']['formula_headers'])
print('formula_headers esempi:', len(r['residui']['formula_headers_esempi']))
"
```
Atteso: count numerico (può essere 0 se la demozione ha funzionato bene), nessun errore.
- [ ] **Step 4: Commit**
```bash
git add conversione/_pipeline/report.py
git commit -m "feat(report): aggiungi metrica formula_headers_residui"
```
---
## Task 5: `validator.py` — penalità formula headers
**Files:**
- Modify: `conversione/_pipeline/validator.py`
- [ ] **Step 1: Aggiungi la penalità in `_score()`**
Trova in `_score()` la riga:
```python
_pen("pua_markers", 2, 20, "caratteri PUA font Symbol")
```
e aggiungi **dopo**:
```python
_pen("formula_headers", 3, 15, "formula/esercizio come header")
```
- [ ] **Step 2: Aggiungi colonna `fhdr` nell'output tabellare di `validate()`**
Trova in `validate()` la riga che costruisce `header`:
```python
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"
)
```
Sostituiscila con:
```python
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"
)
```
Trova il `print(...)` dentro il loop `for r in rows:` e aggiungi `fhdr`:
```python
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)}"
)
```
Aggiorna anche la riga finale `print("\nColonne: ...")`:
```python
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"
)
```
- [ ] **Step 3: Verifica**
```bash
.venv/bin/python -c "
from conversione._pipeline.validator import _score
r = {'structure': {'livello_struttura': 3}, 'anomalie': {}, 'residui': {'formula_headers': 5}}
score, detail = _score(r)
print(score, detail)
assert any('formula' in d for d in detail), 'Penalità formula non applicata'
print('OK')
"
```
Atteso: penalità `formula/esercizio come header ×5 15` nel detail.
- [ ] **Step 4: Commit**
```bash
git add conversione/_pipeline/validator.py
git commit -m "feat(validator): aggiungi penalità formula_headers, colonna fhdr nel report"
```
---
## Task 6: Test di integrazione su analisi1
- [ ] **Step 1: Riesegui la pipeline su analisi1**
```bash
.venv/bin/python conversione/ --stem analisi1 --force 2>&1
```
Atteso: completamento senza errori, print `Formula-hdr demotati: N` visibile.
- [ ] **Step 2: Valida e confronta con il report precedente**
```bash
.venv/bin/python conversione/ validate analisi1 --detail
```
Confronta con il vecchio voto del `report.json` originale. Il voto deve essere ≥ al precedente.
- [ ] **Step 3: Verifica riduzione PUA bracket**
```bash
python3 -c "
import json
r = json.load(open('conversione/analisi1/report.json'))
pua = r['residui']['pua_markers']
fhdr = r['residui'].get('formula_headers', 'N/A')
print(f'PUA residui: {pua} (era 10+ prima)')
print(f'Formula headers residui: {fhdr}')
"
```
Atteso: `pua_markers` ridotto rispetto al run precedente (era 10 nel report originale).
- [ ] **Step 4: Commit finale se tutto OK**
```bash
git add conversione/analisi1/
git commit -m "chore: rigenera output analisi1 con pipeline ottimizzata"
```
@@ -1,80 +0,0 @@
# Pipeline ottimizzazione — Design Spec
*2026-04-30*
## Obiettivo
Eliminare la necessità di revisione manuale del `clean.md` per tutti i tipi di PDF (accademici/matematici, giuridici, tecnici) ottimizzando i parametri di opendataloader-pdf e aggiungendo trasformazioni mirate.
## Scope
Nessun hybrid backend. Solo Java + trasformazioni Python.
---
## 1. `converter.py` — Parametri adattivi
### 1.1 Rilevamento PDF taggato
Funzione `_is_tagged_pdf(pdf_path) -> bool` usando PyMuPDF (`fitz`):
```python
doc = fitz.open(str(pdf_path))
tagged = "StructTreeRoot" in doc.pdf_catalog()
doc.close()
```
### 1.2 Nuovi parametri fissi (tutti i PDF)
- `table_method="cluster"` — tabelle senza bordi visibili
- `content_safety_off=["tiny", "hidden-ocg"]` — evita filtraggio di footnote e layer OCG
### 1.3 Parametro condizionale
- `use_struct_tree=tagged` — attivo solo se il PDF è taggato
Una sola conversione Java, zero overhead per PDF non taggati.
---
## 2. `transforms.py` — Due aggiunte
### 2.1 PUA bracket TeX (U+F8EBF8F8)
Aggiunge al `_SYMBOL_PUA_MAP` i glifoni bracket di Computer Modern font che appaiono come PUA:
`U+F8EB, U+F8EC, U+F8ED, U+F8EE, U+F8EF, U+F8F0, U+F8F1, U+F8F2, U+F8F3, U+F8F4, U+F8F5, U+F8F6, U+F8F7, U+F8F8, U+F8F9, U+F8FA, U+F8FB, U+F8FC, U+F8FD, U+F8FE`
→ tutti mappati a `""` (pezzi di parentesi non ricostruibili come singolo glifo)
Il `_SYMBOL_PUA_RE` si aggiorna automaticamente essendo costruito dalla mappa.
### 2.2 Nuova trasformazione `_t_math_header_demotion`
Demota a testo semplice gli header `##`/`###` che sono in realtà enunciati di esercizi o formule lunghe.
**Criteri di demozione** (tutti devono valere):
- Livello `##` o `###`
- Lunghezza testo > 100 caratteri
- Almeno uno tra:
- ≥ 3 simboli matematici (`=`, `+`, `∈`, `∀`, `∃`, `≤`, `≥`, `∞`, lettere greche Unicode, `lim`, `sup`, `inf`, `∑`, `∫`)
- Matcha pattern traccia esercizio: `(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show|Compute|Calculate)\b`
**Output**: rimuove `#+ ` iniziale. Se numerata (`N. testo`), converte in `**N.** testo`. Altrimenti testo plain.
**Posizione in `_transforms`**: gruppo "Rifinitura", dopo `_t_garbage_headers`.
**Stat key**: `n_formula_headers_demotati`
---
## 3. `report.py` — Nuova metrica residua
`build_report()` aggiunge contatore `formula_headers_residui`:
- Conta header `##`/`###` nel `clean.md` finale che superano ancora i criteri math (sopra)
- Mostra fino a 3 esempi in `formula_headers_esempi`
---
## 4. `validator.py` — Nuova penalità
| Problema | Penalità | Cap |
|----------|----------|-----|
| Formula/esercizio come header residuo | 3/cad | 15 |
---
## File modificati
1. `conversione/_pipeline/converter.py``_is_tagged_pdf()` + nuovi parametri
2. `conversione/_pipeline/transforms.py` — PUA map + `_t_math_header_demotion`
3. `conversione/_pipeline/report.py``formula_headers_residui`
4. `conversione/_pipeline/validator.py` — nuova penalità