From 4c0e0db2a51c2e064ec5ebb4b9cf2ce4e7655b57 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 20 Apr 2026 11:36:18 +0200 Subject: [PATCH 1/6] 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// invece di step-5/ e step-6/ separati. Input: conversione//clean.md --- chunks/chunker.py | 414 ++++++++++++++++++++++++++++++++++++++++ chunks/fix_chunks.py | 283 +++++++++++++++++++++++++++ chunks/verify_chunks.py | 302 +++++++++++++++++++++++++++++ 3 files changed, 999 insertions(+) create mode 100644 chunks/chunker.py create mode 100644 chunks/fix_chunks.py create mode 100644 chunks/verify_chunks.py diff --git a/chunks/chunker.py b/chunks/chunker.py new file mode 100644 index 0000000..2f4c718 --- /dev/null +++ b/chunks/chunker.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +""" +Chunking adattivo + +Divide il Markdown revisionato in chunk semantici pronti per la +vettorizzazione. La strategia dipende dal profilo strutturale del documento. + +Input: conversione//clean.md + conversione//structure_profile.json +Output: chunks//chunks.json + +Uso: + python chunks/chunker.py # tutti i documenti in conversione/ + python chunks/chunker.py --stem documento # un solo documento + python chunks/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]: + parts = re.split(r'(?<=[.!?»])\s+(?=[A-ZÀÈÉÌÒÙA-Z\"])', text.strip()) + 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: + 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]: + 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] + 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: + 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 = current[-overlap_s:] if overlap_s and len(current) > overlap_s else [] + current = overlap[:] + current_len = sum(len(s) + 1 for s in current) + + 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]: + 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): + 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]: + 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]: + sections = parse_h3_sections(text) + + 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) + + 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]: + sections = parse_h2_sections(text) + chunks = [] + + for sec in sections: + sezione = sec["sezione"] or stem + body = sec["body"] + prefix = f"[{sezione}]\n" + + paragraphs = [ + p.strip() + for p in re.split(r"\n{2,}", body) + if p.strip() and not re.match(r"^#+\s", p.strip()) + ] + + 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]: + 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" + + 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]: + 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 + i += max(1, len(window) - OVERLAP_S) + + return chunks + + +# ─── Dispatcher ─────────────────────────────────────────────────────────────── + +_STRATEGIES: dict[str, callable] = { + "h3_aware": chunk_h3_aware, + "h2_paragraph_split": chunk_h2_paragraph_split, + "paragraph": chunk_paragraph, + "sliding_window": chunk_sliding_window, +} + + +def chunk_document(clean_md: Path, profile: dict, stem: str) -> list[dict]: + text = clean_md.read_text(encoding="utf-8") + strategia = profile.get("strategia_chunking", "paragraph") + fn = _STRATEGIES.get(strategia, chunk_paragraph) + return fn(text, stem) + + +# ─── Per-document processing ────────────────────────────────────────────────── + +def process_stem(stem: str, project_root: Path, force: bool) -> bool: + conv_dir = project_root / "conversione" / stem + out_dir = project_root / "chunks" / stem + clean_md = conv_dir / "clean.md" + profile_path = conv_dir / "structure_profile.json" + out_file = out_dir / "chunks.json" + + print(f"\nDocumento: {stem}") + + if not clean_md.exists(): + print(f" ✗ clean.md non trovato in conversione/{stem}/ — skip") + return False + if not profile_path.exists(): + print(f" ✗ structure_profile.json non trovato in conversione/{stem}/ — skip") + return False + + if out_file.exists() and not force: + print(f" ⚠️ chunks.json già presente — skip") + print(f" (usa --force per rieseguire)") + return True + + profile = json.loads(profile_path.read_text(encoding="utf-8")) + strategia = profile.get("strategia_chunking", "paragraph") + print(f" Strategia: {strategia}") + + chunks = chunk_document(clean_md, profile, stem) + + if not chunks: + print(f" ✗ Nessun chunk generato — controlla clean.md") + return False + + out_dir.mkdir(parents=True, exist_ok=True) + out_file.write_text( + json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + lengths = [c["n_chars"] for c in chunks] + min_c = min(lengths) + max_c = max(lengths) + avg_c = int(sum(lengths) / len(lengths)) + short = sum(1 for l in lengths if l < MIN_CHARS) + long_ = sum(1 for l in lengths if l > MAX_CHARS * 1.5) + + print(f" Chunk totali: {len(chunks)}") + print(f" Min: {min_c} char Max: {max_c} char Media: {avg_c} char") + if short: + print(f" ⚠️ {short} chunk sotto MIN_CHARS ({MIN_CHARS})") + if long_: + print(f" ⚠️ {long_} chunk sopra MAX_CHARS×1.5 ({int(MAX_CHARS * 1.5)})") + print(f" ✅ chunks.json salvato in chunks/{stem}/") + return True + + +# ─── Entry point ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + project_root = Path(__file__).parent.parent + + parser = argparse.ArgumentParser(description="Chunking adattivo") + parser.add_argument("--stem", help="Nome del documento (sottocartella di conversione/)") + parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente") + args = parser.parse_args() + + if args.stem: + stems = [args.stem] + else: + conv_dir = project_root / "conversione" + if not conv_dir.exists(): + print(f"Errore: cartella conversione/ non trovata in {project_root}") + sys.exit(1) + stems = sorted( + p.name for p in conv_dir.iterdir() + if p.is_dir() and (p / "clean.md").exists() + ) + if not stems: + print(f"Errore: nessun documento trovato in conversione/") + sys.exit(1) + + results = [process_stem(s, project_root, args.force) for s in stems] + + ok = sum(results) + total = len(results) + print(f"\n{'✅' if all(results) else '⚠️ '} {ok}/{total} documenti processati") + sys.exit(0 if all(results) else 1) diff --git a/chunks/fix_chunks.py b/chunks/fix_chunks.py new file mode 100644 index 0000000..e817e51 --- /dev/null +++ b/chunks/fix_chunks.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +""" +Fix chunk + +Applica correzioni dirette su chunks//chunks.json basandosi sul +report.json prodotto da verify_chunks.py. Non tocca clean.md. + +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: chunks//chunks.json + chunks//report.json +Output: chunks//chunks.json (sovrascrive) + +Uso: + python chunks/fix_chunks.py --stem documento + python chunks/fix_chunks.py --stem documento --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: + sezione = chunk.get("sezione", "") + titolo = chunk.get("titolo", "") + if titolo: + return f"[{sezione} > {titolo}]" + return f"[{sezione}]" + + +def _strip_prefix(text: str) -> str: + 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: + return f"{_prefix(chunk)}\n{body}" + + +def _split_at_boundary(text: str, max_chars: int) -> list[str]: + if len(text) <= max_chars: + return [text] + + parts = [] + remaining = text + + while len(remaining) > max_chars: + candidate = remaining[:max_chars] + split_pos = candidate.rfind("\n\n") + + if split_pos == -1: + 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: + 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]: + 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]: + 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]: + 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] + 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"]) + 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]: + 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: + 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]: + 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: + stem_dir = project_root / "chunks" / stem + chunks_path = stem_dir / "chunks.json" + report_path = stem_dir / "report.json" + + if not chunks_path.exists(): + print(f"✗ chunks/{stem}/chunks.json non trovato.") + print(f" Esegui prima: python chunks/chunker.py --stem {stem}") + return False + + if not report_path.exists(): + print(f"✗ chunks/{stem}/report.json non trovato.") + print(f" Esegui prima: python chunks/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 + + 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", [])} + + 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) + + 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.") + + 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: + chunks, n = fix_too_long(chunks, too_long_ids, max_chars) + print(f" ✂️ Spezzati {n} chunk lunghi.") + + chunks = renumber_ids(chunks) + + n_after = len(chunks) + print(f"\n Totale chunk: {n_before} → {n_after}") + + chunks_path.write_text( + json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(f" ✅ Salvato: chunks/{stem}/chunks.json") + print(f"\n Riesegui la verifica:") + print(f" python chunks/verify_chunks.py --stem {stem}") + + return True + + +# ─── Entry point ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + project_root = Path(__file__).parent.parent + + parser = argparse.ArgumentParser(description="Fix chunk") + parser.add_argument("--stem", required=True, help="Nome del documento (sottocartella di chunks/)") + 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) diff --git a/chunks/verify_chunks.py b/chunks/verify_chunks.py new file mode 100644 index 0000000..b18e55a --- /dev/null +++ b/chunks/verify_chunks.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +Verifica chunk + +Analizza chunks//chunks.json e segnala ogni anomalia che potrebbe +degradare la qualità del retrieval. Non modifica nulla. + +Input: chunks//chunks.json +Output: report a schermo + chunks//report.json + exit code (0 = OK, 1 = problemi) + +Uso: + python chunks/verify_chunks.py --stem documento + python chunks/verify_chunks.py # tutti i documenti in chunks/ + python chunks/verify_chunks.py --min 200 --max 800 +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +# ─── Soglie ─────────────────────────────────────────────────────────────────── + +MIN_CHARS = 200 +MAX_CHARS = 800 +PUNCT_END = re.compile("[.!?»)\\]'\u2019\"\u201c\u201d\u2018\u2014\u2013\u2026-]$") + + +# ─── Checks ─────────────────────────────────────────────────────────────────── + +def has_prefix(chunk: dict) -> bool: + 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: + text = chunk.get("text", "").rstrip() + if not text: + return False + 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: + chunks_path = project_root / "chunks" / stem / "chunks.json" + + print(f"\nDocumento: {stem}") + + if not chunks_path.exists(): + print(f" ✗ chunks/{stem}/chunks.json non trovato") + print(f" Esegui prima: python chunks/chunker.py --stem {stem}") + 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 ─────────────────────────────────────────────────────────── + + 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) + + 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") + + 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" → Soluzione: abbassa MIN_CHARS o revisiona il MD") + + 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" → Soluzione: alza MAX_CHARS o verifica il testo nel MD") + + 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" → Soluzione: correggi le righe spezzate in conversione/{stem}/clean.md") + + # ── Costruisci e salva report.json ──────────────────────────────────────── + + blockers = empty_chunks + no_prefix + incomplete + warnings = too_short + too_long + + def _chunk_entry(c: dict) -> dict: + return { + "chunk_id": c.get("chunk_id", ""), + "sezione": c.get("sezione", ""), + "titolo": c.get("titolo", ""), + "n_chars": c.get("n_chars", 0), + "last_text": c.get("text", "").rstrip().split("\n")[-1][-120:], + } + + verdict = "ok" if not blockers else "blocked" + if not blockers and warnings: + verdict = "warnings_only" + + 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 / "chunks" / 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 chunks/{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: + 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:") + 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 chunks/fix_chunks.py --stem {stem} --dry-run") + print(f" poi: python chunks/fix_chunks.py --stem {stem}") + print(f" poi: python chunks/verify_chunks.py --stem {stem}") + + else: + print(f" 🔴 Problemi bloccanti — correggi prima di procedere:") + print() + if empty_chunks: + print(f" • {len(empty_chunks)} chunk vuoti") + print(f" → Controlla conversione/{stem}/clean.md per sezioni prive di testo") + if no_prefix: + print(f" • {len(no_prefix)} chunk senza prefisso di contesto") + print(f" → Controlla che gli header ### siano corretti in conversione/{stem}/clean.md") + if incomplete: + print(f" • {len(incomplete)} chunk con frase spezzata") + print(f" → Esegui: python chunks/fix_chunks.py --stem {stem}") + print() + print(f" Dopo le correzioni, riesegui nell'ordine:") + print(f" python chunks/chunker.py --stem {stem} --force") + print(f" python chunks/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="Verifica chunk") + parser.add_argument("--stem", help="Nome del documento (sottocartella di chunks/)") + 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: + chunks_dir = project_root / "chunks" + if not chunks_dir.exists(): + print(f"Errore: cartella chunks/ non trovata in {project_root}") + sys.exit(1) + stems = sorted( + p.name for p in chunks_dir.iterdir() + if p.is_dir() and (p / "chunks.json").exists() + ) + if not stems: + print("Errore: nessun chunks.json trovato in chunks/") + 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) From c87a7cb3eb0a71cac9f3c5b37e0685fb1a8273f7 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 20 Apr 2026 12:21:30 +0200 Subject: [PATCH 2/6] refactor: rimuovi step-5/ e step-6/, sostituiti da chunks/ --- step-5/chunker.py | 457 ---------------------------------------- step-6/fix_chunks.py | 320 ---------------------------- step-6/verify_chunks.py | 321 ---------------------------- 3 files changed, 1098 deletions(-) delete mode 100644 step-5/chunker.py delete mode 100644 step-6/fix_chunks.py delete mode 100644 step-6/verify_chunks.py diff --git a/step-5/chunker.py b/step-5/chunker.py deleted file mode 100644 index b5ba539..0000000 --- a/step-5/chunker.py +++ /dev/null @@ -1,457 +0,0 @@ -#!/usr/bin/env python3 -""" -Chunking adattivo - -Divide il Markdown revisionato in chunk semantici pronti per la -vettorizzazione. La strategia dipende dal profilo strutturale del documento. - -Input: conversione//clean.md + conversione//structure_profile.json -Output: step-5//chunks.json - -Uso: - python step-5/chunker.py # tutti i documenti in conversione/ - python step-5/chunker.py --stem documento # un solo documento - python step-5/chunker.py --stem documento --force -""" - -import argparse -import json -import re -import sys -from pathlib import Path - - -# ─── Parametri ──────────────────────────────────────────────────────────────── - -MIN_CHARS = 200 # sotto questa soglia → accorpa al chunk successivo -MAX_CHARS = 800 # sopra questa soglia → spezza su frasi -OVERLAP_S = 2 # frasi di overlap tra sotto-chunk dello stesso boundary - - -# ─── Utilità ────────────────────────────────────────────────────────────────── - -def split_sentences(text: str) -> list[str]: - """ - Divide il testo in frasi senza spezzare abbreviazioni comuni. - Split su punteggiatura finale (.!?») seguita da spazio + lettera maiuscola. - """ - # Split conservativo: solo quando la punteggiatura è seguita da spazio - # e la parola successiva inizia in maiuscolo (o è fine stringa). - parts = re.split(r'(?<=[.!?»])\s+(?=[A-ZÀÈÉÌÒÙA-Z\"])', text.strip()) - # Se non trova nulla con maiuscola, usa split semplice - if len(parts) <= 1: - parts = re.split(r'(?<=[.!?»])\s+', text.strip()) - return [p.strip() for p in parts if p.strip()] - - -def slugify(s: str, max_len: int = 60) -> str: - """Converti una stringa in slug per chunk_id.""" - s = s.lower() - s = re.sub(r'[^\w\s-]', '', s) - s = re.sub(r'[\s_-]+', '_', s).strip('_') - return s[:max_len] if s else "section" - - -def make_sub_chunks( - body: str, - prefix: str, - sezione: str, - titolo: str, - max_chars: int, - overlap_s: int, -) -> list[dict]: - """ - Suddivide un body in sotto-chunk rispettando max_chars. - Aggiunge overlap_s frasi di overlap tra sotto-chunk consecutivi. - Non attraversa mai i confini del body. - """ - sentences = split_sentences(body) - if not sentences: - return [] - - chunks = [] - current: list[str] = [] - current_len = 0 - sub_index = 0 - - i = 0 - while i < len(sentences): - sent = sentences[i] - # +1 per lo spazio di separazione - if not current or current_len + len(sent) + 1 <= max_chars: - current.append(sent) - current_len += len(sent) + (1 if len(current) > 1 else 0) - i += 1 - else: - # Flush del chunk corrente - chunk_text = prefix + " ".join(current) - chunks.append({ - "chunk_id": f"{slugify(sezione)}__{slugify(titolo)}__s{sub_index}", - "text": chunk_text, - "sezione": sezione, - "titolo": titolo, - "sub_index": sub_index, - "n_chars": len(chunk_text), - }) - sub_index += 1 - # Overlap: riparti dalle ultime overlap_s frasi - overlap = current[-overlap_s:] if overlap_s and len(current) > overlap_s else [] - current = overlap[:] - current_len = sum(len(s) + 1 for s in current) - - # Flush delle frasi rimanenti - if current: - chunk_text = prefix + " ".join(current) - chunks.append({ - "chunk_id": f"{slugify(sezione)}__{slugify(titolo)}__s{sub_index}", - "text": chunk_text, - "sezione": sezione, - "titolo": titolo, - "sub_index": sub_index, - "n_chars": len(chunk_text), - }) - - return chunks - - -# ─── Parser Markdown ────────────────────────────────────────────────────────── - -def parse_h3_sections(text: str) -> list[dict]: - """ - Parsa il documento in sezioni (sezione h2, titolo h3, body). - Testo prima del primo header viene assegnato a sezione vuota. - """ - sections = [] - current_h2 = "" - current_h3 = "" - current_body_lines: list[str] = [] - - def flush(): - body = "\n".join(current_body_lines).strip() - if body: - sections.append({ - "sezione": current_h2, - "titolo": current_h3, - "body": body, - }) - - for line in text.splitlines(): - if re.match(r"^# ", line): - # h1 = titolo documento, non crea sezione - flush() - current_h2 = line[2:].strip() - current_h3 = "" - current_body_lines = [] - elif re.match(r"^## ", line): - flush() - current_h2 = line[3:].strip() - current_h3 = "" - current_body_lines = [] - elif re.match(r"^### ", line): - flush() - current_h3 = line[4:].strip() - current_body_lines = [] - else: - current_body_lines.append(line) - - flush() - return sections - - -def parse_h2_sections(text: str) -> list[dict]: - """Parsa il documento in sezioni h2 con il loro testo completo.""" - sections = [] - current_h2 = "" - current_body_lines: list[str] = [] - - def flush(): - body = "\n".join(current_body_lines).strip() - if body: - sections.append({"sezione": current_h2, "body": body}) - - for line in text.splitlines(): - if re.match(r"^## ", line): - flush() - current_h2 = line[3:].strip() - current_body_lines = [] - elif re.match(r"^# ", line): - flush() - current_h2 = line[2:].strip() - current_body_lines = [] - else: - current_body_lines.append(line) - - flush() - return sections - - -# ─── Strategie di chunking ──────────────────────────────────────────────────── - -def chunk_h3_aware(text: str, stem: str) -> list[dict]: - """ - Strategia h3_aware: boundary su ###. - Sezioni piccole (< MIN_CHARS) vengono accorpate alla successiva - purché appartengano allo stesso ## padre. - Sezioni grandi (> MAX_CHARS) vengono suddivise su frasi. - """ - sections = parse_h3_sections(text) - - # Merge greedy: accorpa al successivo se stesso h2 e body piccolo - merged: list[dict] = [] - pending: dict | None = None - - for sec in sections: - if pending is None: - pending = dict(sec) - continue - - if (pending["sezione"] == sec["sezione"] - and len(pending["body"]) < MIN_CHARS): - sep_title = " / ".join(filter(None, [pending["titolo"], sec["titolo"]])) - pending = { - "sezione": pending["sezione"], - "titolo": sep_title or pending["titolo"], - "body": pending["body"] + "\n\n" + sec["body"], - } - else: - merged.append(pending) - pending = dict(sec) - - if pending: - merged.append(pending) - - # Genera chunk con eventuale split su frasi - chunks = [] - for sec in merged: - sezione = sec["sezione"] or stem - titolo = sec["titolo"] or "" - body = sec["body"] - - prefix = f"[{sezione} > {titolo}]\n" if titolo else f"[{sezione}]\n" - sub = make_sub_chunks(body, prefix, sezione, titolo, MAX_CHARS, OVERLAP_S) - chunks.extend(sub) - - return chunks - - -def chunk_h2_paragraph_split(text: str, stem: str) -> list[dict]: - """ - Strategia h2_paragraph_split: boundary su ##. - All'interno di ogni ## i paragrafi vengono usati come sotto-unità. - """ - sections = parse_h2_sections(text) - chunks = [] - - for sec in sections: - sezione = sec["sezione"] or stem - body = sec["body"] - prefix = f"[{sezione}]\n" - - # Suddividi in paragrafi interni (righe vuote doppie) - paragraphs = [ - p.strip() - for p in re.split(r"\n{2,}", body) - if p.strip() and not re.match(r"^#+\s", p.strip()) - ] - - # Merge paragrafi piccoli - merged_pars: list[str] = [] - pending = "" - for par in paragraphs: - if pending and len(pending) < MIN_CHARS: - pending = pending + "\n\n" + par - else: - if pending: - merged_pars.append(pending) - pending = par - if pending: - merged_pars.append(pending) - - for idx, par in enumerate(merged_pars): - sub = make_sub_chunks(par, prefix, sezione, f"par{idx}", MAX_CHARS, OVERLAP_S) - for c in sub: - c["chunk_id"] = f"{slugify(sezione)}__p{idx}__s{c['sub_index']}" - chunks.extend(sub) - - return chunks - - -def chunk_paragraph(text: str, stem: str) -> list[dict]: - """ - Strategia paragraph: boundary su paragrafo (doppia riga vuota). - """ - paragraphs = [ - p.strip() - for p in re.split(r"\n{2,}", text) - if p.strip() and not re.match(r"^#+\s", p.strip()) - ] - prefix = f"[Documento: {stem}]\n" - - # Merge paragrafi piccoli - merged: list[str] = [] - pending = "" - for par in paragraphs: - if pending and len(pending) < MIN_CHARS: - pending = pending + "\n\n" + par - else: - if pending: - merged.append(pending) - pending = par - if pending: - merged.append(pending) - - chunks = [] - for idx, par in enumerate(merged): - sub = make_sub_chunks(par, prefix, stem, f"par{idx}", MAX_CHARS, OVERLAP_S) - for c in sub: - c["chunk_id"] = f"para__{idx}__s{c['sub_index']}" - chunks.extend(sub) - - return chunks - - -def chunk_sliding_window(text: str, stem: str) -> list[dict]: - """ - Strategia sliding_window: finestre di MAX_CHARS con OVERLAP_S frasi di overlap. - Usata per testi piatti senza struttura (livello 0). - """ - sentences = split_sentences(text) - prefix = f"[Documento: {stem}]\n" - - chunks = [] - i = 0 - win_idx = 0 - - while i < len(sentences): - window: list[str] = [] - cur_len = 0 - - j = i - while j < len(sentences): - s = sentences[j] - if window and cur_len + len(s) + 1 > MAX_CHARS: - break - window.append(s) - cur_len += len(s) + (1 if len(window) > 1 else 0) - j += 1 - - if not window: - window = [sentences[i]] - j = i + 1 - - chunk_text = prefix + " ".join(window) - chunks.append({ - "chunk_id": f"win__{win_idx}", - "text": chunk_text, - "sezione": stem, - "titolo": f"finestra {win_idx}", - "sub_index": win_idx, - "n_chars": len(chunk_text), - }) - win_idx += 1 - # Avanza di (window_size - overlap), almeno 1 - i += max(1, len(window) - OVERLAP_S) - - return chunks - - -# ─── Dispatcher ─────────────────────────────────────────────────────────────── - -_STRATEGIES: dict[str, callable] = { - "h3_aware": chunk_h3_aware, - "h2_paragraph_split": chunk_h2_paragraph_split, - "paragraph": chunk_paragraph, - "sliding_window": chunk_sliding_window, -} - - -def chunk_document(clean_md: Path, profile: dict, stem: str) -> list[dict]: - text = clean_md.read_text(encoding="utf-8") - strategia = profile.get("strategia_chunking", "paragraph") - fn = _STRATEGIES.get(strategia, chunk_paragraph) - return fn(text, stem) - - -# ─── Per-document processing ────────────────────────────────────────────────── - -def process_stem(stem: str, project_root: Path, force: bool) -> bool: - conv_dir = project_root / "conversione" / stem - out_dir = project_root / "step-5" / stem - clean_md = conv_dir / "clean.md" - profile_path = conv_dir / "structure_profile.json" - out_file = out_dir / "chunks.json" - - print(f"\nDocumento: {stem}") - - if not clean_md.exists(): - print(f" ✗ clean.md non trovato in conversione/{stem}/ — skip") - return False - if not profile_path.exists(): - print(f" ✗ structure_profile.json non trovato in conversione/{stem}/ — skip") - return False - - if out_file.exists() and not force: - print(f" ⚠️ chunks.json già presente — skip") - print(f" (usa --force per rieseguire)") - return True - - profile = json.loads(profile_path.read_text(encoding="utf-8")) - strategia = profile.get("strategia_chunking", "paragraph") - print(f" Strategia: {strategia}") - - chunks = chunk_document(clean_md, profile, stem) - - if not chunks: - print(f" ✗ Nessun chunk generato — controlla clean.md") - return False - - out_dir.mkdir(parents=True, exist_ok=True) - out_file.write_text( - json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8" - ) - - lengths = [c["n_chars"] for c in chunks] - min_c = min(lengths) - max_c = max(lengths) - avg_c = int(sum(lengths) / len(lengths)) - short = sum(1 for l in lengths if l < MIN_CHARS) - long_ = sum(1 for l in lengths if l > MAX_CHARS * 1.5) - - print(f" Chunk totali: {len(chunks)}") - print(f" Min: {min_c} char Max: {max_c} char Media: {avg_c} char") - if short: - print(f" ⚠️ {short} chunk sotto MIN_CHARS ({MIN_CHARS})") - if long_: - print(f" ⚠️ {long_} chunk sopra MAX_CHARS×1.5 ({int(MAX_CHARS * 1.5)})") - print(f" ✅ chunks.json salvato in step-5/{stem}/") - return True - - -# ─── Entry point ───────────────────────────────────────────────────────────── - -if __name__ == "__main__": - project_root = Path(__file__).parent.parent - - parser = argparse.ArgumentParser(description="Chunking adattivo") - parser.add_argument("--stem", help="Nome del documento (sottocartella di conversione/)") - parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente") - args = parser.parse_args() - - if args.stem: - stems = [args.stem] - else: - conv_dir = project_root / "conversione" - if not conv_dir.exists(): - print(f"Errore: cartella conversione/ non trovata in {project_root}") - sys.exit(1) - stems = sorted(p.name for p in conv_dir.iterdir() if p.is_dir() and (p / "clean.md").exists()) - if not stems: - print(f"Errore: nessun documento trovato in conversione/") - sys.exit(1) - - results = [process_stem(s, project_root, args.force) for s in stems] - - ok = sum(results) - total = len(results) - print(f"\n{'✅' if all(results) else '⚠️ '} {ok}/{total} documenti processati") - sys.exit(0 if all(results) else 1) diff --git a/step-6/fix_chunks.py b/step-6/fix_chunks.py deleted file mode 100644 index 448bbc0..0000000 --- a/step-6/fix_chunks.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env python3 -""" -Step 6 — Fix chunk - -Applica correzioni dirette su step-6//chunks.json basandosi sul report.json -prodotto da verify_chunks.py. Non tocca clean.md né step-5. - -Fixes applicati: - empty → rimuove il chunk - incomplete → fonde con il chunk successivo (la frase continua) - no_prefix → aggiunge prefisso [sezione > titolo] se mancante - too_short → fonde con il chunk adiacente nello stesso sezione - too_long → spezza all'ultimo confine di paragrafo/frase entro MAX_CHARS - -Input: step-6//chunks.json + step-6//report.json -Output: step-6//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) diff --git a/step-6/verify_chunks.py b/step-6/verify_chunks.py deleted file mode 100644 index cf881af..0000000 --- a/step-6/verify_chunks.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env python3 -""" -Step 6 — Verifica chunk - -Analizza step-5//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//chunks.json -Output: report a schermo + step-6//report.json + exit code (0 = OK, 1 = problemi) - -Uso: - python step-6/verify_chunks.py --stem documento - python step-6/verify_chunks.py # tutti i documenti in step-5/ - python step-6/verify_chunks.py --min 200 --max 800 -""" - -import argparse -import json -import re -import sys -from pathlib import Path - - -# ─── Soglie (devono coincidere con quelle usate in chunker.py) ──────────────── - -MIN_CHARS = 200 -MAX_CHARS = 800 -PUNCT_END = re.compile("[.!?»)\\]'\u2019\"\u201c\u201d\u2018\u2014\u2013\u2026-]$") - - -# ─── Checks ─────────────────────────────────────────────────────────────────── - -def has_prefix(chunk: dict) -> bool: - """Il chunk inizia con il prefisso di contesto '[...'.""" - return chunk.get("text", "").lstrip().startswith("[") - - -def is_empty(chunk: dict) -> bool: - return not chunk.get("text", "").strip() - - -def is_too_short(chunk: dict, min_chars: int) -> bool: - return chunk.get("n_chars", 0) < min_chars - - -def is_too_long(chunk: dict, max_chars: int) -> bool: - return chunk.get("n_chars", 0) > max_chars * 1.5 - - -def ends_incomplete(chunk: dict) -> bool: - """L'ultima riga di testo non termina con punteggiatura di fine frase.""" - text = chunk.get("text", "").rstrip() - if not text: - return False - # Rimuovi marcatori markdown finali (_ e *) prima di controllare: - # pattern come _parola._ o _parola!_ sono frasi complete. - text_check = re.sub(r"[_*]+$", "", text).rstrip() - if not text_check: - return False - return not PUNCT_END.search(text_check) - - -# ─── Report ─────────────────────────────────────────────────────────────────── - -def _fmt_chunk(c: dict) -> str: - cid = c.get("chunk_id", "?") - n = c.get("n_chars", 0) - preview = c.get("text", "")[:60].replace("\n", " ") - return f" [{cid}] ({n} char) «{preview}»" - - -def verify_stem(stem: str, project_root: Path, min_chars: int, max_chars: int) -> bool: - """Verifica i chunk di un documento. Ritorna True se nessun bloccante rilevato.""" - import shutil - - chunks_path = project_root / "step-6" / stem / "chunks.json" - - print(f"\nDocumento: {stem}") - - if not chunks_path.exists(): - src = project_root / "step-5" / stem / "chunks.json" - if src.exists(): - chunks_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, chunks_path) - print(f" → Copiato step-5/{stem}/chunks.json → step-6/{stem}/chunks.json") - else: - print(f" ✗ chunks.json non trovato in step-5/{stem}/ né in step-6/{stem}/ — skip") - return False - - chunks: list[dict] = json.loads(chunks_path.read_text(encoding="utf-8")) - - if not chunks: - print(f" ✗ chunks.json è vuoto") - return False - - # ── Raccogli problemi ────────────────────────────────────────────────────── - - empty_chunks = [c for c in chunks if is_empty(c)] - no_prefix = [c for c in chunks if not is_empty(c) and not has_prefix(c)] - too_short = [c for c in chunks if is_too_short(c, min_chars)] - too_long = [c for c in chunks if is_too_long(c, max_chars)] - incomplete = [c for c in chunks if not is_empty(c) and ends_incomplete(c)] - - # ── Statistiche lunghezze ────────────────────────────────────────────────── - - lengths = [c.get("n_chars", 0) for c in chunks] - n_total = len(chunks) - n_ok = n_total - len(set( - c["chunk_id"] for lst in [empty_chunks, no_prefix, too_short, too_long, incomplete] - for c in lst - )) - min_l = min(lengths) - max_l = max(lengths) - avg_l = int(sum(lengths) / n_total) - - # Distribuzione in fasce - n_under = sum(1 for l in lengths if l < min_chars) - n_normal = sum(1 for l in lengths if min_chars <= l <= max_chars) - n_over = sum(1 for l in lengths if l > max_chars) - - # ── Output ──────────────────────────────────────────────────────────────── - - print(f" Totale chunk: {n_total}") - print(f" ✅ OK: {n_ok}") - print() - print(f" Distribuzione lunghezze:") - print(f" Min: {min_l} char") - print(f" Max: {max_l} char") - print(f" Media: {avg_l} char") - print(f" < {min_chars} char (sotto MIN): {n_under}") - print(f" {min_chars}–{max_chars} char (ideale): {n_normal}") - print(f" > {max_chars} char (sopra MAX): {n_over}") - - has_errors = False - - if empty_chunks: - has_errors = True - print(f"\n 🔴 {len(empty_chunks)} chunk VUOTI:") - for c in empty_chunks[:5]: - print(f" [{c.get('chunk_id', '?')}]") - if len(empty_chunks) > 5: - print(f" ... e altri {len(empty_chunks) - 5}") - - if no_prefix: - has_errors = True - print(f"\n 🔴 {len(no_prefix)} chunk SENZA PREFISSO DI CONTESTO:") - for c in no_prefix[:5]: - print(_fmt_chunk(c)) - if len(no_prefix) > 5: - print(f" ... e altri {len(no_prefix) - 5}") - print(f" → Causa probabile: header ### mancanti o malformati nel MD (step 4)") - - if too_short: - has_errors = True - print(f"\n 🟡 {len(too_short)} chunk SOTTO MIN_CHARS ({min_chars}):") - for c in too_short[:5]: - print(_fmt_chunk(c)) - if len(too_short) > 5: - print(f" ... e altri {len(too_short) - 5}") - print(f" → Causa probabile: sezione isolata senza successivo nello stesso ##") - print(f" → Soluzione: abbassa MIN_CHARS o revisiona il MD (step 4)") - - if too_long: - has_errors = True - print(f"\n 🟡 {len(too_long)} chunk SOPRA MAX_CHARS×1.5 ({int(max_chars * 1.5)}):") - for c in too_long[:5]: - print(_fmt_chunk(c)) - if len(too_long) > 5: - print(f" ... e altri {len(too_long) - 5}") - print(f" → Causa probabile: frase singola molto lunga non spezzabile") - print(f" → Soluzione: alza MAX_CHARS o verifica il testo nel MD (step 4)") - - if incomplete: - has_errors = True - print(f"\n 🔴 {len(incomplete)} chunk CHE FINISCONO SENZA PUNTEGGIATURA (frase spezzata):") - for c in incomplete[:5]: - last_line = c.get("text", "").rstrip().split("\n")[-1][-80:] - print(f" [{c.get('chunk_id', '?')}] ...{last_line!r}") - if len(incomplete) > 5: - print(f" ... e altri {len(incomplete) - 5}") - print(f" → Causa probabile: paragrafo spezzato nel MD") - print(f" → Soluzione: correggi le righe spezzate in conversione/{stem}/clean.md") - - # ── Costruisci e salva report.json ─────────────────────────────────────── - - blockers = empty_chunks + no_prefix + incomplete - warnings = too_short + too_long - - def _chunk_entry(c: dict) -> dict: - return { - "chunk_id": c.get("chunk_id", ""), - "sezione": c.get("sezione", ""), - "titolo": c.get("titolo", ""), - "n_chars": c.get("n_chars", 0), - "last_text": c.get("text", "").rstrip().split("\n")[-1][-120:], - } - - if not blockers: - verdict = "ok" if not warnings else "warnings_only" - else: - verdict = "blocked" - - report = { - "stem": stem, - "verdict": verdict, - "stats": { - "total": n_total, - "ok": n_ok, - "min_chars": min_l, - "max_chars": max_l, - "avg_chars": avg_l, - }, - "thresholds": {"min_chars": min_chars, "max_chars": max_chars}, - "blockers": { - "empty": [_chunk_entry(c) for c in empty_chunks], - "no_prefix": [_chunk_entry(c) for c in no_prefix], - "incomplete": [_chunk_entry(c) for c in incomplete], - }, - "warnings": { - "too_short": [_chunk_entry(c) for c in too_short], - "too_long": [_chunk_entry(c) for c in too_long], - }, - } - - out_dir = project_root / "step-6" / stem - out_dir.mkdir(parents=True, exist_ok=True) - (out_dir / "report.json").write_text( - json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" - ) - print(f"\n report.json salvato in step-6/{stem}/") - - # ── Prossimi passi ──────────────────────────────────────────────────────── - - print(f"\n {'─' * 50}") - print(f" PROSSIMI PASSI") - print(f" {'─' * 50}") - - if not blockers and not warnings: - print(f" ✅ Tutto OK — procedi alla vettorizzazione:") - print(f" python step-8/ingest.py --stem {stem}") - - elif not blockers: - # Solo 🟡: si può procedere, i warning sono facoltativi - print(f" 🟡 Solo avvisi minori — puoi procedere alla vettorizzazione:") - print(f" python step-8/ingest.py --stem {stem}") - print() - print(f" Oppure, per ottimizzare prima di procedere:") - if too_short: - pct = int(len(too_short) / n_total * 100) - print(f" • {len(too_short)} chunk corti ({pct}% del totale)") - if too_long: - pct = int(len(too_long) / n_total * 100) - print(f" • {len(too_long)} chunk lunghi ({pct}% del totale)") - if too_short or too_long: - print(f" → Esegui: python step-6/fix_chunks.py --stem {stem} --dry-run") - print(f" poi: python step-6/fix_chunks.py --stem {stem}") - print(f" poi: python step-6/verify_chunks.py --stem {stem}") - - else: - # Ci sono 🔴: non si procede - print(f" 🔴 Problemi bloccanti — correggi prima di procedere:") - print() - if empty_chunks: - print(f" • {len(empty_chunks)} chunk vuoti") - print(f" → Controlla conversione/{stem}/clean.md per sezioni prive di testo") - if no_prefix: - print(f" • {len(no_prefix)} chunk senza prefisso di contesto") - print(f" → Controlla che gli header ### siano corretti in conversione/{stem}/clean.md") - if incomplete: - print(f" • {len(incomplete)} chunk con frase spezzata") - print(f" → Esegui: python step-6/fix_chunks.py --stem {stem}") - print() - print(f" Dopo le correzioni, riesegui nell'ordine:") - print(f" python step-5/chunker.py --stem {stem} --force") - print(f" python step-6/verify_chunks.py --stem {stem}") - print() - if warnings: - print(f" 🟡 Hai anche {len(warnings)} avvisi minori — affrontali dopo aver risolto i 🔴.") - - return not blockers - - -# ─── Entry point ────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - project_root = Path(__file__).parent.parent - - parser = argparse.ArgumentParser(description="Step 6 — Verifica chunk") - parser.add_argument("--stem", help="Nome del documento (sottocartella di step-5/)") - parser.add_argument( - "--min", type=int, default=MIN_CHARS, - help=f"Soglia minima caratteri (default: {MIN_CHARS})" - ) - parser.add_argument( - "--max", type=int, default=MAX_CHARS, - help=f"Soglia massima caratteri (default: {MAX_CHARS})" - ) - args = parser.parse_args() - - if args.stem: - stems = [args.stem] - else: - step5_dir = project_root / "step-5" - if not step5_dir.exists(): - print(f"Errore: cartella step-5/ non trovata in {project_root}") - sys.exit(1) - stems = sorted( - p.name for p in step5_dir.iterdir() - if p.is_dir() and (p / "chunks.json").exists() - ) - if not stems: - print("Errore: nessun chunks.json trovato in step-5/") - sys.exit(1) - - results = [verify_stem(s, project_root, args.min, args.max) for s in stems] - - ok = sum(results) - total = len(results) - print(f"\n{'✅' if all(results) else '⚠️ '} {ok}/{total} documenti senza problemi") - sys.exit(0 if all(results) else 1) From 995a8be7353725bf6d30b430a869adbc591e504f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 20 Apr 2026 12:25:00 +0200 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20pulisci=20.gitignore=20=E2=80=94?= =?UTF-8?q?=20rimuovi=20step-2..6,=20aggiungi=20chunks/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 0b18250..e3783db 100644 --- a/.gitignore +++ b/.gitignore @@ -26,26 +26,10 @@ __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 conversione/*/ +# Output chunks/ — generati da chunks/chunker.py e chunks/verify_chunks.py +chunks/*/ + From fe0ecc24ad873c277a80e2b602b9f894d70f320e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 20 Apr 2026 12:27:58 +0200 Subject: [PATCH 4/6] 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 --- chunks/chunker.py | 49 ++++++++++++++++++++++++++++++-------- chunks/verify_chunks.py | 52 +++++++++++++++++++++++++++++++++-------- conversione/pipeline.py | 3 +++ 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/chunks/chunker.py b/chunks/chunker.py index 2f4c718..188d95a 100644 --- a/chunks/chunker.py +++ b/chunks/chunker.py @@ -44,6 +44,41 @@ def slugify(s: str, max_len: int = 60) -> str: return s[:max_len] if s else "section" +_SENT_BOUNDARY = re.compile(r"[.!?»)\]'\u2019\"\u201c\u201d/:|\u2026]$") + + +def _flush_chunk( + current: list[str], + sentences: list[str], + i: int, + prefix: str, + sezione: str, + titolo: str, + sub_index: int, + max_chars: int, +) -> tuple[dict, list[str], int, int]: + """Emette un chunk, estendendo fino a un confine di frase (max +20%).""" + hard_limit = int(max_chars * 1.2) + current_len = sum(len(s) + 1 for s in current) + while i < len(sentences) and not _SENT_BOUNDARY.search(" ".join(current)): + nxt = sentences[i] + if current_len + len(nxt) + 1 > hard_limit: + break + current.append(nxt) + current_len += len(nxt) + 1 + i += 1 + chunk_text = prefix + " ".join(current) + chunk = { + "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 chunk, current, i, sub_index + 1 + + def make_sub_chunks( body: str, prefix: str, @@ -69,16 +104,10 @@ def make_sub_chunks( current_len += len(sent) + (1 if len(current) > 1 else 0) i += 1 else: - 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 + chunk, current, i, sub_index = _flush_chunk( + current, sentences, i, prefix, sezione, titolo, sub_index, max_chars + ) + chunks.append(chunk) 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) diff --git a/chunks/verify_chunks.py b/chunks/verify_chunks.py index b18e55a..d682748 100644 --- a/chunks/verify_chunks.py +++ b/chunks/verify_chunks.py @@ -25,7 +25,15 @@ from pathlib import Path MIN_CHARS = 200 MAX_CHARS = 800 -PUNCT_END = re.compile("[.!?»)\\]'\u2019\"\u201c\u201d\u2018\u2014\u2013\u2026-]$") +PUNCT_END = re.compile( + r"[.!?»)\]'\u2019\"\u201c\u201d\u2018\u2014\u2013\u2026]$" + r"|/$" # URL che finisce con / + r"|\|$" # riga di tabella Markdown + r"|:$" # introduzione a lista o formula +) +_HEX_END = re.compile(r"[0-9a-fA-F]{8,}$") +_URL_TAIL = re.compile(r"https?://\S+(\s+\S+){0,3}$") # URL con fino a 3 token extra +_MATH_SYMS = re.compile(r"[∈∑≤≥≠∀∃∫√∞∂±×÷→←↔⊂⊃⊆⊇∩∪·°]") # ─── Checks ─────────────────────────────────────────────────────────────────── @@ -53,7 +61,18 @@ def ends_incomplete(chunk: dict) -> bool: text_check = re.sub(r"[_*]+$", "", text).rstrip() if not text_check: return False - return not PUNCT_END.search(text_check) + if PUNCT_END.search(text_check): + return False + if _HEX_END.search(text_check): # hash SHA / codice hex + return False + if _URL_TAIL.search(text_check[-200:]): # URL (con eventuale path dopo spazio) + return False + return True + + +def is_math_incomplete(chunk: dict) -> bool: + """Incompleto ma in contesto matematico — degrada a warning invece di blocker.""" + return ends_incomplete(chunk) and len(_MATH_SYMS.findall(chunk.get("text", ""))) >= 3 # ─── Report ─────────────────────────────────────────────────────────────────── @@ -83,11 +102,13 @@ def verify_stem(stem: str, project_root: Path, min_chars: int, max_chars: int) - # ── 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)] + 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_all = [c for c in chunks if not is_empty(c) and ends_incomplete(c)] + incomplete_math = [c for c in _incomplete_all if is_math_incomplete(c)] + incomplete = [c for c in _incomplete_all if not is_math_incomplete(c)] # ── Statistiche ─────────────────────────────────────────────────────────── @@ -166,10 +187,20 @@ def verify_stem(stem: str, project_root: Path, min_chars: int, max_chars: int) - print(f" ... e altri {len(incomplete) - 5}") print(f" → Soluzione: correggi le righe spezzate in conversione/{stem}/clean.md") + if incomplete_math: + has_errors = True + print(f"\n 🟡 {len(incomplete_math)} chunk MATEMATICI SENZA PUNTEGGIATURA (formula/espressione):") + for c in incomplete_math[:3]: + last_line = c.get("text", "").rstrip().split("\n")[-1][-80:] + print(f" [{c.get('chunk_id', '?')}] ...{last_line!r}") + if len(incomplete_math) > 3: + print(f" ... e altri {len(incomplete_math) - 3}") + print(f" → Le formule non finiscono con punteggiatura — avviso non bloccante") + # ── Costruisci e salva report.json ──────────────────────────────────────── blockers = empty_chunks + no_prefix + incomplete - warnings = too_short + too_long + warnings = too_short + too_long + incomplete_math def _chunk_entry(c: dict) -> dict: return { @@ -201,8 +232,9 @@ def verify_stem(stem: str, project_root: Path, min_chars: int, max_chars: int) - "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], + "too_short": [_chunk_entry(c) for c in too_short], + "too_long": [_chunk_entry(c) for c in too_long], + "incomplete_math": [_chunk_entry(c) for c in incomplete_math], }, } diff --git a/conversione/pipeline.py b/conversione/pipeline.py index eedf436..e657da0 100644 --- a/conversione/pipeline.py +++ b/conversione/pipeline.py @@ -1538,6 +1538,9 @@ def run(stem: str, project_root: Path, force: bool) -> bool: 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" + ) _LIVELLO_DESC = {3: "ricca (h3)", 2: "parziale (h2)", 1: "paragrafi", 0: "testo piatto"} print(f" ✅ Struttura: livello {profile['livello_struttura']} — {_LIVELLO_DESC[profile['livello_struttura']]}") From a7b71fa5086d4100c00c8d796985ebd93d6cf6c6 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 20 Apr 2026 14:25:18 +0200 Subject: [PATCH 5/6] =?UTF-8?q?refactor(skills):=20rinomina=20step6-fix=20?= =?UTF-8?q?=E2=86=92=20post-chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rimpiazza .claude/commands/step6-fix.md con post-chunk.md - aggiorna path da step-6/ a chunks/ in tutta la skill - aggiunge gestione incomplete_math nel report summary - scope ampliato: workflow completo fino alla vettorizzazione - CLAUDE.md: aggiorna /step6-fix → /post-chunk --- .../commands/{step6-fix.md => post-chunk.md} | 47 +++++++++---------- CLAUDE.md | 2 +- 2 files changed, 24 insertions(+), 25 deletions(-) rename .claude/commands/{step6-fix.md => post-chunk.md} (59%) diff --git a/.claude/commands/step6-fix.md b/.claude/commands/post-chunk.md similarity index 59% rename from .claude/commands/step6-fix.md rename to .claude/commands/post-chunk.md index 66a88a4..9a6ce50 100644 --- a/.claude/commands/step6-fix.md +++ b/.claude/commands/post-chunk.md @@ -1,25 +1,23 @@ --- -description: Verifica i chunk di step 5, mostra i problemi, propone e applica le fix tramite fix_chunks.py con ri-verifica automatica finale. +description: Perfeziona i chunk di un documento (verifica, dry-run, fix, ri-verifica) e li prepara per la vettorizzazione. allowed-tools: Read Bash Grep argument-hint: --- -## Passo 0 — Verifica fresca (sempre) +## Passo 0 — Verifica fresca -Esegui sempre `verify_chunks.py` per avere un report aggiornato (non fidarti di un report.json preesistente): +Esegui sempre `verify_chunks.py` per un report aggiornato: ```bash -source .venv/bin/activate && python step-6/verify_chunks.py --stem $ARGUMENTS +source .venv/bin/activate && python chunks/verify_chunks.py --stem $ARGUMENTS ``` ---- - Leggi il report appena generato: !`python3 -c " import json, sys try: - r = json.load(open('step-6/$ARGUMENTS/report.json')) + r = json.load(open('chunks/$ARGUMENTS/report.json')) v = r.get('verdict','?') s = r.get('stats', {}) t = r.get('thresholds', {}) @@ -35,7 +33,7 @@ try: print(f' 🔴 {label}: {len(items)}') for c in items[:3]: print(f' [{c[\"chunk_id\"]}] {c[\"n_chars\"]} char → {c[\"last_text\"][-60:]!r}') - for cat, label in [('too_short','Troppo corti'), ('too_long','Troppo lunghi')]: + for cat, label in [('too_short','Troppo corti'), ('too_long','Troppo lunghi'), ('incomplete_math','Math incompleto')]: items = wa.get(cat, []) if items: print(f' 🟡 {label}: {len(items)}') @@ -48,14 +46,14 @@ except Exception as e: print(f'ERRORE lettura report: {e}') ## Se verdict == "ok" -✅ Nessun problema. Comunica: +✅ Nessun problema bloccante. Comunica: ``` -✅ Chunk puliti — procedi con la vettorizzazione: +✅ Chunk pronti — procedi con la vettorizzazione: python step-8/ingest.py --stem $ARGUMENTS ``` -Fermati qui. Non eseguire nessun altro passo. +Se ci sono solo 🟡, spiega brevemente i warning e chiedi se l'utente vuole risolverli prima o procedere. --- @@ -64,15 +62,16 @@ Fermati qui. Non eseguire nessun altro passo. ### Passo 1 — Dry-run ```bash -source .venv/bin/activate && python step-6/fix_chunks.py --stem $ARGUMENTS --dry-run +source .venv/bin/activate && python chunks/fix_chunks.py --stem $ARGUMENTS --dry-run ``` Spiega in italiano ogni operazione pianificata: -- **rimuovi chunk vuoti** — chunk privi di testo, non contribuiscono al retrieval -- **aggiungi prefisso** — il prefisso `[sezione > titolo]` fornisce contesto all'embedding; senza, il chunk è semanticamente decontestualizzato + +- **rimuovi chunk vuoti** — privi di testo, non contribuiscono al retrieval +- **aggiungi prefisso** — `[sezione > titolo]` fornisce contesto all'embedding; senza, il chunk è decontestualizzato - **fondi incompleti** — frase spezzata a metà: il chunk corrente e il successivo formano una frase unica -- **fondi troppo corti** — chunk sotto MIN_CHARS: troppo brevi per portare informazione semantica utile -- **spezza troppo lunghi** — chunk sopra MAX_CHARS×1.5: troppo densi, degradano la precision del retrieval +- **fondi troppo corti** — sotto MIN_CHARS: troppo brevi per portare informazione semantica utile +- **spezza troppo lunghi** — sopra MAX_CHARS×1.5: troppo densi, degradano la precision del retrieval Se ci sono solo 🟡 (nessun 🔴), informa che si può procedere anche senza fix e chiedi la preferenza. @@ -85,16 +84,16 @@ Applica solo su risposta affermativa esplicita. ### Passo 3 — Applica ```bash -source .venv/bin/activate && python step-6/fix_chunks.py --stem $ARGUMENTS +source .venv/bin/activate && python chunks/fix_chunks.py --stem $ARGUMENTS ``` ### Passo 4 — Ri-verifica automatica ```bash -source .venv/bin/activate && python step-6/verify_chunks.py --stem $ARGUMENTS +source .venv/bin/activate && python chunks/verify_chunks.py --stem $ARGUMENTS ``` -Leggi il nuovo `step-6/$ARGUMENTS/report.json` e riporta: +Leggi il nuovo `chunks/$ARGUMENTS/report.json` e riporta: - Nuovo verdict - Delta chunk (N prima → N dopo) - Problemi residui se presenti @@ -104,17 +103,17 @@ Leggi il nuovo `step-6/$ARGUMENTS/report.json` e riporta: Se verdict finale è `ok` o `warnings_only` senza 🔴: ``` -✅ Chunk pronti in step-6/$ARGUMENTS/chunks.json +✅ Chunk pronti in chunks/$ARGUMENTS/chunks.json Procedi con la vettorizzazione: python step-8/ingest.py --stem $ARGUMENTS ``` -Se rimangono 🔴 dopo il fix (raro — testo non spezzabile o struttura anomala): +Se rimangono 🔴 dopo il fix (testo non spezzabile o struttura anomala nel sorgente): ``` 🔴 X problemi residui non risolvibili automaticamente. - Torna a step-4/$ARGUMENTS/clean.md e correggi manualmente le sezioni indicate, + Torna a conversione/$ARGUMENTS/clean.md e correggi manualmente le sezioni indicate, poi riesegui nell'ordine: - python step-5/chunker.py --stem $ARGUMENTS --force - python step-6/verify_chunks.py --stem $ARGUMENTS + python chunks/chunker.py --stem $ARGUMENTS --force + python chunks/verify_chunks.py --stem $ARGUMENTS ``` diff --git a/CLAUDE.md b/CLAUDE.md index fc0e27b..109fafd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,4 +43,4 @@ Per i path degli script e degli output usa `git ls-files` o esplora la root: la ## Skills custom - `/prepare-md ` — corregge `clean.md`: sillabazione, artefatti, header, paragrafi spezzati, gerarchia. -- `/step6-fix ` — verifica chunk, dry-run e applicazione fix via `fix_chunks.py`. +- `/post-chunk ` — verifica chunk, dry-run, fix via `fix_chunks.py` e prepara per la vettorizzazione. From ebd2a43f845ffedef25aebba62a0ad2de71ea6af Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 11 May 2026 14:46:16 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20integra=20pipeline=20PDF=E2=86=92Ma?= =?UTF-8?q?rkdown=20a=209=20stadi=20e=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porta da main la riscrittura completa di conversione/_pipeline/ (9 stadi PyMuPDF) e la suite tests/ senza modificare chunks/, step-8/, rag.py, ollama/, retrieve.py, config.py. requirements.txt: aggiunge PyMuPDF>=1.24.0 e pytest>=8.0, mantiene chromadb, rimuove opendataloader-pdf e pymupdf4llm. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 +- CLAUDE.md | 206 +++++++++++-- README.md | 153 ++-------- conversione/__main__.py | 109 +++++++ conversione/_pipeline/__init__.py | 30 ++ conversione/_pipeline/_constants.py | 169 +++++++++++ conversione/_pipeline/_helpers.py | 153 ++++++++++ conversione/_pipeline/extract.py | 82 ++++++ conversione/_pipeline/models.py | 44 +++ conversione/_pipeline/report.py | 135 +++++++++ conversione/_pipeline/runner.py | 220 ++++++++++++++ conversione/_pipeline/stage1_metadata.py | 260 +++++++++++++++++ conversione/_pipeline/stage2_layout.py | 184 ++++++++++++ conversione/_pipeline/stage3_font.py | 53 ++++ conversione/_pipeline/stage4_headers.py | 162 +++++++++++ conversione/_pipeline/stage5_hierarchy.py | 147 ++++++++++ conversione/_pipeline/stage6_tree.py | 54 ++++ conversione/_pipeline/stage7_markdown.py | 224 ++++++++++++++ conversione/_pipeline/stage8_normalize.py | 337 ++++++++++++++++++++++ conversione/_pipeline/stage9_validate.py | 97 +++++++ conversione/_pipeline/structure.py | 141 +++++++++ conversione/_pipeline/validator.py | 152 ++++++++++ conversione/clear.sh | 30 +- conversione/pipeline.py | 3 - requirements.txt | 4 +- tests/__init__.py | 0 tests/conftest.py | 96 ++++++ tests/integration/__init__.py | 0 tests/integration/test_pipeline_e2e.py | 68 +++++ tests/integration/test_stage8_repair.py | 40 +++ tests/unit/__init__.py | 0 tests/unit/test_models.py | 47 +++ tests/unit/test_stage3.py | 44 +++ tests/unit/test_stage4.py | 52 ++++ tests/unit/test_stage5.py | 95 ++++++ tests/unit/test_stage6.py | 98 +++++++ tests/unit/test_stage7.py | 62 ++++ tests/unit/test_stage8.py | 49 ++++ tests/unit/test_stage9.py | 36 +++ 39 files changed, 3688 insertions(+), 153 deletions(-) create mode 100644 conversione/__main__.py create mode 100644 conversione/_pipeline/__init__.py create mode 100644 conversione/_pipeline/_constants.py create mode 100644 conversione/_pipeline/_helpers.py create mode 100644 conversione/_pipeline/extract.py create mode 100644 conversione/_pipeline/models.py create mode 100644 conversione/_pipeline/report.py create mode 100644 conversione/_pipeline/runner.py create mode 100644 conversione/_pipeline/stage1_metadata.py create mode 100644 conversione/_pipeline/stage2_layout.py create mode 100644 conversione/_pipeline/stage3_font.py create mode 100644 conversione/_pipeline/stage4_headers.py create mode 100644 conversione/_pipeline/stage5_hierarchy.py create mode 100644 conversione/_pipeline/stage6_tree.py create mode 100644 conversione/_pipeline/stage7_markdown.py create mode 100644 conversione/_pipeline/stage8_normalize.py create mode 100644 conversione/_pipeline/stage9_validate.py create mode 100644 conversione/_pipeline/structure.py create mode 100644 conversione/_pipeline/validator.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_pipeline_e2e.py create mode 100644 tests/integration/test_stage8_repair.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_stage3.py create mode 100644 tests/unit/test_stage4.py create mode 100644 tests/unit/test_stage5.py create mode 100644 tests/unit/test_stage6.py create mode 100644 tests/unit/test_stage7.py create mode 100644 tests/unit/test_stage8.py create mode 100644 tests/unit/test_stage9.py diff --git a/.gitignore b/.gitignore index e3783db..f1ea50a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,11 @@ __pycache__/ Thumbs.db -# 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/*/ diff --git a/CLAUDE.md b/CLAUDE.md index 109fafd..d3ff47b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,46 +1,214 @@ -# 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 + +Ricostruire la struttura logica di PDF digitali e serializzarla in Markdown **stabile e valido per la vettorizzazione RAG**, senza LLM né OCR. Il Markdown è solo il formato di output finale — la pipeline deve operare su una rappresentazione intermedia strutturata. + +**Non supportato:** PDF scansionati (immagini), PDF protetti da password. + +--- ## Regole invarianti - **Lingua:** Rispondi sempre in italiano. - **Venv:** Usa `.venv/bin/python` o `source .venv/bin/activate`. Mai `pip`/`python` di sistema. -- **`raw.md` immutabile:** La copia di lavoro è sempre `clean.md`. +- **`raw.md` immutabile:** Non modificare mai `raw.md`. La copia di lavoro è sempre `clean.md`. +- **Niente LLM nella pipeline:** tutta la logica deve essere rule-based e riproducibile. --- -## Pipeline +## Setup -``` -PDF → conversione → chunking → verifica → vettorizzazione → retrieval +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt ``` -`--stem` = nome PDF senza estensione = nome collection ChromaDB. +Dipendenze principali: -Per i path degli script e degli output usa `git ls-files` o esplora la root: la struttura è in evoluzione verso un programma unico. +- **PyMuPDF** (`fitz`) — estrazione primaria con metadati font e coordinate +- **pdfplumber** — ricostruzione tabelle (opzionale, non per parsing generico) --- -## Configurazione +## Comandi -`config.py` è la fonte di verità: `EMBED_MODEL`, `OLLAMA_MODEL`, `TOP_K`, `TEMPERATURE`, `SYSTEM_PROMPT`. +```bash +# Converti un PDF (posizionalo prima in sources/.pdf) +.venv/bin/python conversione/ --stem -**Se cambi `EMBED_MODEL`:** riesegui ingest con `--force` — embedding incoerenti non producono errori ma risposte insensate. +# Forza riesecuzione (sovrascrive clean.md esistente) +.venv/bin/python conversione/ --stem --force -**Se cambi `MIN_CHARS` / `MAX_CHARS`:** cerca tutte le occorrenze nel repo e sincronizza. +# Tutti i PDF in sources/ +.venv/bin/python conversione/ + +# Validazione batch +.venv/bin/python conversione/ validate +.venv/bin/python conversione/ validate --detail + +# Rimuove l'output di uno stem +bash conversione/clear.sh + +# Test suite +.venv/bin/python -m pytest tests/ +``` + +`--stem` = nome file PDF senza estensione. --- -## Workflow consigliato +## Architettura -1. Converti il PDF con lo script di conversione -2. `/prepare-md conversione//clean.md` -3. Chunking -4. Vettorizza con `--stem ` -6. `python rag.py --stem ` +### Principio fondamentale + +La pipeline **non converte direttamente PDF → Markdown**. + +``` +PDF → Structured Document Tree → Markdown +``` + +Il Markdown è generato solo dall'albero documentale. Non dal testo grezzo. + +### Modello dati intermedio + +```python +class Block: + text: str + page: int + bbox: tuple + font_size: float + font_name: str + is_bold: bool + block_type: str # "header" | "paragraph" | "list" | "table" | "code" + +class Section: + title: str + level: int # 1, 2, 3 + content: list[Block] + children: list[Section] +``` + +Il Markdown si genera **solo** da `Section`. Mai da `Block` direttamente. + +--- + +### I 9 stadi della pipeline + +#### Stage 1 — Metadata Extraction + +Usa `page.get_text("dict")` (o `"rawdict"`) di PyMuPDF. **Non usare estrazione plain text.** + +Per ogni span estrai: testo, font size, font name, flags, bbox, numero di pagina. +Estrai anche: TOC del documento, bookmark, dimensioni pagina. + +#### Stage 2 — Layout Analysis + +Identifica i blocchi strutturali preservando l'ordine di lettura: +headers, paragrafi, liste, tabelle, code block, interruzioni di pagina. + +#### Stage 3 — Font Analysis + +Inferisce la gerarchia visiva **per documento** (non hardcoded): +- calcola il font size dominante del corpo +- raggruppa i font size in cluster +- identifica i candidati header per dimensione + +#### Stage 4 — Header Detection + +Segnali combinati (tutti richiesti): +- font size > corpo testo +- boldness / semibold +- numerazione (`^\d+(\.\d+)*\s+`) +- spaziatura verticale sopra/sotto +- lunghezza riga corta + +#### Stage 5 — Hierarchy Inference + +Priorità delle regole (in ordine): + +1. **Numerazione** — `1` → H1, `1.1` → H2, `1.1.1` → H3 (ha precedenza sul font size) +2. **TOC del PDF** — se presente, è autoritativo; allineare i header rilevati alla sua gerarchia +3. **Font size clustering** — fallback se né numerazione né TOC esistono + +#### Stage 6 — Document Tree Reconstruction + +Costruisce l'albero `Section` con relazioni parent-child, ordinamento e nesting. Ogni nodo contiene titolo, livello, contenuto e figli. + +#### Stage 7 — Markdown Generation + +Serializza l'albero in Markdown valido: +- Header: `#`/`##`/`###` senza salti di livello +- Liste: preserva nesting ordered/unordered +- Tabelle: GitHub-compatible; fallback testo strutturato +- Code block: fenced con language tag dove rilevabile + +#### Stage 8 — Hierarchy Normalization + +Ripara le inconsistenze strutturali: +- salti di livello invalidi (`# A` → `#### B` diventa `# A` → `## B`) +- header vuoti (rimuovi o mergia) +- header consecutivi duplicati (collassa) +- nesting rotto + +#### Stage 9 — Structural Validation + +Valida il Markdown finale: +- nessun salto di livello heading +- nessuna sezione vuota +- liste correttamente annidate +- tabelle con colonne consistenti +- ordine uguale al PDF sorgente + +--- + +## Cosa rende un Markdown perfetto per la vettorizzazione + +- **Struttura semantica:** ogni header è un confine naturale di chunk; ogni sezione è un'unità concettuale. +- **Gerarchia corretta:** h1/h2/h3 riflettono la struttura logica, non il layout tipografico. +- **Testo pulito:** nessun artefatto di encoding, footnote superscript, `
`, dot-leader, PUA. +- **Paragrafi interi:** nessuna frase troncata da salto pagina. +- **Output deterministico:** stessa pipeline su stesso PDF produce sempre lo stesso output. + +--- + +## Linee guida per sviluppare la pipeline + +- Ogni stage deve essere **indipendentemente testabile**. +- Le regex per header numbering e simili vanno compilate in `_constants.py`, mai inline. +- PyMuPDF è il parser primario. pdfplumber si usa solo per tabelle complesse. +- Ogni stage deve ricevere l'output del precedente come struttura tipizzata, non testo grezzo. +- Prima di aggiungere un nuovo segnale di detection (Stage 4), validarlo su almeno 3 PDF diversi. + +### Categorie di test richieste + +| Categoria | Input | Validazione attesa | +|-----------|-------|-------------------| +| Header reconstruction | PDF con H1/H2/H3 numerati | gerarchia corretta, no level skip | +| TOC alignment | PDF con bookmark/TOC | markdown allineato al TOC | +| Mixed font sizes | Font inconsistenti, bold nel corpo | body non classificato come header | +| Broken layout | Header multi-riga, spacing irregolare | header mergiati, markdown valido | +| Tables | Tabelle nel PDF | markdown table con colonne preservate | +| Lists | Liste ordered/unordered annidate | nesting corretto | +| Large documents | PDF tecnico voluminoso | output deterministico, memoria stabile | +| Invalid hierarchy repair | `# A` + `#### B` artificiale | riparazione automatica in `# A` + `## B` | + +--- + +## Pipeline attuale + +La pipeline in `conversione/_pipeline/` (basata su trasformazioni testo con `_apply.py`) è **deprecata** e deve essere sostituita dall'architettura a 9 stadi descritta sopra. Durante la migrazione: + +- separare estrazione da ricostruzione +- introdurre strutture intermedie esplicite (`Block`, `Section`) +- rimuovere l'architettura parser-centrica +- ogni stage deve essere indipendente e testabile --- ## Skills custom -- `/prepare-md ` — corregge `clean.md`: sillabazione, artefatti, header, paragrafi spezzati, gerarchia. -- `/post-chunk ` — verifica chunk, dry-run, fix via `fix_chunks.py` e prepara per la vettorizzazione. +- `/prepare-md ` — corregge `clean.md` quando la pipeline non basta: sillabazione, artefatti residui, header malformati, gerarchia incoerente. diff --git a/README.md b/README.md index 262debf..a3ca353 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,9 @@ -# RAG from Scratch +# 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 · ChromaDB -**Compatibile con:** Linux · macOS · Windows (WSL2) · CPU only · ~8 GB RAM - ---- - -## Pipeline - -``` -PDF → conversione → chunking → verifica → vettorizzazione → retrieval -``` - -| Fase | Rischio | Motivo | -|---|---|---| -| Conversione | 🟡 Medio | Automatica, ma il PDF deve essere digitale e non protetto | -| Revisione Markdown | 🔴 Alto | La qualità del MD determina la qualità del RAG | -| Chunking | 🟡 Medio | Adattivo, dipende dalla qualità del MD | -| Vettorizzazione | 🟢 Basso | Meccanica, lenta ma affidabile | -| Retrieval | 🟡 Medio | Dipende dai parametri in `config.py` | - ---- - -## Struttura del progetto - -``` -rag/ -├── sources/ # PDF originali — non modificare -├── conversione/ # PDF → Markdown strutturato -│ ├── pipeline.py -│ ├── validate.py -│ └── / -│ ├── raw.md # grezzo — non modificare -│ ├── clean.md # copia di lavoro -│ └── report.json -├── step-5/ # Chunking -│ ├── chunker.py -│ └── /chunks.json -├── step-6/ # Verifica e fix chunk -│ ├── verify_chunks.py -│ ├── fix_chunks.py -│ └── / -│ ├── chunks.json -│ └── report.json -├── step-8/ # Vettorizzazione -│ └── ingest.py -├── ollama/ # Setup ambiente -│ ├── check_env.py -│ └── test_ollama.py -├── chroma_db/ # Vector store (generato) -├── config.py # Configurazione pipeline ← modifica qui -├── rag.py # Interrogazione RAG interattiva -└── retrieve.py # Retrieval puro (senza LLM) -``` - -`--stem` = nome del PDF senza estensione = nome della collection ChromaDB. +**Stack:** Python · opendataloader-pdf (XY-Cut++) · Java 11+ +**Compatibile con:** Linux · macOS · Windows (WSL2) --- @@ -68,92 +15,54 @@ source .venv/bin/activate pip install -r requirements.txt ``` -**Java 11+** richiesto per la conversione (`opendataloader-pdf`): +**Java 11+** richiesto: ```bash sudo apt install default-jdk # Ubuntu/Debian/WSL -java -version # verifica +java -version ``` -Vedi [`ollama/README.md`](ollama/README.md) per l'installazione di Ollama e il download dei modelli. - --- -## Workflow - -### 1. Converti il PDF +## Utilizzo ```bash +# Singolo PDF python conversione/pipeline.py --stem + +# Tutti i PDF in sources/ +python conversione/pipeline.py + +# Forza riesecuzione +python conversione/pipeline.py --stem --force ``` -Produce `conversione//clean.md`. Vedi [`conversione/README.md`](conversione/README.md). - -### 2. Rivedi il Markdown - -``` -/prepare-md conversione//clean.md -``` - -Passaggio più importante: la qualità del RAG dipende da questo. - -### 3. Chunking - -```bash -python step-5/chunker.py --stem -``` - -### 4. Verifica e fix chunk - -```bash -python step-6/verify_chunks.py --stem -python step-6/fix_chunks.py --stem # se ci sono 🔴 -python step-6/verify_chunks.py --stem # ri-verifica -``` - -Non procedere alla vettorizzazione se ci sono 🔴. - -### 5. Vettorizza - -```bash -python step-8/ingest.py --stem -``` - -Vedi [`step-8/README.md`](step-8/README.md). Usa `--force` se hai cambiato `EMBED_MODEL` o i chunk. - -### 6. Interroga - -```bash -python rag.py --stem # risposta LLM -python retrieve.py --stem # retrieval puro (debug) -``` +`--stem` = nome file PDF senza estensione. +Esempio: `sources/analisi1.pdf` → `--stem analisi1` --- -## Configurazione (`config.py`) +## Output -| Parametro | Default | Descrizione | -|---|---|---| -| `EMBED_MODEL` | `"nomic-embed-text"` | Modello embedding — deve corrispondere tra ingest e retrieval | -| `OLLAMA_MODEL` | `"qwen3.5:0.8b"` | Modello LLM | -| `OLLAMA_URL` | `"http://localhost:11434"` | Endpoint Ollama | -| `TOP_K` | `6` | Chunk recuperati per query | -| `TEMPERATURE` | `0.0` | Deterministico a `0.0` | -| `NO_THINK` | `True` | Disabilita chain-of-thought (Qwen3/Qwen3.5) | -| `SYSTEM_PROMPT` | *(vedi file)* | Istruzioni di comportamento per il LLM | +Per ogni stem in `conversione//`: -> Se cambi `EMBED_MODEL`, riesegui `step-8/ingest.py --stem --force`. +| 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 | --- -## Principi +## Validazione batch -**Atomico** — ogni fase fa una cosa sola; se si rompe qualcosa sai esattamente dove. +```bash +python conversione/validate.py +``` -**Verificabile** — ogni fase ha un criterio di completamento oggettivo prima di procedere. +Stampa una tabella di stato su tutti gli stem convertiti. -**Reversibile** — puoi tornare indietro senza perdere il lavoro delle altre fasi. +--- -**Adattivo** — nessuna assunzione sulla struttura del documento; si adatta automaticamente. - -**Locale** — nessuna API esterna, nessun dato trasmesso fuori dalla macchina. +Vedi [`conversione/README.md`](conversione/README.md) per dettagli sulla pipeline e i tipi di documento supportati. diff --git a/conversione/__main__.py b/conversione/__main__.py new file mode 100644 index 0000000..05b9dcb --- /dev/null +++ b/conversione/__main__.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Pipeline PDF → clean Markdown per vettorizzazione RAG. + +Uso: + # Converti + python conversione/ --stem + python conversione/ --stem --force + python conversione/ # tutti i PDF in sources/ + + # Valida + python conversione/ validate + python conversione/ validate [ ...] --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 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) ──────────────────────────────────────────────── + 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() diff --git a/conversione/_pipeline/__init__.py b/conversione/_pipeline/__init__.py new file mode 100644 index 0000000..002fb25 --- /dev/null +++ b/conversione/_pipeline/__init__.py @@ -0,0 +1,30 @@ +from .extract import validate_pdf, extract_metadata +from .structure import analyze +from .report import build_report +from .runner import run +from .validator import validate +from .models import Block, Section, FontProfile +from .stage1_metadata import extract_raw_data +from .stage2_layout import analyze_layout +from .stage3_font import build_font_profile +from .stage4_headers import classify_blocks +from .stage5_hierarchy import infer_hierarchy +from .stage6_tree import build_tree +from .stage7_markdown import serialize_tree +from .stage8_normalize import normalize_hierarchy +from .stage9_validate import validate_markdown, ValidationResult + +__all__ = [ + "validate_pdf", "extract_metadata", + "analyze", "build_report", "run", "validate", + "Block", "Section", "FontProfile", + "extract_raw_data", + "analyze_layout", + "build_font_profile", + "classify_blocks", + "infer_hierarchy", + "build_tree", + "serialize_tree", + "normalize_hierarchy", + "validate_markdown", "ValidationResult", +] diff --git a/conversione/_pipeline/_constants.py b/conversione/_pipeline/_constants.py new file mode 100644 index 0000000..6dc14f3 --- /dev/null +++ b/conversione/_pipeline/_constants.py @@ -0,0 +1,169 @@ +""" +Costanti di modulo condivise tra i moduli di trasformazione. +Tutte le regex compilate e le mappe statiche vivono qui. +""" +import re + +# ─── Keyword sets ───────────────────────────────────────────────────────────── + +_TOC_KEYWORDS = frozenset([ + "indice", "index", "contents", "table of contents", + "sommario", "inhaltsverzeichnis", "inhalt", + "indice generale", "indice analitico", "indice dei contenuti", + "elenco dei capitoli", "argomenti", "table des matières", + "tabla de contenidos", "содержание", +]) + +_ORDINALS_IT = { + "PRIMO": "I", "SECONDO": "II", "TERZO": "III", "QUARTO": "IV", + "QUINTO": "V", "SESTO": "VI", "SETTIMO": "VII", "OTTAVO": "VIII", + "NONO": "IX", "DECIMO": "X", +} +_ORDINALS_EN = { + "ONE": "1", "TWO": "2", "THREE": "3", "FOUR": "4", "FIVE": "5", + "SIX": "6", "SEVEN": "7", "EIGHT": "8", "NINE": "9", "TEN": "10", +} + +# ─── PUA Symbol font map ────────────────────────────────────────────────────── + +_SYMBOL_PUA_MAP: dict[str, str] = { + "": " ", + "": "(", + "": ")", + "": "+", + "": "−", + "": ".", + "": "/", + "": "0", "": "1", "": "2", "": "3", "": "4", + "": "5", "": "6", "": "7", "": "8", "": "9", + "": ":", "": ";", "": "<", "": "=", "": ">", + "": "≅", + "": "Α", "": "Β", "": "Χ", "": "Δ", "": "Ε", + "": "Φ", "": "Γ", "": "Η", "": "Ι", "": "ϑ", + "": "Κ", "": "Λ", "": "Μ", "": "Ν", "": "Ο", + "": "Π", "": "Θ", "": "Ρ", "": "Σ", "": "Τ", + "": "Υ", "": "ς", "": "Ω", "": "Ξ", "": "Ψ", + "": "Ζ", + "": "[", + "": "∴", + "": "]", + "": "⊥", + "": "α", "": "β", "": "χ", "": "δ", "": "ε", + "": "φ", "": "γ", "": "η", "": "ι", "": "ϕ", + "": "κ", "": "λ", "": "μ", "": "ν", "": "ο", + "": "π", "": "θ", "": "ρ", "": "σ", "": "τ", + "": "υ", "": "ϖ", "": "ω", "": "ξ", "": "ψ", + "": "ζ", + "": "{", + "": "|", + "": "}", + "": "~", + "": "±", + "": "•", + "": "√", + "": "≤", + "": "≥", + "": "∝", + "": "×", + "": "÷", + "": "×", + "": "≠", + "": "≠", + "": "≥", + "": "′", + "": "*", + "": ",", + "": "≤", + "": "•", + "": "•", + "": "→", + "": "÷", + "": "", + "": "→", + "": "", + "": "", + "": "", + "": "", + # TeX Computer Modern bracket/delimiter pieces (U+F8EB–F8FE) → stringa vuota + "": "", # TeX large paren left + "": "", # TeX large paren extension + "": "", # TeX large paren right + "": "", # TeX large paren right ext + "": "", # TeX large bracket left + "": "", # TeX large bracket ext + "": "", # TeX brace top-left + "": "", # TeX brace mid + "": "", # TeX brace mid-right + "": "", # TeX brace extension + "": "", # TeX brace right + "": "", # TeX bracket right large + "": "", # TeX bracket right ext + "": "", # TeX bracket right close + "": "", # TeX integral large + "": "", # TeX integral extension + "": "", # TeX integral top + "": "", # TeX radical top + "": "", # TeX radical extension + "": "", # TeX arrowhead +} + +_SYMBOL_PUA_RE = re.compile( + "[" + "".join(re.escape(k) for k in _SYMBOL_PUA_MAP) + "]" +) + +# ─── Regex compilate condivise ──────────────────────────────────────────────── + +_SUPERSCRIPT_RE = re.compile(r'[¹²³⁰⁴-⁹]+') +_FOOTNOTE_BODY_RE = re.compile( + r'^([¹²³⁰⁴-⁹]+\s+|\[\d{1,3}\]\s+)' +) +_NUMBERED_HDR_RE = re.compile( + r"^(#{1,6})\s+(\d+(?:\.\d+)*)\.\s+(.+)$", + re.MULTILINE, +) +_BIB_MARKERS_RE = re.compile( + r'\b(pp?\.|vol\.|n\.\s*\d|ed\.|edn\.|ISBN|DOI|arXiv)\b' + r'|\b(19|20)\d{2}\b' + r'|\b(ibid\.?|ibidem|op\.\s*cit\.?|cit\.|cfr\.|ivi[,;\s])\b', + re.IGNORECASE, +) +# Pattern autore accademico: iniziale maiuscola + cognome TUTTO-MAIUSCOLO (es. "A. SMITH") +_FOOTNOTE_AUTHOR_RE = re.compile(r'(?\s*$") +_STANDALONE_NUM_RE = re.compile(r"(?m)^(?:- )?\d{1,3}$") +_UNDERSCORE_SEP_RE = re.compile(r"(?m)^_{4,}\s*$") diff --git a/conversione/_pipeline/_helpers.py b/conversione/_pipeline/_helpers.py new file mode 100644 index 0000000..e91ad1b --- /dev/null +++ b/conversione/_pipeline/_helpers.py @@ -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 diff --git a/conversione/_pipeline/extract.py b/conversione/_pipeline/extract.py new file mode 100644 index 0000000..c28012f --- /dev/null +++ b/conversione/_pipeline/extract.py @@ -0,0 +1,82 @@ +"""Validazione PDF e estrazione metadati tramite fitz.""" +import re +from pathlib import Path + + +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}" + + +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() + + 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": (meta.get("title") or "").strip(), + "author": (meta.get("author") or "").strip(), + "year": year, + "pages": pages, + } + except Exception: + return {"source": pdf_path.name, "title": "", "author": "", "year": "", "pages": 0} diff --git a/conversione/_pipeline/models.py b/conversione/_pipeline/models.py new file mode 100644 index 0000000..c12d70d --- /dev/null +++ b/conversione/_pipeline/models.py @@ -0,0 +1,44 @@ +"""Strutture dati intermedie della pipeline: Block, Section, FontProfile.""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Block: + text: str + page: int + bbox: tuple[float, float, float, float] # x0, y0, x1, y1 + font_size: float + font_name: str + is_bold: bool + block_type: str = "paragraph" # paragraph|header_candidate|list_item|table|ignore + space_before: float = 0.0 + level: int = 0 # assegnato da stage5 (0 = non header) + origin_spans: list[dict] = field(default_factory=list, repr=False) + + @property + def x0(self) -> float: return self.bbox[0] + @property + def y0(self) -> float: return self.bbox[1] + @property + def x1(self) -> float: return self.bbox[2] + @property + def y1(self) -> float: return self.bbox[3] + + +@dataclass +class Section: + title: str + level: int # 1, 2, 3 + content: list[Block] = field(default_factory=list) + children: list[Section] = field(default_factory=list) + page_start: int = 0 + source_block: Block | None = field(default=None, repr=False) + + +@dataclass +class FontProfile: + body_size: float + cluster_map: dict[float, int] # font_size arrotondato → livello (1/2/3) + header_sizes: list[float] # taglie candidate header, ordinate desc diff --git a/conversione/_pipeline/report.py b/conversione/_pipeline/report.py new file mode 100644 index 0000000..501603d --- /dev/null +++ b/conversione/_pipeline/report.py @@ -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"(?:\. ){5,}"), + "url": _scan(r"^(https?://|www\.)\S+"), + "immagini": _scan(r"!\[[^\]]*\]\([^)]*\)"), + "br_inline": _scan(r"
"), + "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 diff --git a/conversione/_pipeline/runner.py b/conversione/_pipeline/runner.py new file mode 100644 index 0000000..c827501 --- /dev/null +++ b/conversione/_pipeline/runner.py @@ -0,0 +1,220 @@ +"""Orchestrazione della pipeline PDF → Markdown a 9 stadi.""" +import json +import sys +import threading +import time +from pathlib import Path + +from .extract import validate_pdf, extract_metadata +from .stage1_metadata import extract_raw_data_with_pdfplumber_fallback as extract_raw_data +from .stage2_layout import analyze_layout +from .stage3_font import build_font_profile +from .stage4_headers import classify_blocks +from .stage5_hierarchy import infer_hierarchy +from .stage6_tree import build_tree +from .stage7_markdown import serialize_tree +from .stage8_normalize import normalize_hierarchy +from .stage9_validate import validate_markdown +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"} +_SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +def _build_frontmatter(meta: dict) -> str: + lines = ["---", f"source: {meta['source']}"] + if meta.get("title"): + lines.append(f'title: "{meta["title"]}"') + if meta.get("author"): + lines.append(f'author: "{meta["author"]}"') + if meta.get("year"): + lines.append(f"year: {meta['year']}") + if meta.get("pages"): + lines.append(f"pages: {meta['pages']}") + lines += ["---", ""] + return "\n".join(lines) + "\n" + + +class _Spinner: + 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 PDF ─────────────────────────────────────────────────── + print(" [1/9] 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) + meta["source"] = pdf_path.name + if meta.get("title"): + print(f" Titolo: {meta['title']}") + if meta.get("author"): + print(f" Autore: {meta['author']}") + + # ── [2] Stage 1: estrazione span ────────────────────────────────────────── + print(" [2/9] Stage 1: Estrazione span PyMuPDF...") + with _Spinner("Lettura PDF con PyMuPDF..."): + try: + raw_blocks, doc_meta = extract_raw_data(pdf_path) + except Exception as e: + print(f" ✗ Estrazione fallita: {e}") + return False + + print(f" ✅ {len(raw_blocks)} span estratti da {doc_meta['page_count']} pagine") + toc_entries = len(doc_meta.get("toc", [])) + if toc_entries: + print(f" TOC: {toc_entries} voci") + + # ── [3] Stage 2: layout ─────────────────────────────────────────────────── + print(" [3/9] Stage 2: Analisi layout e reading order...") + with _Spinner("Analisi layout..."): + blocks = analyze_layout(raw_blocks, doc_meta) + print(f" ✅ {len(blocks)} blocchi dopo layout analysis") + + # ── [4] Stage 3: font analysis ──────────────────────────────────────────── + print(" [4/9] Stage 3: Font analysis...") + profile = build_font_profile(blocks) + print(f" ✅ Body size: {profile.body_size}pt " + f"Header sizes: {profile.header_sizes}") + + # ── [5] Stage 4: header detection ───────────────────────────────────────── + print(" [5/9] Stage 4: Header detection...") + blocks = classify_blocks(blocks, profile) + n_candidates = sum(1 for b in blocks if b.block_type == "header_candidate") + print(f" ✅ {n_candidates} header candidate rilevati") + + # ── [6] Stage 5: hierarchy inference ───────────────────────────────────── + print(" [6/9] Stage 5: Hierarchy inference...") + blocks = infer_hierarchy(blocks, profile, doc_meta.get("toc", [])) + from collections import Counter + level_dist = Counter(b.level for b in blocks if b.block_type == "header_candidate") + print(f" ✅ H1={level_dist.get(1,0)} H2={level_dist.get(2,0)} H3={level_dist.get(3,0)}") + + # ── [7] Stage 6: document tree ──────────────────────────────────────────── + print(" [7/9] Stage 6: Document tree reconstruction...") + tree = build_tree(blocks) + print(f" ✅ {len(tree)} sezioni radice") + + # ── [8] Stage 7: markdown generation ───────────────────────────────────── + print(" [8/9] Stage 7: Markdown generation...") + with _Spinner("Serializzazione albero..."): + raw_md = serialize_tree(tree, meta, pdf_path=pdf_path) + + size_kb = len(raw_md.encode()) // 1024 + n_lines = raw_md.count("\n") + print(f" ✅ raw.md: {size_kb} KB, {n_lines} righe") + + # Scrittura raw.md (IMMUTABILE) + try: + out_dir.mkdir(parents=True, exist_ok=True) + if not raw_out.exists() or force: + raw_out.write_text(raw_md, encoding="utf-8") + except PermissionError as e: + print(f" ✗ Permesso negato durante la scrittura: {e}") + return False + + # ── [9] Stage 8+9: normalizzazione + validazione ────────────────────────── + print(" [9/9] Stage 8-9: Normalize + validate...") + clean_md, norm_stats = normalize_hierarchy(raw_md) + validation = validate_markdown(clean_md, meta.get("pages", 0)) + + if norm_stats["n_level_jumps_repaired"]: + print(f" Salti livello riparati: {norm_stats['n_level_jumps_repaired']}") + if norm_stats["n_empty_headers_removed"]: + print(f" Header vuoti rimossi: {norm_stats['n_empty_headers_removed']}") + if norm_stats["n_duplicate_headers_removed"]: + print(f" Header duplicati rimossi: {norm_stats['n_duplicate_headers_removed']}") + + for w in validation.warnings: + print(f" ⚠️ {w}") + for e in validation.errors: + print(f" ✗ {e}") + + # Aggiungi frontmatter a clean.md + frontmatter = _build_frontmatter(meta) + full_clean = frontmatter + clean_md + + try: + clean_out.write_text(full_clean, encoding="utf-8") + except PermissionError as e: + print(f" ✗ Permesso negato durante la scrittura di clean.md: {e}") + return False + + print(f" ✅ clean.md scritto") + + # ── Analisi struttura + report + score ──────────────────────────────────── + profile_struct = analyze(clean_out) + (out_dir / "structure_profile.json").write_text( + json.dumps(profile_struct, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + print(f" Struttura: livello {profile_struct['livello_struttura']} — " + f"{_LIVELLO_DESC[profile_struct['livello_struttura']]}") + print(f" h1={profile_struct['n_h1']} h2={profile_struct['n_h2']} " + f"h3={profile_struct['n_h3']} paragrafi={profile_struct['n_paragrafi']}") + print(f" Strategia chunking: {profile_struct['strategia_chunking']}") + print(f" Lingua rilevata: {profile_struct['lingua_rilevata']}") + for w in profile_struct["avvertenze"]: + print(f" ⚠️ {w}") + + t_stats = { + **norm_stats, + "validation": validation.to_dict(), + } + reduction = 100.0 * (1 - len(clean_md) / len(raw_md)) if raw_md else 0.0 + report_path = build_report(stem, out_dir, full_clean, t_stats, profile_struct, 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 diff --git a/conversione/_pipeline/stage1_metadata.py b/conversione/_pipeline/stage1_metadata.py new file mode 100644 index 0000000..36e3de9 --- /dev/null +++ b/conversione/_pipeline/stage1_metadata.py @@ -0,0 +1,260 @@ +"""Stage 1: estrazione raw span da PDF con PyMuPDF + metadati documento.""" +from pathlib import Path + +import fitz # PyMuPDF + +from .models import Block + + +_BOLD_FONT_KEYWORDS = ("bold", "heavy", "black", "demi", "semibold") + +# Mappa PUA (U+F000–U+F0FF) → Unicode per font Symbol e font math LaTeX. +# Le chiavi sono caratteri nel range PUA come estratti da PyMuPDF. +_SYMBOL_PUA_MAP: dict[str, str] = { + '': ' ', '': '!', '': '∀', '': '#', + '': '∃', '': '%', '': '&', '': '∋', + '': '(', '': ')', '': '∗', '': '+', + '': ',', '': '−', '': '.', '': '/', + '': '0', '': '1', '': '2', '': '3', + '': '4', '': '5', '': '6', '': '7', + '': '8', '': '9', '': ':', '': ';', + '': '<', '': '=', '': '>', '': '?', + '': '≅', '': 'Α', '': 'Β', '': 'Χ', + '': 'Δ', '': 'Ε', '': 'Φ', '': 'Γ', + '': 'Η', '': 'Ι', '': 'ϑ', '': 'Κ', + '': 'Λ', '': 'Μ', '': 'Ν', '': 'Ο', + '': 'Π', '': 'Θ', '': 'Ρ', '': 'Σ', + '': 'Τ', '': 'Υ', '': 'ς', '': 'Ω', + '': 'Ξ', '': 'Ψ', '': 'Ζ', '': '[', + '': '∴', '': ']', '': '⊥', '': '_', + '': 'α', '': 'β', '': 'χ', '': 'δ', + '': 'ε', '': 'φ', '': 'γ', '': 'η', + '': 'ι', '': 'ϕ', '': 'κ', '': 'λ', + '': 'μ', '': 'ν', '': 'ο', '': 'π', + '': 'θ', '': 'ρ', '': 'σ', '': 'τ', + '': 'υ', '': 'ϖ', '': 'ω', '': 'ξ', + '': 'ψ', '': 'ζ', '': '{', '': '|', + '': '}', '': '∼', + '': 'ϒ', '': '′', '': '≤', '': '⁄', + '': '∞', '': 'ƒ', '': '♣', '': '♦', + '': '♥', '': '♠', '': '↔', '': '←', + '': '↑', '': '→', '': '↓', + '': '°', '': '±', '': '″', '': '≥', + '': '×', '': '∝', '': '∂', '': '•', + '': '÷', '': '≠', '': '≡', '': '≈', + '': '…', '': '|', '': '–', + '': 'ℵ', '': 'ℑ', '': 'ℜ', '': '℘', + '': '⊗', '': '⊕', '': '∅', '': '∩', + '': '∪', '': '⊃', '': '⊇', '': '⊄', + '': '⊂', '': '⊆', '': '∈', '': '∉', + '': '∠', '': '∇', '': '∏', '': '©', + '': '™', '': '∏', '': '√', '': '⋅', + '': '¬', '': '∧', '': '∨', + '': '◊', '': '⟨', '': '∑', + '': '⟩', '': '∫', '': '⌠', '': '⌡', +} + +# Font che tipicamente contengono caratteri PUA math (LaTeX e Symbol) +_MATH_FONT_KEYWORDS = ("symbol", "cmmi", "cmsy", "msam", "msbm", "eurm", "cmex", "math") + + +def _clean_pua(text: str) -> str: + """ + Applica la mappatura PUA→Unicode a TUTTI i testi estratti. + Converte i caratteri nel range U+F000–U+F0FF usando _SYMBOL_PUA_MAP; + i caratteri PUA non mappati vengono rimossi (sostituiti con stringa vuota). + """ + result = [] + for ch in text: + cp = ord(ch) + if 0xF000 <= cp <= 0xF0FF: + mapped = _SYMBOL_PUA_MAP.get(ch) + if mapped is not None: + result.append(mapped) + # carattere PUA non mappato → scarta (artefatto illeggibile) + else: + result.append(ch) + return ''.join(result) + + +def _is_bold_span(span: dict) -> bool: + if span["flags"] & 16: + return True + return any(kw in span["font"].lower() for kw in _BOLD_FONT_KEYWORDS) + + +def _extract_page_blocks(page: fitz.Page, page_num: int) -> list[Block]: + page_dict = page.get_text("dict") + blocks: list[Block] = [] + prev_y1 = 0.0 + + for raw_block in page_dict["blocks"]: + if raw_block.get("type") != 0: # ignora blocchi immagine + continue + + for line in raw_block.get("lines", []): + spans = line.get("spans", []) + if not spans: + continue + + # Aggrega span della stessa riga con stesso font+size in un Block + groups: list[list[dict]] = [] + current: list[dict] = [] + for sp in spans: + if not current: + current.append(sp) + elif ( + round(sp["size"], 1) == round(current[0]["size"], 1) + and sp["font"] == current[0]["font"] + ): + current.append(sp) + else: + groups.append(current) + current = [sp] + if current: + groups.append(current) + + for group in groups: + text = _clean_pua("".join(s["text"] for s in group).strip()) + if not text: + continue + + first = group[0] + bbox = ( + min(s["bbox"][0] for s in group), + min(s["bbox"][1] for s in group), + max(s["bbox"][2] for s in group), + max(s["bbox"][3] for s in group), + ) + y0 = bbox[1] + space_before = max(0.0, y0 - prev_y1) + + is_bold = _is_bold_span(first) + font_size = round(first["size"], 2) + + # Superscript (flags & 1) → ignore provvisorio + block_type = "ignore" if (first["flags"] & 1) else "paragraph" + + block = Block( + text=text, + page=page_num, + bbox=bbox, + font_size=font_size, + font_name=first["font"], + is_bold=is_bold, + block_type=block_type, + space_before=space_before, + origin_spans=group, + ) + blocks.append(block) + prev_y1 = bbox[3] + + return blocks + + +def extract_raw_data(pdf_path: Path) -> tuple[list[Block], dict]: + """ + Apre il PDF con PyMuPDF ed estrae tutti i Block + metadati documento. + + Ritorna: + blocks — lista di Block ordinati per pagina (poi per y0/x0 in stage2) + doc_meta — dict con: toc, page_count, page_dimensions, title, author, year + """ + doc = fitz.open(str(pdf_path)) + + toc = doc.get_toc() # [(level, title, page), ...] + page_count = len(doc) + page_dimensions = [(p.rect.width, p.rect.height) for p in doc] + + raw_meta = doc.metadata or {} + + import re + year = "" + creation = raw_meta.get("creationDate", "") + m = re.match(r"D:(\d{4})", creation) + if m: + year = m.group(1) + + doc_meta = { + "toc": toc, + "page_count": page_count, + "page_dimensions": page_dimensions, + "title": (raw_meta.get("title") or "").strip(), + "author": (raw_meta.get("author") or "").strip(), + "year": year, + } + + all_blocks: list[Block] = [] + for page_num, page in enumerate(doc, start=1): + page_blocks = _extract_page_blocks(page, page_num) + all_blocks.extend(page_blocks) + + doc.close() + return all_blocks, doc_meta + + +def extract_raw_data_with_pdfplumber_fallback(pdf_path: Path) -> tuple[list[Block], dict]: + """ + Estrae i Block con PyMuPDF; per le pagine dove il testo è < 100 caratteri + (ma la pagina non è blank), usa pdfplumber come fallback e aggiunge un + Block "paragraph" sintetico con il testo alternativo. + + La funzione `extract_raw_data` originale rimane invariata. + """ + all_blocks, doc_meta = extract_raw_data(pdf_path) + + # Raggruppa i blocchi per pagina per misurare quante parole ci sono + from collections import defaultdict + blocks_by_page: dict[int, list[Block]] = defaultdict(list) + for b in all_blocks: + blocks_by_page[b.page].append(b) + + page_count = doc_meta["page_count"] + sparse_pages = [] + for page_num in range(1, page_count + 1): + page_blocks = blocks_by_page.get(page_num, []) + total_chars = sum(len(b.text) for b in page_blocks if b.block_type != "ignore") + if total_chars < 100: + sparse_pages.append(page_num) + + if not sparse_pages: + return all_blocks, doc_meta + + try: + import pdfplumber + except ImportError: + return all_blocks, doc_meta + + try: + with pdfplumber.open(str(pdf_path)) as pdf: + for page_num in sparse_pages: + page_idx = page_num - 1 + if page_idx >= len(pdf.pages): + continue + page = pdf.pages[page_idx] + text = page.extract_text() or "" + text = text.strip() + if not text or len(text) < 20: + continue # pagina davvero vuota + + # Costruisci un Block sintetico per il testo fallback + w = page.width or 612 + h = page.height or 792 + fallback_block = Block( + text=_clean_pua(text), + page=page_num, + bbox=(0.0, 0.0, float(w), float(h)), + font_size=10.0, + font_name="pdfplumber-fallback", + is_bold=False, + block_type="paragraph", + space_before=0.0, + origin_spans=[], + ) + all_blocks.append(fallback_block) + except Exception: + pass # se pdfplumber fallisce, usa i block di PyMuPDF già presenti + + # Riordina per pagina (i fallback sono stati appesi in coda) + all_blocks.sort(key=lambda b: (b.page, b.bbox[1], b.bbox[0])) + return all_blocks, doc_meta diff --git a/conversione/_pipeline/stage2_layout.py b/conversione/_pipeline/stage2_layout.py new file mode 100644 index 0000000..d3ee174 --- /dev/null +++ b/conversione/_pipeline/stage2_layout.py @@ -0,0 +1,184 @@ +"""Stage 2: analisi layout — reading order, multi-colonna, merge header multi-riga.""" +from collections import Counter + +from .models import Block + + +_RECURRING_MIN_OCCURRENCES = 3 +_RECURRING_MAX_LEN = 100 +_RECURRING_PAGE_RATIO = 0.05 # soglia minima: ≥5% delle pagine del documento + + +def _mark_recurring_lines(blocks: list[Block]) -> list[Block]: + """ + Segna come 'ignore' i blocchi con testo breve che compaiono molte volte + nel documento — tipicamente header/footer di pagina ripetuti. + + La soglia scala con la lunghezza del documento: max(3, page_count * 5%) + per evitare di marcare come ricorrenti titoli di sezione che appaiono + poche volte in documenti lunghi con struttura a parti (es. I/II/III). + """ + if not blocks: + return blocks + page_count = max(b.page for b in blocks) + threshold = max(_RECURRING_MIN_OCCURRENCES, int(page_count * _RECURRING_PAGE_RATIO)) + + counts = Counter( + b.text.strip() + for b in blocks + if 3 < len(b.text.strip()) < _RECURRING_MAX_LEN + ) + recurring = {t for t, n in counts.items() if n >= threshold} + if not recurring: + return blocks + for b in blocks: + if b.text.strip() in recurring: + b.block_type = "ignore" + return blocks + + +_COLUMN_GAP_RATIO = 0.15 # gap orizzontale minimo per rilevare colonne (% page_width) +_COLUMN_THRESHOLD = 0.40 # % blocchi per lato per dichiarare layout multi-colonna +_MULTILINE_X_TOL = 5.0 # tolleranza px per allineamento x0 di righe consecutive (testo a sx) +_MULTILINE_CX_TOL = 20.0 # tolleranza px per allineamento centro di righe centrate + + +def _detect_columns(blocks: list[Block], page_width: float) -> int: + """Ritorna 1 (singola colonna) o 2 (doppia colonna).""" + if not blocks or page_width <= 0: + return 1 + mid = page_width * 0.5 + left = sum(1 for b in blocks if b.x0 < mid) + right = sum(1 for b in blocks if b.x0 >= mid) + total = left + right + if total == 0: + return 1 + if (left / total >= _COLUMN_THRESHOLD) and (right / total >= _COLUMN_THRESHOLD): + return 2 + return 1 + + +def _reorder_two_columns(blocks: list[Block], page_width: float) -> list[Block]: + """Riordina blocchi in layout a due colonne: prima col. sinistra, poi destra.""" + mid = page_width * 0.5 + left = sorted([b for b in blocks if b.x0 < mid], key=lambda b: b.y0) + right = sorted([b for b in blocks if b.x0 >= mid], key=lambda b: b.y0) + return left + right + + +def _merge_multiline_headers(blocks: list[Block]) -> list[Block]: + """ + Unifica coppie di block consecutivi che formano un header multi-riga: + stesso font_size, stesso x0 (±5px), gap verticale < 1.5×font_size. + """ + if not blocks: + return blocks + result: list[Block] = [] + i = 0 + while i < len(blocks): + cur = blocks[i] + if i + 1 < len(blocks): + nxt = blocks[i + 1] + same_size = round(cur.font_size, 1) == round(nxt.font_size, 1) + same_page = cur.page == nxt.page + same_x = abs(cur.x0 - nxt.x0) <= _MULTILINE_X_TOL + # Titoli centrati: larghezze diverse → x0 diversi; verifica il centro invece + cur_cx = (cur.x0 + cur.x1) / 2 + nxt_cx = (nxt.x0 + nxt.x1) / 2 + same_cx = abs(cur_cx - nxt_cx) <= _MULTILINE_CX_TOL + aligned = same_x or same_cx + gap = nxt.y0 - cur.y1 + # gap >= -3pt: le bbox di righe consecutive possono sovrapporsi leggermente + # per font a tight-leading; -3pt esclude cross-column merge (gap ≈ -800pt) + small_gap = -3 <= gap < 1.5 * cur.font_size + both_short = len(cur.text) < 120 and len(nxt.text) < 120 + # Non fondere blocco corpo testuale con titolo: il testo di corpo termina + # con ! o ? e contiene minuscole (fine frase), mentre il titolo è ALLCAPS/breve. + cur_stripped = cur.text.strip() + body_sentence_end = ( + cur_stripped.endswith(("!", "?")) + and any(c.islower() for c in cur_stripped) + ) + if same_size and same_page and aligned and small_gap and both_short and not body_sentence_end: + merged = Block( + text=cur.text + " " + nxt.text, + page=cur.page, + bbox=(cur.x0, cur.y0, max(cur.x1, nxt.x1), nxt.y1), + font_size=cur.font_size, + font_name=cur.font_name, + is_bold=cur.is_bold or nxt.is_bold, + block_type=cur.block_type, + space_before=cur.space_before, + origin_spans=cur.origin_spans + nxt.origin_spans, + ) + result.append(merged) + i += 2 + continue + + result.append(cur) + i += 1 + return result + + +def _recompute_space_before(blocks: list[Block]) -> list[Block]: + """Ricalcola space_before dopo eventuali riordinamenti. + + Salto di pagina: usa b.y0 come stima del gap dalla cima della nuova pagina + (minimo 50pt) in modo che il primo blocco di ogni pagina ottenga il space_signal + anche quando si trova subito dopo un page break (coordinate y azzerano tra pagine). + """ + for i, b in enumerate(blocks): + if i == 0: + b.space_before = 0.0 + elif b.page != blocks[i - 1].page: + b.space_before = max(b.y0, 50.0) + else: + b.space_before = max(0.0, b.y0 - blocks[i - 1].y1) + return blocks + + +def analyze_layout(raw_blocks: list[Block], doc_meta: dict) -> list[Block]: + """ + Organizza i Block estratti in Stage 1 in reading order corretto. + + 1. Raggruppa per pagina. + 2. Rileva layout multi-colonna → riordina. + 3. Ordina ogni pagina per (y0, x0). + 4. Merge header multi-riga. + 5. Ricalcola space_before. + """ + if not raw_blocks: + return [] + + page_dimensions = doc_meta.get("page_dimensions", []) + + # Raggruppa per pagina + pages: dict[int, list[Block]] = {} + for b in raw_blocks: + pages.setdefault(b.page, []).append(b) + + ordered: list[Block] = [] + for page_num in sorted(pages): + page_blocks = pages[page_num] + page_idx = page_num - 1 + page_width = page_dimensions[page_idx][0] if page_idx < len(page_dimensions) else 595.0 + + # Ordina per (y0, x0) prima della rilevazione colonne + page_blocks.sort(key=lambda b: (b.y0, b.x0)) + + n_cols = _detect_columns(page_blocks, page_width) + if n_cols == 2: + page_blocks = _reorder_two_columns(page_blocks, page_width) + + ordered.extend(page_blocks) + + # Merge header multi-riga + ordered = _merge_multiline_headers(ordered) + + # Ricalcola space_before + ordered = _recompute_space_before(ordered) + + # Segna come ignore i blocchi ricorrenti (header/footer di capitolo) + ordered = _mark_recurring_lines(ordered) + + return ordered diff --git a/conversione/_pipeline/stage3_font.py b/conversione/_pipeline/stage3_font.py new file mode 100644 index 0000000..636e746 --- /dev/null +++ b/conversione/_pipeline/stage3_font.py @@ -0,0 +1,53 @@ +"""Stage 3: analisi font — rileva body size e cluster header per documento.""" +from collections import Counter + +from .models import Block, FontProfile + + +def build_font_profile(blocks: list[Block]) -> FontProfile: + """ + Determina body_size (mode dei font size) e costruisce cluster_map + per i livelli header (1=H1, 2=H2, 3=H3), inferiti dinamicamente. + """ + sizes = [ + round(b.font_size, 1) + for b in blocks + if b.block_type != "ignore" + ] + if not sizes: + return FontProfile(body_size=11.0, cluster_map={}, header_sizes=[]) + + counter = Counter(sizes) + total = sum(counter.values()) + + # Body size = font size più frequente + body_size = counter.most_common(1)[0][0] + + # Candidati header: size > body + 1pt, frequenza < 30% del totale + raw_candidates = sorted( + { + s for s, c in counter.items() + if s > body_size + 1.0 and c / total < 0.30 + }, + reverse=True, + ) + + # Collassa cluster entro ±0.5pt + collapsed: list[float] = [] + for s in raw_candidates: + if collapsed and abs(s - collapsed[-1]) <= 0.5: + continue # appartiene al cluster precedente (già più grande) + collapsed.append(s) + + header_sizes = collapsed[:3] # max 3 livelli + + # cluster_map: size arrotondato → livello (1=grande, 2=medio, 3=piccolo) + cluster_map: dict[float, int] = {} + for i, s in enumerate(header_sizes, start=1): + cluster_map[s] = i + + return FontProfile( + body_size=body_size, + cluster_map=cluster_map, + header_sizes=header_sizes, + ) diff --git a/conversione/_pipeline/stage4_headers.py b/conversione/_pipeline/stage4_headers.py new file mode 100644 index 0000000..4840554 --- /dev/null +++ b/conversione/_pipeline/stage4_headers.py @@ -0,0 +1,162 @@ +"""Stage 4: classificazione blocchi — rileva header candidate con segnali combinati.""" +import re + +from .models import Block, FontProfile + + +# Numerazione gerarchica con separatore esplicito: "1.", "1.2", "1.2.3" + MAIUSCOLA. +# Non usa \s come separatore per evitare "1 La divisione..." (note a pie' di pagina). +_NUMBERED_SECTION_RE = re.compile(r"^\d+(\.\d+)*[.)]\s*[A-ZÀ-Ÿ]") +_ARTICLE_RE = re.compile(r"^Art(?:icolo|\.)\s+\d+", re.IGNORECASE) +# "CAPITOLO QUARTO." / "CHAPTER FOUR" / "CANTO XII" — keyword strutturale ALLCAPS + ordinale/numero/romano. +# Solo maiuscolo: cattura sezioni dove il font è identico al corpo (PDF letterari/accademici) +# ma lascia intatti i riferimenti in sentence-case nel corpo del testo. +_CHAPTER_WORD_RE = re.compile( + r"^(?:CAPITOLO|CHAPTER|CANTO)\s+(?:[A-ZÀ-Ÿ][A-ZÀ-Ÿ]+|\d+|[IVXLCDM]+)\b" +) +# "Capitolo 1: TITOLO" / "Chapter 3 — ..." in sentence-case + bold. +# Cattura capitoli di PDF tecnici/didattici con body-size identico agli header. +_CHAPTER_WORD_BOLD_RE = re.compile( + r"^(?:Capitolo|Chapter)\s+\d+\b", re.IGNORECASE +) +_PURE_NUMBERS_RE = re.compile(r"^[\d\s\-\./,]+$") # solo numeri/punteggiatura, nessuna lettera +# Simbolo di sezione § seguito da numero o romano: "§ 1", "§ I.", "§ 12" +_SECTION_SYMBOL_RE = re.compile(r"^§\s*[\dIVXivx]") +# Dot-leader: tipici di TOC e liste figure (". . . . .") +_DOT_LEADER_RE = re.compile(r"(?:\.[ ]){3,}") +# Riferimento di pagina TOC: ", p. 42" (voce indice) — in qualsiasi posizione nel testo +# oppure multipli riferimenti pagina (liste TOC con più voci) +_TOC_PAGE_REF_RE = re.compile(r",?\s+p\.\s+\d+") +# Numerale romano minuscolo standalone: page number preliminari (i, ii, vii, xii…) +_ROMAN_PAGE_RE = re.compile(r"^x{0,3}(?:ix|iv|v?i{0,3})$") +_SHORT_LINE_THRESHOLD = 80 # caratteri +_HEADER_SCORE_THRESHOLD = 3 # punteggio minimo per diventare header_candidate + + +def _score_block(block: Block, body_size: float) -> int: + score = 0 + text = block.text.strip() + + # size_signal: font_size significativamente più grande del corpo + if block.font_size >= body_size + 1.5: + score += 2 + + # bold_signal: bold E font_size almeno pari al corpo. + # Usa round() per evitare falsi positivi da rumore floating point del PDF + # (es. 11.52 vs body_size 11.5 → stesso cluster, non un vero header). + if block.is_bold and round(block.font_size, 1) > round(body_size, 1): + score += 1 + + # number_signal: numerazione gerarchica SOLO se font > corpo + 0.5pt. + # Evita che paragrafi numerati a font-corpo (es. "1. Lo spazio non è…") + # vengano promossi ad header per il solo fatto di iniziare con un numero. + if _NUMBERED_SECTION_RE.match(text) and block.font_size > body_size + 0.5: + score += 2 + + # section_symbol_signal: simbolo § (tipico di trattati filosofici/giuridici). + # Threshold body-2.5pt: cattura § a font ridotto (varianti editoriali del PDF) + # ma esclude annotazioni marginali a 8.2pt (§9, §10 come running notes). + if _SECTION_SYMBOL_RE.match(text) and block.font_size >= body_size - 2.5: + score += 2 + + # allcaps_signal: testo interamente maiuscolo con font ≥ corpo → titolo di parte/capitolo. + # Threshold abbassata a >= body_size: cattura sezioni ALLCAPS nei PDF letterari + # dove il font del titolo è identico al corpo. + # Escluso se bold: bold+ALLCAPS a body_size indica enfasi nel testo (intestazioni di cella, + # etichette), non un titolo di sezione strutturale. + alpha = re.sub(r"[^a-zA-ZÀ-ÿ]", "", text) + if (alpha and alpha == alpha.upper() and len(alpha) > 3 + and block.font_size >= body_size and not block.is_bold): + score += 1 + + # length_signal: riga breve (i titoli sono concisi) + if len(text) < _SHORT_LINE_THRESHOLD: + score += 1 + + # space_signal: spazio verticale prima del blocco > 1.5× dimensione font + if block.space_before > 1.5 * block.font_size: + score += 1 + + return score + + +def classify_blocks(blocks: list[Block], profile: FontProfile) -> list[Block]: + """ + Assegna block_type ad ogni Block in base a segnali combinati. + + Guardie aggiuntive che impediscono la promozione a header_candidate: + - testo puramente numerico (numeri di pagina, intervalli TOC) + - testo che inizia con `|` (footer/intestazioni di capitolo stile tabella) + - testo troppo corto (< 2 caratteri) + """ + body_size = profile.body_size + + for block in blocks: + # Non toccare classificazioni precedenti protette + if block.block_type in ("table", "ignore"): + continue + + text = block.text.strip() + if not text or len(text) < 2: + block.block_type = "ignore" + continue + + # Guard legale: articoli di codice → sempre header candidate + if _ARTICLE_RE.match(text): + block.block_type = "header_candidate" + continue + + # Guard letterario ALLCAPS: keyword strutturale + ordinale/numero/romano → sempre header candidate. + if _CHAPTER_WORD_RE.match(text) and len(text) < _SHORT_LINE_THRESHOLD: + block.block_type = "header_candidate" + continue + + # Guard letterario bold: "Capitolo 1: TITOLO" bold anche al body-size → header candidate. + if block.is_bold and _CHAPTER_WORD_BOLD_RE.match(text) and len(text) < _SHORT_LINE_THRESHOLD: + block.block_type = "header_candidate" + continue + + # Guard: testo puramente numerico → numero di pagina standalone, da ignorare + if _PURE_NUMBERS_RE.match(text): + block.block_type = "ignore" + continue + + # Guard: numerale romano minuscolo standalone → page number preliminare (vii, xii…) + if _ROMAN_PAGE_RE.match(text) and len(text) >= 2: + block.block_type = "ignore" + continue + + # Guard: dot-leader → riga TOC o lista figure, non testo del documento + if _DOT_LEADER_RE.search(text): + block.block_type = "ignore" + continue + + # Guard: testo che inizia con pipe → footer/intestazione di capitolo o frammento tabella + if text.startswith("|"): + block.block_type = "ignore" + continue + + # Guard: voce di indice con riferimento pagina → "§ 9. Titolo, p. 90." + if _TOC_PAGE_REF_RE.search(text): + block.block_type = "ignore" + continue + + score = _score_block(block, body_size) + if score >= _HEADER_SCORE_THRESHOLD: + # Guard: header candidate deve iniziare con lettera maiuscola (dopo eventuali numeri/simboli). + # Filtra frammenti LaTeX come "1 segue", "1 allora", "2) prodotto" che hanno + # font grande ma non sono titoli di sezione. + stripped_nums = re.sub(r"^[§\d\s\.\)\(\-]+", "", text) + if stripped_nums and stripped_nums[0].islower(): + block.block_type = "paragraph" + else: + block.block_type = "header_candidate" + else: + # Rilevamento liste: riga che inizia con bullet o numero seguito da punto + stripped = text.lstrip() + if stripped.startswith(("- ", "* ", "• ", "· ")) or re.match(r"^\d+\.\s", stripped): + block.block_type = "list_item" + else: + block.block_type = "paragraph" + + return blocks diff --git a/conversione/_pipeline/stage5_hierarchy.py b/conversione/_pipeline/stage5_hierarchy.py new file mode 100644 index 0000000..1cd1adc --- /dev/null +++ b/conversione/_pipeline/stage5_hierarchy.py @@ -0,0 +1,147 @@ +"""Stage 5: inferenza gerarchia — assegna livello (1-3) agli header candidate.""" +import re +import unicodedata + +from .models import Block, FontProfile + + +_NUMBERED_RE = re.compile(r"^(\d+(?:\.\d+)*)[.)\s]\s*[A-ZÀ-Ÿ]") +_MIN_NUMBERED_FOR_RULE1 = 3 # soglia per attivare Regola 1 + +# "Capitolo 3 Titolo" / "Chapter 5 – Titolo": sezioni numerate con la parola +# "Capitolo/Chapter" + numero intero (in senso-maiuscolo, tipicamente bold body-size). +# Se ≥3 blocchi corrispondono, vengono promossi a livello 2 come sezioni primarie. +_CHAPTER_NUM_BOLD_RE = re.compile(r"^(?:Capitolo|Chapter)\s+\d+\b", re.IGNORECASE) +_MIN_CHAPTER_NUM_FOR_PROMOTION = 3 + + +def _normalize_title(text: str) -> str: + """Normalizza un titolo per il confronto fuzzy con il TOC.""" + text = unicodedata.normalize("NFKC", text) + text = text.lower().strip() + text = re.sub(r"[^\w\s]", " ", text) + text = re.sub(r"\s+", " ", text) + return text.strip() + + +def _fuzzy_match(title: str, toc_map: dict[str, int], threshold: float = 0.75) -> int: + """ + Cerca il livello TOC per un titolo con confronto fuzzy. + Ritorna il livello trovato, o 0 se nessun match. + """ + norm = _normalize_title(title) + if not norm: + return 0 + + # Match esatto + if norm in toc_map: + return toc_map[norm] + + # Match parziale: confronta le prime parole (fino a 8) + norm_words = norm.split()[:8] + norm_prefix = " ".join(norm_words) + + best_score = 0.0 + best_level = 0 + for toc_norm, level in toc_map.items(): + toc_words = toc_norm.split()[:8] + toc_prefix = " ".join(toc_words) + # Calcola sovrapposizione su caratteri del prefisso più corto + shorter = min(len(norm_prefix), len(toc_prefix)) + if shorter == 0: + continue + matches = sum( + 1 for a, b in zip(norm_prefix, toc_prefix) if a == b + ) + score = matches / shorter + if score > best_score: + best_score = score + best_level = level + + return best_level if best_score >= threshold else 0 + + +def _level_from_numbering(text: str) -> int: + """Inferisce il livello dall'numerazione gerarchica: "1." → 1, "1.2" → 2, ecc.""" + m = _NUMBERED_RE.match(text.strip()) + if not m: + return 0 + dots = m.group(1).count(".") + return min(dots + 1, 3) + + +def _level_from_font(font_size: float, cluster_map: dict[float, int]) -> int: + """Cerca il livello più vicino nel cluster_map in base alla font_size.""" + if not cluster_map: + return 2 # fallback: tutti H2 + rounded = round(font_size, 1) + if rounded in cluster_map: + return cluster_map[rounded] + # Cerca il cluster più vicino + best = min(cluster_map.keys(), key=lambda s: abs(s - rounded)) + return cluster_map[best] + + +def infer_hierarchy( + blocks: list[Block], + profile: FontProfile, + toc: list, +) -> list[Block]: + """ + Assegna block.level ad ogni header_candidate secondo la priorità: + Regola 1: numerazione gerarchica (≥3 candidati numerati) + Regola 2: allineamento TOC (se TOC non vuoto) + Regola 3: font size clustering (fallback) + """ + candidates = [b for b in blocks if b.block_type == "header_candidate"] + if not candidates: + return blocks + + # ── Regola 1: numerazione ────────────────────────────────────────────────── + numbered = [b for b in candidates if _NUMBERED_RE.match(b.text.strip())] + use_numbering = len(numbered) >= _MIN_NUMBERED_FOR_RULE1 + + # ── Regola 2: costruisci mappa TOC ──────────────────────────────────────── + toc_map: dict[str, int] = {} + for entry in toc: + if len(entry) >= 3: + level, title, _ = entry[0], entry[1], entry[2] + norm = _normalize_title(str(title)) + if norm: + toc_map[norm] = min(int(level), 3) + use_toc = bool(toc_map) + + # ── Assegna livelli ─────────────────────────────────────────────────────── + for block in candidates: + text = block.text.strip() + level = 0 + + if use_numbering and _NUMBERED_RE.match(text): + level = _level_from_numbering(text) + elif use_numbering: + # Documento numerato ma questo candidato non ha numero → + # usa font size come hint secondario, poi fallback a 2 + level = _level_from_font(block.font_size, profile.cluster_map) or 2 + elif use_toc: + level = _fuzzy_match(text, toc_map) + if level == 0: + level = _level_from_font(block.font_size, profile.cluster_map) or 2 + else: + level = _level_from_font(block.font_size, profile.cluster_map) or 2 + + block.level = max(1, min(level, 3)) + + # ── Post-correzione: "Capitolo/Chapter N" bold → sezioni primarie (L2) ──── + # Quando il documento usa "Capitolo N" bold a body-size (senza font distinto + # per i titoli), il font clustering assegna L3 perché la dimensione è sotto + # tutti i cluster. Con ≥3 capitoli numerati, li promuoviamo a L2. + if not use_toc and not use_numbering: + chapter_bold = [ + b for b in candidates + if b.is_bold and _CHAPTER_NUM_BOLD_RE.match(b.text.strip()) and b.level > 2 + ] + if len(chapter_bold) >= _MIN_CHAPTER_NUM_FOR_PROMOTION: + for b in chapter_bold: + b.level = 2 + + return blocks diff --git a/conversione/_pipeline/stage6_tree.py b/conversione/_pipeline/stage6_tree.py new file mode 100644 index 0000000..6a1ca28 --- /dev/null +++ b/conversione/_pipeline/stage6_tree.py @@ -0,0 +1,54 @@ +"""Stage 6: ricostruzione albero documentale — Section con parent-child stack-based.""" +from .models import Block, Section + + +def build_tree(blocks: list[Block]) -> list[Section]: + """ + Costruisce l'albero di Section dalla lista ordinata di Block. + + Algoritmo stack-based: + - header_candidate → nuova Section; pop stack finché livello >= nuovo livello. + - Altri block → aggiunti al content della Section in cima allo stack. + - Testo prima del primo header → sezione implicita (title="", level=0). + """ + roots: list[Section] = [] + stack: list[Section] = [] # sezioni aperte, ordinate per livello crescente + + def _current() -> Section | None: + return stack[-1] if stack else None + + def _push(section: Section) -> None: + """Inserisce la nuova sezione nell'albero rispettando la gerarchia.""" + # Pop sezioni con livello >= al nuovo (nuovo header chiude i predecessori allo stesso livello) + while stack and stack[-1].level >= section.level: + stack.pop() + + if stack: + stack[-1].children.append(section) + else: + roots.append(section) + + stack.append(section) + + for block in blocks: + if block.block_type == "header_candidate" and block.level > 0: + new_section = Section( + title=block.text.strip(), + level=block.level, + page_start=block.page, + source_block=block, + ) + _push(new_section) + elif block.block_type == "ignore": + continue + else: + cur = _current() + if cur is None: + # Testo prima del primo header → sezione implicita + implicit = Section(title="", level=0, page_start=block.page) + roots.append(implicit) + stack.append(implicit) + cur = implicit + cur.content.append(block) + + return roots diff --git a/conversione/_pipeline/stage7_markdown.py b/conversione/_pipeline/stage7_markdown.py new file mode 100644 index 0000000..98c6d7e --- /dev/null +++ b/conversione/_pipeline/stage7_markdown.py @@ -0,0 +1,224 @@ +"""Stage 7: serializzazione del document tree in Markdown valido.""" +import re +from pathlib import Path + +from .models import Block, Section + +# Pulisce artefatti finali nei titoli: " | 30", " |", " | " +# (pipe con eventuale numero di pagina — tipici footer di capitolo nei PDF) +_TITLE_TRAIL_RE = re.compile(r"\s*\|\s*\d*\s*$") + +# Sezioni preliminari da omettere interamente dall'output Markdown +# (TOC, lista figure, lista tabelle — non sono contenuto RAG-utile) +_SKIP_SECTION_TITLES = { + "indice", "indice generale", "indice analitico", + "table of contents", "contents", + "elenco delle figure", "lista delle figure", "list of figures", + "elenco delle tabelle", "lista delle tabelle", "list of tables", + "sommario", +} + + +_LIST_RE = re.compile(r"^(?:[-*•·]\s|\d+\.\s)") + + +def _split_long_title(title: str) -> tuple[str, str]: + """ + Divide un titolo multi-frase in (titolo_breve, corpo_extra). + + Cerca il primo confine di frase ('. ' seguito da maiuscola) dopo il + carattere 15, per non spezzare abbreviazioni brevi all'inizio del titolo. + Ritorna (title, '') se non c'è divisione sensata o il titolo è corto. + """ + if len(title) <= 120: + return title, '' + for i in range(15, len(title) - 2): + if title[i] == '.' and title[i + 1] == ' ' and title[i + 2].isupper(): + return title[:i + 1].strip(), title[i + 2:].strip() + return title, '' + + +def _serialize_block(block: Block, pdf_path: Path | None = None) -> str: + """Serializza un singolo Block in testo Markdown.""" + if block.block_type == "ignore": + return "" + + text = block.text.strip() + if not text: + return "" + + if block.block_type == "table": + return _serialize_table(block, pdf_path) + + if block.block_type == "list_item": + return text # già formattato con bullet/numero + + return text # paragraph + + +def _serialize_table(block: Block, pdf_path: Path | None = None) -> str: + """ + Tenta di estrarre la tabella con pdfplumber; fallback a testo raw. + """ + if pdf_path is not None and block.origin_spans: + try: + import pdfplumber + with pdfplumber.open(str(pdf_path)) as pdf: + page_idx = block.page - 1 + if 0 <= page_idx < len(pdf.pages): + page = pdf.pages[page_idx] + x0, y0, x1, y1 = block.bbox + cropped = page.crop((x0 - 2, y0 - 2, x1 + 2, y1 + 2)) + table = cropped.extract_table() + if table: + return _table_to_markdown(table) + except Exception: + pass + + # Fallback: testo grezzo + return block.text.strip() + + +def _table_to_markdown(table: list[list[str | None]]) -> str: + """Converte una tabella pdfplumber in Markdown GFM.""" + if not table: + return "" + + def _cell(c: str | None) -> str: + return (c or "").replace("\n", " ").strip() + + rows = [[_cell(c) for c in row] for row in table] + # Normalizza larghezza colonne + n_cols = max(len(r) for r in rows) + rows = [r + [""] * (n_cols - len(r)) for r in rows] + + header = rows[0] + sep = ["---"] * n_cols + body = rows[1:] + + lines = [ + "| " + " | ".join(header) + " |", + "| " + " | ".join(sep) + " |", + ] + for row in body: + lines.append("| " + " | ".join(row) + " |") + return "\n".join(lines) + + +def _is_para_break(block: Block) -> bool: + """ + Restituisce True se il block inizia un nuovo paragrafo logico. + Soglia: gap verticale > 1× font_size (≈ una riga intera di margine). + All'interno di un paragrafo il gap è ≈ 0-4pt; tra paragrafi è ≥ font_size. + """ + return block.space_before > block.font_size + + +def _serialize_section(section: Section, pdf_path: Path | None = None) -> list[str]: + """Traversal DFS in-order: header → content → children.""" + # Salta sezioni preliminari non utili per RAG (TOC, lista figure, ecc.) + # I FIGLI vengono comunque serializzati: se la TOC è genitore errato dei capitoli + # reali (gerarchia piatta nel PDF), i capitoli appaiono ugualmente nel Markdown. + if section.title.strip().lower() in _SKIP_SECTION_TITLES: + parts: list[str] = [] + for child in section.children: + parts.extend(_serialize_section(child, pdf_path)) + return parts + + parts: list[str] = [] + + # Header (livello 0 = sezione implicita pre-primo-header → no #) + extra_body: str = '' + if section.level > 0: + title = _TITLE_TRAIL_RE.sub("", section.title).strip() + if not title: + pass # titolo vuoto: nessun header, ma il contenuto viene comunque emesso + else: + title, extra_body = _split_long_title(title) + hashes = "#" * section.level + parts.append(f"{hashes} {title}") + parts.append("") + + # Content: accumula righe di paragrafo consecutive in un unico blocco di testo + pending: list[str] = [] # pezzi del paragrafo corrente + if extra_body: + pending.append(extra_body) + + def _flush() -> None: + if not pending: + return + # Unisci i pezzi riparando la sillabazione inter-riga: + # "de-" + "stino" → "destino" (trattino finale + inizio minuscolo) + joined = pending[0] + for part in pending[1:]: + if joined.endswith("-") and part and part[0].islower(): + joined = joined[:-1] + part + else: + joined = joined + " " + part + parts.append(joined) + parts.append("") + pending.clear() + + for block in section.content: + text = _serialize_block(block, pdf_path) + if not text: + continue + + if block.block_type == "list_item": + _flush() + parts.append(text) + elif block.block_type == "table": + _flush() + parts.append(text) + parts.append("") + else: + # Blocco paragrafo: unisci con il precedente oppure inizia nuovo paragrafo + if pending and _is_para_break(block): + _flush() + pending.append(text) + + _flush() + + # Figli + for child in section.children: + parts.extend(_serialize_section(child, pdf_path)) + + return parts + + +def serialize_tree( + roots: list[Section], + meta: dict, + pdf_path: Path | None = None, + include_frontmatter: bool = False, +) -> str: + """ + Serializza la lista di Section radice in un documento Markdown. + + include_frontmatter: se True, inserisce blocco YAML con metadati. + Nota: il frontmatter viene aggiunto dal runner, non qui, per mantenere + raw.md privo di metadata soggetti a variazione. + """ + parts: list[str] = [] + + if include_frontmatter and meta: + fm_lines = ["---", f"source: {meta.get('source', '')}"] + if meta.get("title"): + fm_lines.append(f'title: "{meta["title"]}"') + if meta.get("author"): + fm_lines.append(f'author: "{meta["author"]}"') + if meta.get("year"): + fm_lines.append(f"year: {meta['year']}") + if meta.get("pages"): + fm_lines.append(f"pages: {meta['pages']}") + fm_lines += ["---", ""] + parts.extend(fm_lines) + + for root in roots: + root_parts = _serialize_section(root, pdf_path) + parts.extend(root_parts) + + # Normalizza righe vuote consecutive (max 2) + text = "\n".join(parts) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + "\n" diff --git a/conversione/_pipeline/stage8_normalize.py b/conversione/_pipeline/stage8_normalize.py new file mode 100644 index 0000000..9303961 --- /dev/null +++ b/conversione/_pipeline/stage8_normalize.py @@ -0,0 +1,337 @@ +"""Stage 8: normalizzazione gerarchia Markdown — ripara salti livello, header vuoti, duplicati.""" +import re +import unicodedata + + +_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+)$") + +# Conversione encoding LaTeX accenti italiani estratti da PDF TeX-compilati +# backtick + vocale → accento grave; ´ + vocale → accento acuto +_GRAVE = {'a': 'à', 'e': 'è', 'i': 'ì', 'o': 'ò', 'u': 'ù', 'ı': 'ì', + 'A': 'À', 'E': 'È', 'I': 'Ì', 'O': 'Ò', 'U': 'Ù'} +_ACUTE = {'a': 'á', 'e': 'é', 'i': 'í', 'o': 'ó', 'u': 'ú', + 'A': 'Á', 'E': 'É', 'I': 'Í', 'O': 'Ó', 'U': 'Ú'} + + +def _fix_latex_accents(text: str) -> str: + """Converte encoding LaTeX degli accenti: \`e→è, ´e→é, ecc.""" + text = re.sub(r'`([aeiouAEIOUı])', lambda m: _GRAVE.get(m.group(1), m.group(0)), text) + text = re.sub(r'´([aeiouAEIOU])', lambda m: _ACUTE.get(m.group(1), m.group(0)), text) + # Encoding font: "1'" → "l'" (glifo 'l' letto come cifra '1' prima di apostrofo) + text = re.sub(r"\b1'([a-zA-ZÀ-ÿ])", r"l'\1", text) + return text + + +# Sillabazione TeX/PDF: "evi- tare" → "evitare" (trattino-spazio tra due frammenti) +_HYPHEN_SPACE_RE = re.compile(r'([a-zà-ÿ])- ([a-zà-ÿ])') + +# Bold markup dentro header: ## **Titolo** → ## Titolo +_HEADER_BOLD_RE = re.compile(r'^(#{1,6})\s+\*\*(.+?)\*\*\s*$', re.MULTILINE) + +# Pattern header numerato senza punto: "### 5 Titolo" → "### 5. Titolo" +_HDR_NUM_NO_DOT_RE = re.compile(r'^(#{1,6})\s+(\d{1,3})\s+(.+)$') + +# Figura/Tabella come header (caption di layout finito nei blocchi strutturali) +_FIGURE_CAPTION_RE = re.compile( + r'^(Figura|Figure|Fig\.|Tabella|Table|Tab\.)\s+\d', re.IGNORECASE +) +# Numerale romano usato come marcatore di sezione: I, II, IV, VII, XXIII, ecc. +_ROMAN_NUMERAL_RE = re.compile(r'^[IVXLCDM]+\.?$', re.IGNORECASE) + + +def _sentence_case(s: str) -> str: + if not s: + return s + low = s.lower() + return low[0].upper() + low[1:] + + +def _is_garbage_header(content: str) -> bool: + """Rileva header privi di significato strutturale.""" + stripped = content.strip() + + # Simbolo § — marcatore di sezione valido anche se solo numerico/romano + if stripped.startswith("§"): + return False + + if stripped.startswith("..."): + return True + + # Testo che termina con parentesi aperta → testo troncato, non un titolo valido + if stripped.endswith("("): + return True + + # Testo con caratteri PUA (Symbol/Wingdings font): formula o simbolo matematico + if re.search(r'[-]', stripped): + return True + + # Testo che inizia con [ → notazione matematica/vettoriale + if stripped.startswith("["): + return True + + # Header troppo breve (≤4 caratteri non-spazio) → formula, variabile o simbolo isolato + if len(stripped.replace(" ", "")) <= 4 and not _ROMAN_NUMERAL_RE.match(stripped): + return True + + # Nessuna sequenza di ≥2 lettere → pura punteggiatura/numero + if not re.search(r'[A-Za-zÀ-ÿ]{2,}', stripped): + return True + + # Header di 1-4 lettere (es. "(a)", "x") — ma non numerali romani di sezione + if re.fullmatch(r'\(?\s*[A-Za-z]{1,4}\s*\)?', stripped): + if not _ROMAN_NUMERAL_RE.match(stripped.strip("(). ")): + return True + + # Equazione breve come header: "x = y", "f(x) ≤" + if re.match(r'^[A-Za-zÀ-ÿ_]{1,3}\s*[=<>≤≥]', stripped): + return True + + # Caption di figura o tabella estratta come header + if _FIGURE_CAPTION_RE.match(stripped): + return True + + # Header che inizia con lettera minuscola e testo lungo: frammento corpo + 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 + + return False + + +def _header_level(line: str) -> int: + m = _HEADER_RE.match(line) + return len(m.group(1)) if m else 0 + + +def _norm_title(text: str) -> str: + text = unicodedata.normalize("NFKC", text).lower().strip() + return re.sub(r"\s+", " ", text) + + +def normalize_hierarchy(text: str) -> tuple[str, dict]: + """ + Ripara il Markdown prodotto da Stage 7 in più passate: + + Pass 0 — Accenti LaTeX (encoding PDF TeX-compilati) + Pass 0.5 — Sillabazione "word- word" (artefatto TeX/PDF) + Pass 1 — Bold dentro header: ## **T** → ## T + Pass 1.5 — Header spazzatura rimossi PRIMA del repair (caption figure, equazioni, simboli) + Questo evita che simboli chimici/matematici H1/H2 alterino il repair dei salti. + Pass 2 — Salti di livello: # A → #### B diventa # A → ## B + Pass 3 — Duplicati consecutivi: header identici adiacenti collassati + Pass 4 — Header vuoti senza contenuto né sezioni figlio rimossi + Pass 5 — Running-header prefisso del successivo (es. "§ 4" prima di "§ 4. Titolo") + Pass 6 — ALLCAPS → sentence case (≥4 lettere tutte maiuscole) + Pass 7 — Demote # → ## se il documento ha ≥5 header H1 + Pass 8 — Clamp H4+ → H3; normalizza "### 5 Titolo" → "### 5. Titolo" + + Ritorna (testo_riparato, stats_dict). + """ + lines = text.split("\n") + stats = { + "n_level_jumps_repaired": 0, + "n_empty_headers_removed": 0, + "n_duplicate_headers_removed": 0, + "n_hyphenations_repaired": 0, + "n_bold_in_headers_removed": 0, + "n_allcaps_headers_normalized": 0, + "n_h1_demoted": 0, + "n_garbage_headers_removed": 0, + "n_headers_clamped": 0, + } + + # ── Pass 0: correggi encoding accenti italiani LaTeX ────────────────────── + lines = [_fix_latex_accents(l) for l in lines] + + # ── Pass 0.5: ripara sillabazione "word- word" nei paragrafi ────────────── + repaired_lines: list[str] = [] + for line in lines: + if not _HEADER_RE.match(line): + new_line, n = _HYPHEN_SPACE_RE.subn(r'\1\2', line) + stats["n_hyphenations_repaired"] += n + repaired_lines.append(new_line) + else: + repaired_lines.append(line) + lines = repaired_lines + + # ── Pass 1: rimuovi bold markup dentro header ───────────────────────────── + no_bold: list[str] = [] + for line in lines: + new_line, n = _HEADER_BOLD_RE.subn(r'\1 \2', line) + stats["n_bold_in_headers_removed"] += n + no_bold.append(new_line) + lines = no_bold + + # ── Pass 1.5: rimuovi header spazzatura PRIMA del repair ────────────────── + # I simboli chimici/matematici estratti a font grande (H1/H2) alterano il + # repair dei salti di livello se rimossi solo dopo. Rimuovendoli prima, i + # capitoli reali ricevono il livello corretto senza distorsioni. + no_garbage_pre: list[str] = [] + for line in lines: + m = _HEADER_RE.match(line) + if m and _is_garbage_header(m.group(2)): + stats["n_garbage_headers_removed"] += 1 + continue + no_garbage_pre.append(line) + lines = no_garbage_pre + + # ── Pass 2: ripara salti di livello ─────────────────────────────────────── + repaired: list[str] = [] + last_level = 0 + for line in lines: + m = _HEADER_RE.match(line) + if m: + hashes, title = m.group(1), m.group(2) + level = len(hashes) + if last_level > 0 and level > last_level + 1: + new_level = last_level + 1 + line = "#" * new_level + " " + title + stats["n_level_jumps_repaired"] += 1 + level = new_level + last_level = level + repaired.append(line) + + # ── Pass 3: rimuovi duplicati consecutivi ───────────────────────────────── + no_dup: list[str] = [] + last_header_norm: str | None = None + for line in repaired: + m = _HEADER_RE.match(line) + if m: + norm = _norm_title(m.group(2)) + if norm == last_header_norm: + stats["n_duplicate_headers_removed"] += 1 + continue + last_header_norm = norm + else: + if line.strip(): + last_header_norm = None # reset su contenuto reale + no_dup.append(line) + + # ── Pass 4: rimuovi header vuoti (nessun contenuto E nessuna sezione figlia) ── + no_empty: list[str] = [] + i = 0 + while i < len(no_dup): + line = no_dup[i] + m = _HEADER_RE.match(line) + if m: + cur_level = len(m.group(1)) + j = i + 1 + has_content = False + next_level: int | None = None + while j < len(no_dup): + ahead = no_dup[j] + m2 = _HEADER_RE.match(ahead) + if m2: + next_level = len(m2.group(1)) + break + if ahead.strip(): + has_content = True + break + j += 1 + is_empty = not has_content and j < len(no_dup) + is_container = next_level is not None and next_level > cur_level + if is_empty and not is_container: + stats["n_empty_headers_removed"] += 1 + i += 1 + continue + no_empty.append(line) + i += 1 + + # ── Pass 5: rimuovi running-header prefisso del successivo ──────────────── + # Es. "§ 4" immediatamente seguito (≤3 righe di contenuto) da "§ 4. Titolo reale". + no_prefix: list[str] = [] + i = 0 + while i < len(no_empty): + line = no_empty[i] + m = _HEADER_RE.match(line) + if m: + cur_norm = _norm_title(m.group(2)) + if cur_norm: + j = i + 1 + non_blank = 0 + next_header_norm: str | None = None + while j < len(no_empty) and non_blank <= 3: + ahead = no_empty[j] + m2 = _HEADER_RE.match(ahead) + if m2: + next_header_norm = _norm_title(m2.group(2)) + break + if ahead.strip(): + non_blank += 1 + j += 1 + if ( + next_header_norm is not None + and len(cur_norm) < len(next_header_norm) + and next_header_norm.startswith(cur_norm) + ): + stats["n_duplicate_headers_removed"] += 1 + i += 1 + continue + no_prefix.append(line) + i += 1 + lines = no_prefix + + # ── Pass 6: ALLCAPS → sentence case ─────────────────────────────────────── + # Solo header con ≥4 lettere tutte maiuscole; preserva prefissi numerici/simbolici. + normalized: list[str] = [] + for line in lines: + m = _HEADER_RE.match(line) + if m: + hashes, content = m.group(1), m.group(2).strip() + letters = [c for c in content if c.isalpha()] + if len(letters) >= 4 and all(c.isupper() for c in letters): + # Preserva prefisso numerico/simbolico (§, numeri, punteggiatura) + prefix_m = re.match(r'^([§\d\s\.\)\(\-]+\s+)', content) + if prefix_m: + prefix = prefix_m.group(1) + rest = content[len(prefix):] + if rest: + line = f"{hashes} {prefix}{_sentence_case(rest)}" + else: + line = f"{hashes} {_sentence_case(content)}" + stats["n_allcaps_headers_normalized"] += 1 + normalized.append(line) + lines = normalized + + # ── Pass 7: demote # → ## se il documento ha ≥5 header H1 ─────────────── + # Documenti con H1 come sezione principale (non come titolo unico) producono + # una gerarchia piatta ## → ### senza livello intermedio. + # Quando si abbassa di un livello, il cascade è totale: H1→H2, H2→H3, H3→H3 + # (clamp: non si scende sotto H3). Questo preserva la gerarchia relativa. + h1_count = sum(1 for l in lines if re.match(r'^# [A-Za-zÀ-ÿ§\d]', l)) + if h1_count >= 5: + demoted: list[str] = [] + for line in lines: + m = _HEADER_RE.match(line) + if m: + level = len(m.group(1)) + if level == 1: + line = f"## {m.group(2)}" + stats["n_h1_demoted"] += 1 + elif level == 2: + line = f"### {m.group(2)}" + stats["n_h1_demoted"] += 1 + # level 3 resta a 3 (clamp) + demoted.append(line) + lines = demoted + + clamped: list[str] = [] + for line in lines: + m = _HEADER_RE.match(line) + if m: + level = len(m.group(1)) + content = m.group(2) + if level > 3: + line = f"### {content}" + stats["n_headers_clamped"] += 1 + else: + # "### 5 Titolo" → "### 5. Titolo" (numerazione senza punto separatore) + nm = _HDR_NUM_NO_DOT_RE.match(line) + if nm and len(nm.group(1)) == 3: + line = f"{nm.group(1)} {nm.group(2)}. {nm.group(3)}" + clamped.append(line) + lines = clamped + + result = "\n".join(lines) + result = re.sub(r"\n{3,}", "\n\n", result) + return result, stats diff --git a/conversione/_pipeline/stage9_validate.py b/conversione/_pipeline/stage9_validate.py new file mode 100644 index 0000000..162fa87 --- /dev/null +++ b/conversione/_pipeline/stage9_validate.py @@ -0,0 +1,97 @@ +"""Stage 9: validazione strutturale del Markdown finale.""" +import re +from dataclasses import dataclass, field + + +_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+)$") +_TABLE_ROW_RE = re.compile(r"^\|.+\|$") + + +@dataclass +class ValidationResult: + is_valid: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "valid": self.is_valid, + "errors": self.errors, + "warnings": self.warnings, + } + + +def validate_markdown(text: str, page_count: int = 0) -> ValidationResult: + """ + Valida l'integrità strutturale del Markdown. + + Check 1: no salti di livello heading + Check 2: no sezioni vuote eccessive + Check 3: tabelle con colonne inconsistenti + Check 4: ordine heading ragionevole + """ + lines = text.split("\n") + errors: list[str] = [] + warnings: list[str] = [] + + # ── Check 1: salti di livello ───────────────────────────────────────────── + last_level = 0 + level_jumps = 0 + for i, line in enumerate(lines, 1): + m = _HEADER_RE.match(line) + if m: + level = len(m.group(1)) + if last_level > 0 and level > last_level + 1: + level_jumps += 1 + last_level = level + if level_jumps > 0: + errors.append(f"Salti di livello heading non riparati: {level_jumps}") + + # ── Check 2: sezioni vuote ──────────────────────────────────────────────── + header_indices = [i for i, l in enumerate(lines) if _HEADER_RE.match(l)] + total_sections = len(header_indices) + empty_sections = 0 + for idx in range(len(header_indices)): + start = header_indices[idx] + 1 + end = header_indices[idx + 1] if idx + 1 < len(header_indices) else len(lines) + content_lines = [l for l in lines[start:end] if l.strip() and not _HEADER_RE.match(l)] + if not content_lines: + empty_sections += 1 + + if total_sections > 0: + empty_ratio = empty_sections / total_sections + if empty_ratio > 0.30: + errors.append( + f"Troppe sezioni vuote: {empty_sections}/{total_sections} " + f"({empty_ratio:.0%})" + ) + elif empty_ratio > 0.10: + warnings.append( + f"Sezioni vuote: {empty_sections}/{total_sections} ({empty_ratio:.0%})" + ) + + # ── Check 3: colonne tabelle inconsistenti ──────────────────────────────── + in_table = False + table_cols: int | None = None + inconsistent_tables = 0 + for line in lines: + if _TABLE_ROW_RE.match(line.strip()): + cols = line.count("|") - 1 + if not in_table: + in_table = True + table_cols = cols + elif table_cols is not None and cols != table_cols: + inconsistent_tables += 1 + table_cols = None # non segnalare ulteriori righe della stessa tabella + else: + in_table = False + table_cols = None + if inconsistent_tables > 0: + warnings.append(f"Tabelle con colonne inconsistenti: {inconsistent_tables}") + + # ── Check 4: struttura minima ───────────────────────────────────────────── + if total_sections == 0: + warnings.append("Nessun header rilevato — documento non strutturato") + + is_valid = len(errors) == 0 + return ValidationResult(is_valid=is_valid, errors=errors, warnings=warnings) diff --git a/conversione/_pipeline/structure.py b/conversione/_pipeline/structure.py new file mode 100644 index 0000000..fd4442c --- /dev/null +++ b/conversione/_pipeline/structure.py @@ -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, + } diff --git a/conversione/_pipeline/validator.py b/conversione/_pipeline/validator.py new file mode 100644 index 0000000..8e79e16 --- /dev/null +++ b/conversione/_pipeline/validator.py @@ -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) +
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, "
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=
inline enc=simboli encoding fhdr=formula-header med=mediana chars\n" + ) diff --git a/conversione/clear.sh b/conversione/clear.sh index 3774610..04867f1 100755 --- a/conversione/clear.sh +++ b/conversione/clear.sh @@ -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" diff --git a/conversione/pipeline.py b/conversione/pipeline.py index e657da0..eedf436 100644 --- a/conversione/pipeline.py +++ b/conversione/pipeline.py @@ -1538,9 +1538,6 @@ def run(stem: str, project_root: Path, force: bool) -> bool: 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" - ) _LIVELLO_DESC = {3: "ricca (h3)", 2: "parziale (h2)", 1: "paragrafi", 0: "testo piatto"} print(f" ✅ Struttura: livello {profile['livello_struttura']} — {_LIVELLO_DESC[profile['livello_struttura']]}") diff --git a/requirements.txt b/requirements.txt index dc5da54..adcadb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ pdfplumber==0.11.9 -pymupdf4llm -opendataloader-pdf +PyMuPDF>=1.24.0 chromadb +pytest>=8.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a903c74 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +"""Fixture condivise per l'intera test suite.""" +import pytest +from conversione._pipeline.models import Block, Section + + +@pytest.fixture +def make_block(): + """Factory per Block di test con valori di default ragionevoli.""" + def _make( + text="testo di prova", + page=1, + font_size=12.0, + font_name="Helvetica", + is_bold=False, + block_type="paragraph", + space_before=5.0, + bbox=(50.0, 100.0, 400.0, 114.0), + level=0, + ): + return Block( + text=text, + page=page, + bbox=bbox, + font_size=font_size, + font_name=font_name, + is_bold=is_bold, + block_type=block_type, + space_before=space_before, + level=level, + ) + return _make + + +@pytest.fixture +def mock_fitz_page(): + """Dizionario che simula l'output di page.get_text('dict') per una pagina.""" + return { + "width": 595.0, + "height": 842.0, + "blocks": [ + { + "type": 0, + "bbox": (50, 50, 450, 70), + "lines": [{ + "bbox": (50, 50, 450, 70), + "spans": [{ + "text": "1. Capitolo Primo", + "font": "Helvetica-Bold", + "size": 18.0, + "flags": 16, + "bbox": (50, 50, 450, 70), + "origin": (50, 68), + "color": 0, + }], + }], + }, + { + "type": 0, + "bbox": (50, 90, 500, 104), + "lines": [{ + "bbox": (50, 90, 500, 104), + "spans": [{ + "text": "Testo del primo paragrafo del capitolo.", + "font": "Helvetica", + "size": 12.0, + "flags": 0, + "bbox": (50, 90, 500, 104), + "origin": (50, 102), + "color": 0, + }], + }], + }, + ], + } + + +@pytest.fixture +def simple_hierarchy_blocks(make_block): + """Lista di Block con gerarchia semplice H1→H2→H3 numerata.""" + return [ + make_block("1. Introduzione", font_size=18, is_bold=True, space_before=20.0), + make_block("Testo del paragrafo di introduzione.", font_size=12), + make_block("1.1 Contesto", font_size=15, is_bold=True, space_before=15.0), + make_block("Testo della sezione di contesto.", font_size=12), + make_block("1.1.1 Dettaglio", font_size=13, is_bold=True, space_before=10.0), + make_block("Testo del dettaglio specifico.", font_size=12), + make_block("2. Conclusioni", font_size=18, is_bold=True, space_before=20.0), + make_block("Testo conclusivo.", font_size=12), + ] + + +@pytest.fixture +def sources_dir(): + from pathlib import Path + d = Path(__file__).parent.parent / "sources" + return d if d.exists() else None diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_pipeline_e2e.py b/tests/integration/test_pipeline_e2e.py new file mode 100644 index 0000000..c48754c --- /dev/null +++ b/tests/integration/test_pipeline_e2e.py @@ -0,0 +1,68 @@ +"""Test end-to-end: pipeline completa su PDF reali da sources/.""" +import json +import shutil +import pytest +from pathlib import Path + +from conversione._pipeline import run + + +PROJECT_ROOT = Path(__file__).parent.parent.parent + + +def _sources_available(stem: str) -> bool: + return (PROJECT_ROOT / "sources" / f"{stem}.pdf").exists() + + +@pytest.mark.skipif(not _sources_available("bitcoin"), reason="sources/bitcoin.pdf non disponibile") +def test_bitcoin_produces_clean_md(tmp_path, monkeypatch): + """Pipeline completa su bitcoin.pdf — verifica output strutturato.""" + # Usa tmp_path come output per non inquinare il repo + out_dir = tmp_path / "conversione" / "bitcoin" + out_dir.mkdir(parents=True) + sources_dir = tmp_path / "sources" + sources_dir.mkdir() + shutil.copy(PROJECT_ROOT / "sources" / "bitcoin.pdf", sources_dir / "bitcoin.pdf") + + ok = run("bitcoin", tmp_path, force=True) + assert ok, "La pipeline deve completare senza errori" + + clean_md = out_dir / "clean.md" + assert clean_md.exists(), "clean.md deve essere creato" + + text = clean_md.read_text(encoding="utf-8") + assert len(text) > 1000, "clean.md deve avere contenuto significativo" + assert "#" in text, "clean.md deve avere almeno un header" + + report = json.loads((out_dir / "report.json").read_text(encoding="utf-8")) + assert report["structure"]["livello_struttura"] >= 1, "Struttura deve avere almeno livello 1" + + +@pytest.mark.skipif(not _sources_available("bitcoin"), reason="sources/bitcoin.pdf non disponibile") +def test_determinism(tmp_path): + """Due run consecutive sullo stesso PDF producono output identico.""" + sources_dir = tmp_path / "sources" + sources_dir.mkdir() + shutil.copy(PROJECT_ROOT / "sources" / "bitcoin.pdf", sources_dir / "bitcoin.pdf") + + run("bitcoin", tmp_path, force=True) + first = (tmp_path / "conversione" / "bitcoin" / "clean.md").read_text() + + run("bitcoin", tmp_path, force=True) + second = (tmp_path / "conversione" / "bitcoin" / "clean.md").read_text() + + assert first == second, "Output deve essere deterministico tra due run" + + +@pytest.mark.skipif(not _sources_available("codice_civile"), reason="sources/codice_civile.pdf non disponibile") +def test_codice_civile_has_articles(tmp_path): + """Il Codice Civile deve produrre header con 'Art.'.""" + sources_dir = tmp_path / "sources" + sources_dir.mkdir() + shutil.copy(PROJECT_ROOT / "sources" / "codice_civile.pdf", sources_dir / "codice_civile.pdf") + + ok = run("codice_civile", tmp_path, force=True) + assert ok + + text = (tmp_path / "conversione" / "codice_civile" / "clean.md").read_text() + assert "Art." in text, "clean.md del codice civile deve contenere articoli" diff --git a/tests/integration/test_stage8_repair.py b/tests/integration/test_stage8_repair.py new file mode 100644 index 0000000..462e39a --- /dev/null +++ b/tests/integration/test_stage8_repair.py @@ -0,0 +1,40 @@ +"""Test categoria 8: riparazione automatica gerarchia rotta (todo.md Cat.8).""" +from conversione._pipeline.stage8_normalize import normalize_hierarchy + + +def test_cat8_invalid_hierarchy_auto_repaired(): + """ + Categoria 8 dal todo.md: + Input: # A \\n\\n#### B + Atteso: # A \\n\\n## B (salto riparato a max +1) + """ + md_input = "# A\n\n#### B\n\nContenuto di B.\n" + result, stats = normalize_hierarchy(md_input) + + assert "## B" in result, "#### deve diventare ## (salto +1 dal padre #)" + assert "#### B" not in result, "Il livello originale non deve restare" + assert stats["n_level_jumps_repaired"] >= 1 + + +def test_multiple_jumps_all_repaired(): + """Catena di salti: # → #### → ######.""" + md_input = "# Root\n\n#### Middle\n\nTesto\n\n###### Deep\n\nTesto\n" + result, stats = normalize_hierarchy(md_input) + + lines = [l for l in result.split("\n") if l.startswith("#")] + levels = [len(l) - len(l.lstrip("#")) for l in lines] + + # Verifica che non ci siano salti > 1 + for i in range(1, len(levels)): + assert levels[i] <= levels[i - 1] + 1, \ + f"Salto non riparato: {levels[i-1]} → {levels[i]}" + + +def test_valid_hierarchy_not_touched(): + """Gerarchia valida non deve essere modificata.""" + md_valid = "# H1\n\nTesto\n\n## H2\n\nTesto\n\n### H3\n\nTesto\n" + result, stats = normalize_hierarchy(md_valid) + assert stats["n_level_jumps_repaired"] == 0 + assert "# H1" in result + assert "## H2" in result + assert "### H3" in result diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..90ae0a7 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,47 @@ +"""Test dataclass Block, Section, FontProfile.""" +from conversione._pipeline.models import Block, Section, FontProfile + + +def test_block_creation(): + b = Block( + text="Titolo", page=1, + bbox=(0, 0, 100, 14), + font_size=16.0, font_name="Arial-Bold", + is_bold=True, + ) + assert b.text == "Titolo" + assert b.is_bold + assert b.block_type == "paragraph" + assert b.level == 0 + assert b.x0 == 0.0 + assert b.y1 == 14.0 + + +def test_block_properties(): + b = Block("x", 1, (10.0, 20.0, 110.0, 34.0), 12.0, "Helvetica", False) + assert b.x0 == 10.0 + assert b.y0 == 20.0 + assert b.x1 == 110.0 + assert b.y1 == 34.0 + + +def test_section_defaults(): + s = Section(title="Intro", level=1) + assert s.content == [] + assert s.children == [] + assert s.page_start == 0 + + +def test_section_nesting(): + parent = Section("Parent", level=1) + child = Section("Child", level=2) + parent.children.append(child) + assert len(parent.children) == 1 + assert parent.children[0].title == "Child" + + +def test_font_profile(): + fp = FontProfile(body_size=11.0, cluster_map={18.0: 1, 15.0: 2}, header_sizes=[18.0, 15.0]) + assert fp.body_size == 11.0 + assert fp.cluster_map[18.0] == 1 + assert len(fp.header_sizes) == 2 diff --git a/tests/unit/test_stage3.py b/tests/unit/test_stage3.py new file mode 100644 index 0000000..451fa0a --- /dev/null +++ b/tests/unit/test_stage3.py @@ -0,0 +1,44 @@ +"""Test Stage 3: font analysis.""" +from conversione._pipeline.models import Block +from conversione._pipeline.stage3_font import build_font_profile + + +def _make_block(font_size, n=1): + return [ + Block(f"testo {i}", 1, (0, i*14.0, 100, (i+1)*14.0), font_size, "Helvetica", False) + for i in range(n) + ] + + +def test_body_size_is_most_frequent(): + blocks = _make_block(12.0, 20) + _make_block(18.0, 2) + _make_block(15.0, 3) + profile = build_font_profile(blocks) + assert profile.body_size == 12.0 + + +def test_header_sizes_above_body(): + blocks = _make_block(12.0, 20) + _make_block(18.0, 2) + _make_block(15.0, 3) + profile = build_font_profile(blocks) + assert all(s > profile.body_size for s in profile.header_sizes) + + +def test_cluster_map_levels(): + blocks = _make_block(12.0, 20) + _make_block(24.0, 2) + _make_block(18.0, 3) + _make_block(14.0, 4) + profile = build_font_profile(blocks) + # Taglia più grande deve avere livello 1 + if profile.header_sizes: + assert profile.cluster_map[profile.header_sizes[0]] == 1 + + +def test_empty_blocks(): + profile = build_font_profile([]) + assert profile.body_size == 11.0 + assert profile.header_sizes == [] + + +def test_single_font_size(): + blocks = _make_block(11.0, 50) + profile = build_font_profile(blocks) + assert profile.body_size == 11.0 + assert profile.header_sizes == [] + assert profile.cluster_map == {} diff --git a/tests/unit/test_stage4.py b/tests/unit/test_stage4.py new file mode 100644 index 0000000..8986be6 --- /dev/null +++ b/tests/unit/test_stage4.py @@ -0,0 +1,52 @@ +"""Test Stage 4: header detection — segnali combinati.""" +import pytest +from conversione._pipeline.models import Block, FontProfile +from conversione._pipeline.stage4_headers import classify_blocks + + +def _profile(body=12.0): + return FontProfile(body_size=body, cluster_map={18.0: 1, 15.0: 2}, header_sizes=[18.0, 15.0]) + + +def _block(text, font_size=12.0, is_bold=False, space_before=5.0, block_type="paragraph"): + return Block(text, 1, (50, 100, 400, 114), font_size, "Helvetica", is_bold, + block_type=block_type, space_before=space_before) + + +def test_numbered_large_bold_short_becomes_header(): + # Tutti i segnali positivi + b = _block("1. Introduzione", font_size=18, is_bold=True, space_before=30.0) + classify_blocks([b], _profile()) + assert b.block_type == "header_candidate" + + +def test_body_text_stays_paragraph(): + b = _block("Questo è un lungo paragrafo di testo normale che non deve diventare un header.", font_size=12) + classify_blocks([b], _profile()) + assert b.block_type == "paragraph" + + +def test_bold_body_text_not_header(): + # Bold ma stesso size del corpo e testo lungo → NON header (bold_signal richiede size > body+0.5) + b = _block("Testo importante in grassetto nel corpo del documento.", font_size=12, is_bold=True) + classify_blocks([b], _profile()) + assert b.block_type == "paragraph" + + +def test_article_forced_header(): + # "Art. N" → sempre header candidate + b = _block("Art. 1423. Nullità del contratto.", font_size=12) + classify_blocks([b], _profile()) + assert b.block_type == "header_candidate" + + +def test_table_preserved(): + b = _block("Colonna A | Colonna B", font_size=12, block_type="table") + classify_blocks([b], _profile()) + assert b.block_type == "table" + + +def test_list_item_detection(): + b = _block("- primo elemento della lista", font_size=12) + classify_blocks([b], _profile()) + assert b.block_type == "list_item" diff --git a/tests/unit/test_stage5.py b/tests/unit/test_stage5.py new file mode 100644 index 0000000..89b6eef --- /dev/null +++ b/tests/unit/test_stage5.py @@ -0,0 +1,95 @@ +"""Test Stage 5: hierarchy inference — numerazione, TOC, font fallback.""" +from conversione._pipeline.models import Block, FontProfile +from conversione._pipeline.stage5_hierarchy import infer_hierarchy, _level_from_numbering + + +def _profile(): + return FontProfile(body_size=12.0, cluster_map={18.0: 1, 15.0: 2, 13.0: 3}, header_sizes=[18.0, 15.0, 13.0]) + + +def _hblock(text, font_size=18.0, is_bold=True): + b = Block(text, 1, (50, 100, 400, 114), font_size, "Helvetica-Bold", is_bold) + b.block_type = "header_candidate" + return b + + +def _pblock(text): + b = Block(text, 1, (50, 120, 400, 134), 12.0, "Helvetica", False) + b.block_type = "paragraph" + return b + + +# ── Test _level_from_numbering ──────────────────────────────────────────────── + +def test_numbering_level1(): + assert _level_from_numbering("1. Titolo") == 1 + +def test_numbering_level2(): + assert _level_from_numbering("1.2 Sottotitolo") == 2 + +def test_numbering_level3(): + assert _level_from_numbering("1.2.3 Dettaglio") == 3 + +def test_numbering_deep_capped_at_3(): + assert _level_from_numbering("1.2.3.4 Troppo profondo") == 3 + +def test_numbering_no_match(): + assert _level_from_numbering("Testo senza numero") == 0 + + +# ── Test infer_hierarchy con numerazione ───────────────────────────────────── + +def test_numbered_sections_get_correct_levels(): + blocks = [ + _hblock("1. Introduzione", font_size=18), + _pblock("Testo."), + _hblock("1.1 Contesto", font_size=15), + _pblock("Testo."), + _hblock("1.1.1 Dettaglio", font_size=13), + _pblock("Testo."), + _hblock("2. Conclusioni", font_size=18), + ] + result = infer_hierarchy(blocks, _profile(), toc=[]) + headers = [b for b in result if b.block_type == "header_candidate"] + assert headers[0].level == 1 # "1." + assert headers[1].level == 2 # "1.1" + assert headers[2].level == 3 # "1.1.1" + assert headers[3].level == 1 # "2." + + +# ── Test infer_hierarchy con TOC ───────────────────────────────────────────── + +def test_toc_alignment(): + toc = [[1, "Introduzione", 1], [2, "Contesto storico", 3], [1, "Conclusioni", 10]] + blocks = [ + _hblock("Introduzione", font_size=14), + _hblock("Contesto storico", font_size=13), + _hblock("Conclusioni", font_size=14), + ] + result = infer_hierarchy(blocks, _profile(), toc=toc) + headers = [b for b in result if b.block_type == "header_candidate"] + assert headers[0].level == 1 + assert headers[1].level == 2 + assert headers[2].level == 1 + + +# ── Test infer_hierarchy con font fallback ──────────────────────────────────── + +def test_font_fallback_no_numbering_no_toc(): + blocks = [ + _hblock("Capitolo Grande", font_size=18), + _pblock("Testo."), + _hblock("Sezione Media", font_size=15), + _pblock("Testo."), + ] + result = infer_hierarchy(blocks, _profile(), toc=[]) + headers = [b for b in result if b.block_type == "header_candidate"] + assert headers[0].level == 1 # 18pt → cluster level 1 + assert headers[1].level == 2 # 15pt → cluster level 2 + + +def test_empty_cluster_map_defaults_to_2(): + profile_empty = FontProfile(body_size=12.0, cluster_map={}, header_sizes=[]) + blocks = [_hblock("Titolo qualsiasi", font_size=18)] + result = infer_hierarchy(blocks, profile_empty, toc=[]) + assert result[0].level == 2 diff --git a/tests/unit/test_stage6.py b/tests/unit/test_stage6.py new file mode 100644 index 0000000..76e7fc1 --- /dev/null +++ b/tests/unit/test_stage6.py @@ -0,0 +1,98 @@ +"""Test Stage 6: document tree reconstruction.""" +import pytest +from conversione._pipeline.models import Block, Section +from conversione._pipeline.stage6_tree import build_tree + + +def _hblock(text, level, page=1): + b = Block(text, page, (50, 100, 400, 114), 16.0, "Helvetica-Bold", True) + b.block_type = "header_candidate" + b.level = level + return b + + +def _pblock(text, page=1): + b = Block(text, page, (50, 120, 400, 134), 12.0, "Helvetica", False) + b.block_type = "paragraph" + return b + + +def test_simple_hierarchy(): + blocks = [ + _hblock("H1", 1), + _pblock("p1"), + _hblock("H2", 2), + _pblock("p2"), + ] + roots = build_tree(blocks) + assert len(roots) == 1 + h1 = roots[0] + assert h1.title == "H1" + assert h1.level == 1 + assert len(h1.content) == 1 + assert h1.content[0].text == "p1" + assert len(h1.children) == 1 + h2 = h1.children[0] + assert h2.title == "H2" + assert len(h2.content) == 1 + + +def test_two_siblings(): + blocks = [ + _hblock("Cap 1", 1), + _pblock("testo 1"), + _hblock("Cap 2", 1), + _pblock("testo 2"), + ] + roots = build_tree(blocks) + assert len(roots) == 2 + assert roots[0].title == "Cap 1" + assert roots[1].title == "Cap 2" + + +def test_pre_header_text_gets_implicit_section(): + blocks = [ + _pblock("Testo introduttivo prima del primo header."), + _hblock("Primo header", 1), + ] + roots = build_tree(blocks) + # La sezione implicita (level=0) è la radice; contiene il testo pre-header + # e il primo header diventa suo figlio. + assert len(roots) == 1 + implicit = roots[0] + assert implicit.title == "" + assert implicit.level == 0 + assert len(implicit.content) == 1 + assert len(implicit.children) == 1 + assert implicit.children[0].title == "Primo header" + + +def test_deep_nesting(): + blocks = [ + _hblock("H1", 1), + _hblock("H2", 2), + _hblock("H3", 3), + _pblock("testo profondo"), + ] + roots = build_tree(blocks) + assert len(roots) == 1 + h1 = roots[0] + assert len(h1.children) == 1 + h2 = h1.children[0] + assert len(h2.children) == 1 + h3 = h2.children[0] + assert len(h3.content) == 1 + + +def test_ignore_blocks_skipped(): + b_ignore = Block("superscript", 1, (0,0,10,10), 8.0, "Helvetica", False, block_type="ignore") + blocks = [ + _hblock("Titolo", 1), + b_ignore, + _pblock("paragrafo"), + ] + roots = build_tree(blocks) + h1 = roots[0] + # Il blocco ignore non deve essere nel content + assert all(b.block_type != "ignore" for b in h1.content) + assert len(h1.content) == 1 diff --git a/tests/unit/test_stage7.py b/tests/unit/test_stage7.py new file mode 100644 index 0000000..20c7987 --- /dev/null +++ b/tests/unit/test_stage7.py @@ -0,0 +1,62 @@ +"""Test Stage 7: serializzazione Markdown.""" +from conversione._pipeline.models import Block, Section +from conversione._pipeline.stage7_markdown import serialize_tree, _table_to_markdown + + +def _section(title, level, texts=None, children=None): + blocks = [] + for t in (texts or []): + b = Block(t, 1, (0,0,100,14), 12.0, "Helvetica", False, block_type="paragraph") + blocks.append(b) + s = Section(title=title, level=level, content=blocks, children=children or []) + return s + + +def test_h1_header(): + roots = [_section("Introduzione", 1, ["Testo."])] + md = serialize_tree(roots, {}) + assert "# Introduzione" in md + assert "Testo." in md + + +def test_h2_nested(): + child = _section("Sezione 1.1", 2, ["Contenuto della sezione."]) + root = _section("Capitolo 1", 1, [], [child]) + md = serialize_tree([root], {}) + assert "# Capitolo 1" in md + assert "## Sezione 1.1" in md + assert "Contenuto della sezione." in md + + +def test_implicit_section_no_hash(): + # Sezione implicita level=0 → nessun # header + s = Section(title="", level=0) + b = Block("Testo iniziale.", 1, (0,0,100,14), 12.0, "Helvetica", False) + s.content.append(b) + md = serialize_tree([s], {}) + assert not md.startswith("#") + assert "Testo iniziale." in md + + +def test_ignore_blocks_not_serialized(): + s = Section("Titolo", 1) + b_ignore = Block("superscript", 1, (0,0,10,10), 8.0, "Helvetica", False, block_type="ignore") + b_para = Block("Paragrafo valido.", 1, (0,0,100,14), 12.0, "Helvetica", False, block_type="paragraph") + s.content.extend([b_ignore, b_para]) + md = serialize_tree([s], {}) + assert "superscript" not in md + assert "Paragrafo valido." in md + + +def test_table_to_markdown(): + table = [["Nome", "Età"], ["Alice", "30"], ["Bob", "25"]] + md = _table_to_markdown(table) + assert "| Nome | Età |" in md + assert "| --- | --- |" in md + assert "| Alice | 30 |" in md + + +def test_no_excessive_blank_lines(): + roots = [_section("A", 1, ["p1", "p2", "p3"])] + md = serialize_tree(roots, {}) + assert "\n\n\n" not in md diff --git a/tests/unit/test_stage8.py b/tests/unit/test_stage8.py new file mode 100644 index 0000000..fa939b5 --- /dev/null +++ b/tests/unit/test_stage8.py @@ -0,0 +1,49 @@ +"""Test Stage 8: normalizzazione gerarchia Markdown.""" +from conversione._pipeline.stage8_normalize import normalize_hierarchy + + +def test_level_jump_repaired(): + md = "# A\n\n#### B\n\nTesto\n" + result, stats = normalize_hierarchy(md) + assert "## B" in result + assert "#### B" not in result + assert stats["n_level_jumps_repaired"] == 1 + + +def test_valid_hierarchy_unchanged(): + md = "# A\n\n## B\n\nTesto\n\n### C\n\nTesto\n" + result, stats = normalize_hierarchy(md) + assert "# A" in result + assert "## B" in result + assert "### C" in result + assert stats["n_level_jumps_repaired"] == 0 + + +def test_empty_header_removed(): + md = "# Titolo\n\n## Vuoto\n\n## Con contenuto\n\nTesto.\n" + result, stats = normalize_hierarchy(md) + assert "## Vuoto" not in result + assert "## Con contenuto" in result + assert stats["n_empty_headers_removed"] == 1 + + +def test_duplicate_consecutive_header_collapsed(): + md = "# Titolo\n\n# Titolo\n\nTesto.\n" + result, stats = normalize_hierarchy(md) + assert result.count("# Titolo") == 1 + assert stats["n_duplicate_headers_removed"] == 1 + + +def test_multiple_jumps(): + md = "# A\n\n### B\n\nTesto B\n\n##### C\n\nTesto C\n" + result, stats = normalize_hierarchy(md) + assert stats["n_level_jumps_repaired"] == 2 + assert "## B" in result + assert "### C" in result + + +def test_no_false_positives(): + md = "# A\n\nTesto.\n\n## B\n\nTesto.\n" + result, stats = normalize_hierarchy(md) + assert stats["n_level_jumps_repaired"] == 0 + assert stats["n_empty_headers_removed"] == 0 diff --git a/tests/unit/test_stage9.py b/tests/unit/test_stage9.py new file mode 100644 index 0000000..c684918 --- /dev/null +++ b/tests/unit/test_stage9.py @@ -0,0 +1,36 @@ +"""Test Stage 9: validazione strutturale Markdown.""" +from conversione._pipeline.stage9_validate import validate_markdown + + +def test_valid_document(): + md = "# Titolo\n\nTesto.\n\n## Sezione\n\nContenuto.\n" + result = validate_markdown(md) + assert result.is_valid + assert not result.errors + + +def test_level_jump_detected(): + md = "# A\n\n### B\n\nTesto.\n" + result = validate_markdown(md) + assert not result.is_valid + assert any("salto" in e.lower() or "livello" in e.lower() for e in result.errors) + + +def test_no_headers_warning(): + md = "Testo senza nessun header.\n\nAltro paragrafo.\n" + result = validate_markdown(md) + assert any("header" in w.lower() or "strutturato" in w.lower() for w in result.warnings) + + +def test_inconsistent_table_warning(): + md = "# Titolo\n\nTesto.\n\n| A | B |\n|---|---|\n| 1 | 2 | 3 |\n" + result = validate_markdown(md) + assert any("tabelle" in w.lower() or "colonne" in w.lower() for w in result.warnings) + + +def test_to_dict(): + md = "# A\n\nTesto.\n" + d = validate_markdown(md).to_dict() + assert "valid" in d + assert "errors" in d + assert "warnings" in d