feat(pipeline): 10 nuovi transform e metriche residui estese

- 0_br: rimozione tag <br> residui da tabelle PDF
- 0_tabsep: rimozione separatori | | e |---| (doppio pass pre/post merge)
- 0a2: correzione encoding " → × (moltiplicazione, solo digit-before)
- 0a3: correzione encoding ! → µ prima di unità SI
- 0a4: rimozione label formule inline [N.M]
- 9c: filtro garbage headers — simboli puri, abbreviazioni brevi, prefisso ...
- 9d: rimozione sezioni frontmatter (URL, email, copyright, affiliazione)
- build_report: tracking esteso br_inline, simboli_encoding, formule_inline
This commit is contained in:
2026-04-17 09:19:44 +02:00
parent 9910a70823
commit ea721774da
+123 -12
View File
@@ -349,6 +349,12 @@ def apply_transforms(text: str) -> tuple[str, dict]:
"toc_rimosso": False, "toc_rimosso": False,
"n_immagini_rimosse": 0, "n_immagini_rimosse": 0,
"n_accenti_corretti": 0, "n_accenti_corretti": 0,
"n_moltiplicazioni_corrette": 0,
"n_micro_corretti": 0,
"n_br_rimossi": 0,
"n_formule_rimossi": 0,
"n_garbage_headers_rimossi": 0,
"n_frontmatter_rimossi": 0,
"n_dotleader_rimossi": 0, "n_dotleader_rimossi": 0,
"n_header_concat_fixati": 0, "n_header_concat_fixati": 0,
"n_articoli_estratti": 0, "n_articoli_estratti": 0,
@@ -357,12 +363,26 @@ def apply_transforms(text: str) -> tuple[str, dict]:
"n_header_allcaps": 0, "n_header_allcaps": 0,
"n_sezioni_numerate": 0, "n_sezioni_numerate": 0,
"n_paragrafi_uniti": 0, "n_paragrafi_uniti": 0,
"n_tabsep_rimossi": 0,
} }
# 0. Rimuovi riferimenti immagini (artefatti opendataloader-pdf) # 0. Rimuovi riferimenti immagini (artefatti opendataloader-pdf)
stats["n_immagini_rimosse"] = len(re.findall(r"!\[[^\]]*\]\([^)]*\)", text)) stats["n_immagini_rimosse"] = len(re.findall(r"!\[[^\]]*\]\([^)]*\)", text))
text = re.sub(r"!\[[^\]]*\]\([^)]*\)\s*", "", text) text = re.sub(r"!\[[^\]]*\]\([^)]*\)\s*", "", text)
# 0_br. Rimuovi tag <br> residui da tabelle e blocchi formula PDF
# Nelle celle di tabella produce spazio; nel testo inline elimina rumore.
stats["n_br_rimossi"] = len(re.findall(r"<br>", text, re.IGNORECASE))
text = re.sub(r"<br>\s*", " ", text, flags=re.IGNORECASE)
# 0_tabsep. Rimuovi separatori tabella PDF: "| |" (riga vuota) e "|---|" (separatore).
# Nascono da tabelle non strutturate nel PDF. Rimossi PRIMA del merge paragrafi
# (step 5) altrimenti "|---|" viene fuso con il paragrafo successivo producendo
# righe tipo "|---| Una caratterizzazione analoga...".
_pat_tabsep = re.compile(r"(?m)^\|\s*\|\s*$|^\|---\|?\s*$")
stats["n_tabsep_rimossi"] = len(_pat_tabsep.findall(text))
text = _pat_tabsep.sub("", text)
# 0a. Fix artefatti backtick da PDF LaTeX: `e→è, e`→è, sar`a→sarà, ecc. # 0a. Fix artefatti backtick da PDF LaTeX: `e→è, e`→è, sar`a→sarà, ecc.
# I PDF prodotti da LaTeX estraggono gli accenti gravi come backtick separati # I PDF prodotti da LaTeX estraggono gli accenti gravi come backtick separati
# dalla vocale accentata. Esempi: "`e" → "è", "puo`" → "può", "sar`a" → "sarà" # dalla vocale accentata. Esempi: "`e" → "è", "puo`" → "può", "sar`a" → "sarà"
@@ -383,6 +403,30 @@ def apply_transforms(text: str) -> tuple[str, dict]:
text = re.sub(r"`", "", text) text = re.sub(r"`", "", text)
stats["n_accenti_corretti"] += n_bt_orfani stats["n_accenti_corretti"] += n_bt_orfani
# 0a2. Fix segno di moltiplicazione "→× (encoding font PDF non-standard)
# Esempi: 2"107 → 2×107, 2"(10-2 m)3 → 2×(10-2 m)3
# Lookbehind SOLO su cifra: evita falsi positivi tipo t1"t0 (→ limite)
# o h"hf (→ differenza) dove la lettera prima della " non indica prodotto.
_n_cross = len(re.findall(r'(?<=[0-9])"(?=[0-9(])', text))
text = re.sub(r'(?<=[0-9])"(?=[0-9(])', '×', text)
stats["n_moltiplicazioni_corrette"] = _n_cross
# 0a3. Fix prefisso micro !→µ prima di unità SI note
# "1 !m" → "1 µm", "1 !A" → "1 µA", "3 !s-1" → "3 µs-1"
# Pattern stretto: cifra + spazio opzionale + ! + lettera unità SI a scelta ristretta.
# Non tocca "4! steradianti" (spazio dopo !) né "mol!K" (non preceduto da cifra).
_SI_UNITS_RE = r'[mAsgVWFHTKNJClΩ]'
_n_micro = len(re.findall(rf'\d\s*!(?={_SI_UNITS_RE})', text))
text = re.sub(rf'(\d)\s*!({_SI_UNITS_RE})', r'\1 µ\2', text)
stats["n_micro_corretti"] = _n_micro
# 0a4. Rimuovi label formule inline [N.M] — es. [3.4], [10.7], [5.25]
# Non aggiungono valore semantico per il RAG; restano come rumore numerico.
# Preserva [N] senza punto (riferimenti bibliografici/note legittime).
n_form_before = len(re.findall(r"\[\d+\.\d+\]", text))
text = re.sub(r"\s*\[\d+\.\d+\]\s*", " ", text)
stats["n_formule_rimossi"] = n_form_before
# 0b_pre. Rimuovi righe con dot-leader (voci di indice/sommario) # 0b_pre. Rimuovi righe con dot-leader (voci di indice/sommario)
# Esempi: "- 1.1 Alfabeto greco . . . . . . 1", "3.4 Continuità . . . . 205" # Esempi: "- 1.1 Alfabeto greco . . . . . . 1", "3.4 Continuità . . . . 205"
# Pattern: almeno 3 occorrenze di ". " consecutive nella riga # Pattern: almeno 3 occorrenze di ". " consecutive nella riga
@@ -624,6 +668,9 @@ def apply_transforms(text: str) -> tuple[str, dict]:
i += 1 i += 1
text = "\n\n".join(merged) text = "\n\n".join(merged)
# Secondo pass: rimuovi prefisso |---| eventualmente rimasto dopo il merge paragrafi
text = re.sub(r"(?m)^\|---\|\s*", "", text)
# 6. Normalizza whitespace multiplo interno alle righe # 6. Normalizza whitespace multiplo interno alle righe
lines = text.split("\n") lines = text.split("\n")
text = "\n".join( text = "\n".join(
@@ -655,6 +702,61 @@ def apply_transforms(text: str) -> tuple[str, dict]:
text, n_titoli = _merge_title_headers(text) text, n_titoli = _merge_title_headers(text)
stats["n_titoli_uniti"] = n_titoli stats["n_titoli_uniti"] = n_titoli
# 9c. Rimuovi garbage headers: header ### senza parole reali o con solo
# abbreviazioni matematiche. Esempi: "### ( vm)", "### #", "### ! =",
# "### (am)", "### 2. Il valore di hf si deter- mina risolvendo mg(h!hf)"
# Questi nascono da espressioni matematiche scambiate per titoli di sezione.
# Il corpo rimane nel testo e viene accorpato alla sezione precedente.
def _is_garbage_header(content: str) -> bool:
# Header con prefisso "..." — frammento di formula (es. "...Di", "...vi")
if content.lstrip().startswith("..."):
return True
# Nessuna sequenza alfabetica ≥ 2 char
if not re.search(r"[A-Za-zÀ-ÿ]{2,}", content):
return True
# Abbreviazione corta in parentesi opzionali: "(vm)", "( am)", "(am)"
if re.fullmatch(r"\(?\s*[A-Za-z]{1,4}\s*\)?", content.strip()):
return True
# Header molto lungo (>60ch) con artefatti formula inline
if len(content) > 60 and re.search(r"[!%#]\w|\w[!%#]|\b\w+-\s*\w", content):
return True
return False
lines = text.split("\n")
new_lines = []
for line in lines:
m = re.match(r"^#{1,6} (.+)$", line)
if m and _is_garbage_header(m.group(1)):
stats["n_garbage_headers_rimossi"] += 1
continue
new_lines.append(line)
text = "\n".join(new_lines)
text = re.sub(r"\n{3,}", "\n\n", text)
# 9d. Rimuovi sezioni frontmatter: header senza numero + corpo corto con
# URL, email, affiliazione, copyright, edizione — metadati non-contenuto.
_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,
)
blocks = re.split(r"\n{2,}", text)
cleaned = []
for i, block in enumerate(blocks):
stripped = block.strip()
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:
stats["n_frontmatter_rimossi"] += 1
continue
cleaned.append(block)
text = re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned))
return text, stats return text, stats
@@ -831,10 +933,13 @@ def build_report(
return hits return hits
residui = { residui = {
"backtick": _scan(r"`"), "backtick": _scan(r"`"),
"dotleader": _scan(r"(?:\. ){3,}"), "dotleader": _scan(r"(?:\. ){3,}"),
"url": _scan(r"^(https?://|www\.)\S+"), "url": _scan(r"^(https?://|www\.)\S+"),
"immagini": _scan(r"!\[[^\]]*\]\([^)]*\)"), "immagini": _scan(r"!\[[^\]]*\]\([^)]*\)"),
"br_inline": _scan(r"<br>"),
"simboli_encoding":_scan(r'(?<=[0-9A-Za-z])[!"](?=[0-9A-Za-z])'),
"formule_inline": _scan(r"\[\d+\.\d+\]"),
} }
# ── Composizione report ─────────────────────────────────────────────── # ── Composizione report ───────────────────────────────────────────────
@@ -856,14 +961,20 @@ def build_report(
"long_sections_list": long_secs, "long_sections_list": long_secs,
}, },
"residui": { "residui": {
"backtick": len(residui["backtick"]), "backtick": len(residui["backtick"]),
"dotleader": len(residui["dotleader"]), "dotleader": len(residui["dotleader"]),
"url": len(residui["url"]), "url": len(residui["url"]),
"immagini": len(residui["immagini"]), "immagini": len(residui["immagini"]),
"backtick_esempi": residui["backtick"], "br_inline": len(residui["br_inline"]),
"dotleader_esempi": residui["dotleader"], "simboli_encoding": len(residui["simboli_encoding"]),
"url_esempi": residui["url"], "formule_inline": len(residui["formule_inline"]),
"immagini_esempi": residui["immagini"], "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"],
}, },
} }