Files
rag-from-scratch/chunks/md_optimizer.py
T
davide e78c404211 feat(chunks): pipeline unificata Stage 1+2 con md_optimizer
chunker.py ora esegue in sequenza:
  - Stage 1 (md_optimizer.py): _content_list_v2.json + _model.json → _clean.md
    con pulizia TOC, frontespizio, sommari interni, merge titoli capitolo
  - Stage 2: _clean.md → chunks.json (paragraph-overlap, atomici tabelle/liste)

config.py esteso con CHAPTER_PREFIX_PATTERNS, SOMMARIO_PATTERNS,
MODEL_SKIP_LABELS, MODEL_ABSTRACT_LABELS, MIN_CONTENT_CHARS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:07:40 +02:00

541 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Stage 1 — Ottimizzatore Markdown (modulo interno, chiamato da chunker.py)
Legge _content_list_v2.json (struttura primaria) e _model.json (label di
layout) di MinerU e produce un Markdown pulito con gerarchia H1/H2/H3.
Progettato per essere generico rispetto al documento: sfrutta la struttura
comune di tutti gli output MinerU senza dipendere da pattern testuali
specifici del documento sorgente.
Logica di costruzione blocchi:
- title L1 consecutivi senza contenuto tra loro → fusi in un H1 unico
(il primo frammento è sempre il numero/identificatore del capitolo)
- title L1 singolo → H1
- title L2 → H2
- paragraph con label "abstract" o che matcha SOMMARIO_PATTERNS → skip
- paragraph breve che matcha H3_DETECTION_RE → H3
- paragraph normale → testo
- label MODEL_SKIP_LABELS → skip
Filtri di pulizia:
- _remove_frontmatter : rimuove sezioni per nome (FRONTMATTER_HEADINGS)
- _remove_toc_runs : rimuove sequenze di heading senza contenuto (TOC)
- _remove_frontespizio : rimuove contenuto prima del primo heading "vero"
(>= MIN_CONTENT_CHARS di testo reale)
Input: sources/<stem>/auto/<stem>_content_list_v2.json
sources/<stem>/auto/<stem>_model.json (opzionale)
Output: sources/<stem>/auto/<stem>_clean.md
Uso standalone:
python chunks/md_optimizer.py --stem <stem> [--force]
python chunks/md_optimizer.py # tutti gli stem in sources/
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
import config as cfg
# ─── Struttura dati interna ───────────────────────────────────────────────────
@dataclass
class Block:
kind: str # "h1" | "h2" | "h3" | "text" | "list" | "table"
text: str
_HEADING_LEVEL = {"h1": 1, "h2": 2, "h3": 3}
# Pattern compilati da config (inizializzati lazy per permettere hot-reload in test)
_SOMMARIO_RES: list[re.Pattern] = []
_CHAPTER_PREFIX_RES: list[re.Pattern] = []
def _init_patterns() -> None:
global _SOMMARIO_RES, _CHAPTER_PREFIX_RES
_SOMMARIO_RES = [re.compile(p, re.IGNORECASE) for p in cfg.SOMMARIO_PATTERNS]
_CHAPTER_PREFIX_RES = [re.compile(p, re.IGNORECASE) for p in cfg.CHAPTER_PREFIX_PATTERNS]
_init_patterns()
def _is_sommario(text: str) -> bool:
return any(r.match(text) for r in _SOMMARIO_RES)
def _is_chapter_prefix(text: str) -> bool:
"""True se il testo è un identificatore di capitolo (es. "CAPITOLO 1").
Usato come fallback quando MinerU produce il numero del capitolo come
paragraph anziché come title L1.
"""
return any(r.match(text) for r in _CHAPTER_PREFIX_RES)
# ─── Caricamento e indicizzazione _model.json ─────────────────────────────────
def _load_label_map(model_path: Path) -> dict[int, list[tuple[float, float, str]]]:
"""Restituisce {page_idx: [(cx_v2, cy_v2, label), ...]}
Le coordinate cx/cy sono nel sistema di riferimento v2:
v2_coord = model_coord * 1000 / model_page_dim
"""
if not model_path.exists():
return {}
pages = json.loads(model_path.read_text(encoding="utf-8"))
label_map: dict[int, list[tuple[float, float, str]]] = {}
for page in pages:
info = page.get("page_info", {})
page_no = info.get("page_no", 0)
pw = info.get("width", 1350)
ph = info.get("height", 1891)
entries: list[tuple[float, float, str]] = []
for det in page.get("layout_dets", []):
label = det.get("label", "")
if label in cfg.MODEL_SKIP_LABELS:
continue
x0, y0, x1, y1 = det["bbox"]
cx = (x0 + x1) * 0.5 * 1000.0 / pw
cy = (y0 + y1) * 0.5 * 1000.0 / ph
entries.append((cx, cy, label))
label_map[page_no] = entries
return label_map
def _get_label(page_idx: int, bbox: list[int],
label_map: dict[int, list]) -> str:
"""Restituisce il label model.json il cui centro è più vicino al centro
del bbox v2 (tolleranza 80 unità v2 ≈ 8% della larghezza pagina)."""
entries = label_map.get(page_idx)
if not entries:
return ""
x0, y0, x1, y1 = bbox
cx = (x0 + x1) * 0.5
cy = (y0 + y1) * 0.5
best_label = ""
best_dist = 80.0
for ex, ey, label in entries:
dist = ((cx - ex) ** 2 + (cy - ey) ** 2) ** 0.5
if dist < best_dist:
best_dist = dist
best_label = label
return best_label
# ─── Estrazione testo dai blocchi MinerU ──────────────────────────────────────
def _text_para(content: dict) -> str:
return " ".join(
p["content"] for p in content.get("paragraph_content", [])
if p.get("type") == "text"
).strip()
def _text_title(content: dict) -> str:
return " ".join(
p["content"] for p in content.get("title_content", [])
if p.get("type") == "text"
).strip()
def _text_list(content: dict) -> str:
lines = []
for item in content.get("list_content", []):
for block in item.get("blocks", []):
t = block.get("content", "").strip()
if t:
lines.append(f"- {t}")
return "\n".join(lines)
def _is_h3_candidate(text: str) -> bool:
return (
len(text) <= cfg.H3_MAX_CHARS
and bool(re.match(cfg.H3_DETECTION_RE, text))
)
# ─── Build blocchi da JSON MinerU ─────────────────────────────────────────────
def _build_blocks(pages: list, label_map: dict) -> list[Block]:
"""Costruisce la lista di Block dalla struttura MinerU.
Logica per i titoli H1 consecutivi (generica, senza pattern lingua-specifica):
- Ogni title L1 viene bufferizzato come "pending_h1".
- Se arriva un altro title L1 subito dopo (senza contenuto tra loro),
i due frammenti vengono fusi in un unico H1 con "" come separatore.
Questo gestisce il pattern comune di MinerU dove il numero/identificatore
del capitolo e il suo titolo sono due blocchi separati.
- Quando arriva contenuto non-titolo (paragrafo, lista, H2), il pending_h1
viene emesso così com'è.
"""
blocks: list[Block] = []
pending_h1: str = "" # titolo L1 in attesa di conferma/merge
def _flush_h1() -> None:
nonlocal pending_h1
if pending_h1:
blocks.append(Block(kind="h1", text=pending_h1))
pending_h1 = ""
for page_idx, page in enumerate(pages):
for item in page:
kind = item.get("type", "")
content = item.get("content", {})
bbox = item.get("bbox", [0, 0, 0, 0])
# ── Tipi MinerU rumorosi ─────────────────────────────────────────
if kind in cfg.NOISE_TYPES:
_flush_h1()
continue
model_label = _get_label(page_idx, bbox, label_map)
# ── Label model rumorosi ─────────────────────────────────────────
if model_label in cfg.MODEL_SKIP_LABELS:
continue
# ── Sommari interni (abstract label o pattern testuale) ──────────
if model_label in cfg.MODEL_ABSTRACT_LABELS:
continue
# ── Titoli ───────────────────────────────────────────────────────
if kind == "title":
text = _text_title(content)
if not text:
continue
level = min(content.get("level", 2), 3)
if level == 1:
if pending_h1:
# Due title L1 consecutivi: fondi il precedente col corrente
merged = f"{pending_h1}{text}"
pending_h1 = merged
else:
pending_h1 = text
else:
# H2: emetti prima il pending H1 se esiste
_flush_h1()
blocks.append(Block(kind="h2", text=text))
# ── Paragrafi ────────────────────────────────────────────────────
elif kind == "paragraph":
text = _text_para(content)
if not text:
continue
# Sommario interno: salta (fallback testuale se label non copre)
if _is_sommario(text):
continue
# Prefisso di capitolo come paragraph (es. "CAPITOLO 1"):
# bufferizza come pending H1, verrà fuso col titolo L1 successivo
if _is_chapter_prefix(text):
if pending_h1:
pending_h1 = f"{pending_h1}{text}"
else:
pending_h1 = text
continue
_flush_h1()
if _is_h3_candidate(text):
blocks.append(Block(kind="h3", text=text))
else:
blocks.append(Block(kind="text", text=text))
# ── Liste ────────────────────────────────────────────────────────
elif kind == "list":
_flush_h1()
text = _text_list(content)
if text:
blocks.append(Block(kind="list", text=text))
# ── Tabelle ──────────────────────────────────────────────────────
elif kind == "table":
_flush_h1()
body = content.get("table_body", "")
if body:
blocks.append(Block(kind="table", text=body))
# ── Immagini (opzionale) ─────────────────────────────────────────
elif kind == "image" and not cfg.SKIP_IMAGES:
_flush_h1()
src = content.get("image_source", {}).get("path", "")
caption = " ".join(
c.get("content", "") for c in content.get("image_caption", [])
).strip()
if src:
blocks.append(Block(kind="text", text=f"![{caption}]({src})"))
else:
_flush_h1()
_flush_h1() # flush finale
return blocks
# ─── Helpers content check ────────────────────────────────────────────────────
def _has_content(blocks: list[Block], idx: int) -> bool:
"""True se esiste almeno un blocco testo/lista/tabella prima del prossimo
heading di livello uguale o superiore."""
level = _HEADING_LEVEL.get(blocks[idx].kind)
if level is None:
return False
for b in blocks[idx + 1:]:
blevel = _HEADING_LEVEL.get(b.kind)
if blevel is not None and blevel <= level:
return False
if b.kind in ("text", "list", "table"):
return True
return False
def _has_real_content(blocks: list[Block], idx: int) -> bool:
"""True se il totale caratteri di testo sotto questo heading >=
MIN_CONTENT_CHARS. Permette di distinguere frontespizi (copyright breve)
da sezioni con contenuto vero."""
level = _HEADING_LEVEL.get(blocks[idx].kind)
if level is None:
return False
total = 0
for b in blocks[idx + 1:]:
blevel = _HEADING_LEVEL.get(b.kind)
if blevel is not None and blevel <= level:
break
if b.kind in ("text", "list", "table"):
total += len(b.text)
if total >= cfg.MIN_CONTENT_CHARS:
return True
return False
# ─── Filtri di pulizia ────────────────────────────────────────────────────────
def _remove_frontmatter(blocks: list[Block]) -> list[Block]:
"""Rimuove le sezioni il cui heading è in FRONTMATTER_HEADINGS, insieme
a tutto il loro contenuto.
Il salto continua finché non si trova un heading non-frontmatter —
questo elimina anche sezioni TOC consecutive in un colpo solo.
"""
def _norm(text: str) -> str:
t = text.strip().lower()
# Rimuovi eventuale prefisso "Xxx N — " (identificatore capitolo)
return re.sub(r"^\S+\s+\S+\s+[—\-]\s*", "", t)
def _is_fm(text: str) -> bool:
core = _norm(text)
return any(
core == fm or core.startswith(fm + " ")
for fm in cfg.FRONTMATTER_HEADINGS
)
if not cfg.FRONTMATTER_HEADINGS:
return blocks
result: list[Block] = []
i = 0
while i < len(blocks):
b = blocks[i]
if b.kind in _HEADING_LEVEL and _is_fm(b.text):
level = _HEADING_LEVEL[b.kind]
i += 1
while i < len(blocks):
nxt = blocks[i]
nxt_level = _HEADING_LEVEL.get(nxt.kind)
if nxt_level is not None and nxt_level <= level and not _is_fm(nxt.text):
break
i += 1
continue
result.append(b)
i += 1
return result
def _remove_toc_runs(blocks: list[Block]) -> list[Block]:
"""Rimuove sequenze di MIN_TOC_HEADINGS o più heading consecutivi senza
testo reale tra loro (TOC residuo).
"Consecutivi" tolera micro-testi brevi (≤ 120 chars) intercalati tra
i heading (es. attribuzioni autori nel TOC).
"""
def _is_toc_entry(idx: int) -> bool:
b = blocks[idx]
if b.kind not in _HEADING_LEVEL:
return False
level = _HEADING_LEVEL[b.kind]
for b2 in blocks[idx + 1:]:
blevel = _HEADING_LEVEL.get(b2.kind)
if blevel is not None and blevel <= level:
return True
if b2.kind in ("text", "list", "table") and len(b2.text) > 20:
return False
return True
result: list[Block] = []
i = 0
while i < len(blocks):
b = blocks[i]
if b.kind in _HEADING_LEVEL and _is_toc_entry(i):
j = i + 1
toc_count = 1
while j < len(blocks):
bj = blocks[j]
if bj.kind in _HEADING_LEVEL:
if _is_toc_entry(j):
toc_count += 1
j += 1
continue
else:
break
if bj.kind in ("text", "list") and len(bj.text) <= 120:
j += 1
continue
break
if toc_count >= cfg.MIN_TOC_HEADINGS:
i = j
continue
result.append(b)
i += 1
return result
def _remove_frontespizio(blocks: list[Block]) -> list[Block]:
"""Rimuove tutto il contenuto prima del primo heading con contenuto reale
(>= MIN_CONTENT_CHARS): copertine, copyright, pagine iniziali."""
for i, b in enumerate(blocks):
if b.kind in _HEADING_LEVEL and _has_real_content(blocks, i):
return blocks[i:]
return blocks
def filter_blocks(blocks: list[Block]) -> list[Block]:
blocks = _remove_frontmatter(blocks)
blocks = _remove_toc_runs(blocks)
blocks = _remove_frontespizio(blocks)
return blocks
# ─── Rendering ────────────────────────────────────────────────────────────────
def _render(blocks: list[Block]) -> str:
lines: list[str] = []
prev_was_heading = False
for b in blocks:
if b.kind in ("h1", "h2", "h3"):
prefix = "#" * _HEADING_LEVEL[b.kind]
if lines and not prev_was_heading:
lines.append("")
lines.append(f"{prefix} {b.text}")
prev_was_heading = True
else:
lines.append("")
lines.append(b.text)
prev_was_heading = False
md = "\n".join(lines).strip() + "\n"
return re.sub(r"\n{3,}", "\n\n", md)
# ─── Core ─────────────────────────────────────────────────────────────────────
def optimize(stem: str, project_root: Path, force: bool = False) -> bool:
"""Esegue Stage 1: _content_list_v2.json + _model.json → _clean.md.
Restituisce True se il file è stato prodotto (o era già presente e
force=False), False in caso di errore.
"""
auto_dir = project_root / "sources" / stem / "auto"
json_path = auto_dir / f"{stem}_content_list_v2.json"
model_path = auto_dir / f"{stem}_model.json"
out_path = auto_dir / f"{stem}_clean.md"
print(f"\n[Stage 1] Documento: {stem}")
if not json_path.exists():
print(f"{json_path.name} non trovato")
return False
if out_path.exists() and not force:
print(f"{out_path.name} già presente — skip ottimizzazione")
return True
pages = json.loads(json_path.read_text(encoding="utf-8"))
if model_path.exists():
label_map = _load_label_map(model_path)
n_labels = sum(len(v) for v in label_map.values())
print(f" 📐 {model_path.name} ({n_labels} label)")
else:
label_map = {}
print(f" {model_path.name} non trovato — nessun enrichment layout")
blocks = _build_blocks(pages, label_map)
n_raw = len(blocks)
blocks = filter_blocks(blocks)
n_filtered = n_raw - len(blocks)
md = _render(blocks)
out_path.write_text(md, encoding="utf-8")
n_h1 = len(re.findall(r"^# ", md, re.MULTILINE))
n_h2 = len(re.findall(r"^## ", md, re.MULTILINE))
n_h3 = len(re.findall(r"^### ", md, re.MULTILINE))
print(f"{out_path.name} "
f"({md.count(chr(10))} righe — H1={n_h1} H2={n_h2} H3={n_h3} "
f"rimossi={n_filtered}/{n_raw})")
return True
# ─── Entry point standalone ───────────────────────────────────────────────────
if __name__ == "__main__":
project_root = Path(__file__).parent.parent
parser = argparse.ArgumentParser(
description="Stage 1: _content_list_v2.json + _model.json → _clean.md"
)
parser.add_argument("--stem", help="Nome documento (sottocartella di sources/)")
parser.add_argument("--force", action="store_true",
help="Rigenera anche se _clean.md esiste già")
args = parser.parse_args()
if args.stem:
stems = [args.stem]
else:
sources_dir = project_root / "sources"
stems = sorted(
p.name for p in sources_dir.iterdir()
if p.is_dir()
and (p / "auto" / f"{p.name}_content_list_v2.json").exists()
)
if not stems:
print("Errore: nessun documento MinerU trovato in sources/")
sys.exit(1)
results = [optimize(s, project_root, args.force) for s in stems]
ok = sum(results)
print(f"\n{'' if all(results) else '⚠️ '} {ok}/{len(results)} documenti processati")
sys.exit(0 if all(results) else 1)