diff --git a/.claude/commands/step4-review.md b/.claude/commands/step4-review.md new file mode 100644 index 0000000..486858c --- /dev/null +++ b/.claude/commands/step4-review.md @@ -0,0 +1,62 @@ +--- +description: Revisione qualitativa del clean.md dopo il pre-processing automatico (step 4). Trova artefatti residui, paragrafi spezzati e header errati, poi propone le correzioni. +allowed-tools: Read Bash Grep Edit +argument-hint: +--- + +Esegui la revisione qualitativa di `step-4/$ARGUMENTS/clean.md`. + +**Profilo struttura:** +!`cat step-4/$ARGUMENTS/structure_profile.json 2>/dev/null || echo "ERRORE: step-4/$ARGUMENTS/structure_profile.json non trovato — verifica che lo stem sia corretto"` + +**Statistiche file:** +!`wc -l step-4/$ARGUMENTS/clean.md 2>/dev/null && grep -c "^## " step-4/$ARGUMENTS/clean.md 2>/dev/null | xargs -I{} echo "## headers: {}" && grep -c "^### " step-4/$ARGUMENTS/clean.md 2>/dev/null | xargs -I{} echo "### headers: {}"` + +--- + +Analizza `step-4/$ARGUMENTS/clean.md` seguendo questi passi. Usa Grep e Read per raccogliere i casi concreti prima di riportarli. + +## Passo 1 — Righe orfane in corpo testo + +Cerca blocchi di testo standalone (separati da righe vuote, non header `#`) più corti di 80 caratteri che sembrino artefatti da PDF: +- Firme o nomi di persone isolati (tutto maiuscolo o iniziali maiuscole seguite da punto) +- Date o luoghi isolati +- Titoli del documento ripetuti nel corpo +- Numeri di pagina isolati +- Riferimenti bibliografici fuori contesto + +Per ogni caso trovato: riporta numero di riga e testo. + +## Passo 2 — Paragrafi spezzati residui + +Cerca paragrafi che finiscono senza punteggiatura di fine frase (`.?!»)`), escludendo quelli che terminano con ` -` (interruzione stilistica intenzionale). + +Riporta i primi 5 casi con numero di riga e la fine del testo. + +## Passo 3 — Header sospetti + +Controlla i `##` e `###`: +- `### N.` con solo numero e punto → **corretto**, non segnalare +- `##` o `###` con contenuto ALL-CAPS non convertito in sentence-case → segnalare +- `##` o `###` duplicati (stesso titolo che appare due volte) → segnalare +- `##` con testo molto lungo (> 80 char) → segnalare come sospetto + +## Passo 4 — Sezioni quasi vuote + +Cerca `### ` header seguiti da un blocco di testo inferiore a 60 caratteri (sezione priva di contenuto utile per il RAG). Riporta i casi. + +## Report finale + +``` +🔴 BLOCCANTI (compromettono il chunking o la qualità del RAG) + ... + +🟡 MINORI (artefatti visibili ma non bloccanti) + ... + +🟢 OK + ... +``` + +Chiedi all'utente: "Vuoi che applichi le correzioni per i problemi 🔴? E per i 🟡?" +Applica solo le correzioni esplicitamente approvate. diff --git a/.gitignore b/.gitignore index 73ee299..4bc48a5 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ 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 + diff --git a/README.md b/README.md index 21b24a6..b3e36da 100644 --- a/README.md +++ b/README.md @@ -285,19 +285,59 @@ Livello 0 — testo piatto ### Step 4 — Revisione manuale -**Tipo:** manuale -**Input:** `processed/documento/raw.md` + profilo struttura -**Output:** `processed/documento/clean.md` -**Tool:** il tuo editor di testo +**Tipo:** manuale (con pre-processing automatico) +**Input:** `step-3//clean.md` + `step-3//structure_profile.json` +**Output:** `step-4//clean.md` — MD revisionato +**Script:** `step-4/revise.py` > Questo è lo step più importante dell'intera pipeline. > La qualità del RAG dipende da questo step più di qualsiasi > parametro tecnico o scelta di modello. -Apri `clean.md` nel tuo editor e lavora sezione per sezione, -guidato dal report dello step 1 e dal profilo dello step 3. +#### Pre-processing automatico -**Struttura target del MD pulito:** +Prima di qualsiasi revisione manuale, esegui lo script di revisione automatica: + +```bash +python step-4/revise.py --stem documento +``` + +Lo script applica le seguenti trasformazioni euristiche, valide per qualsiasi documento: + +| Trasformazione | Descrizione | +|---|---| +| Rimozione TOC | Righe che iniziano con `INDICE`, `INDEX`, `CONTENTS`, ecc. | +| ALL-CAPS → `##` | Righe standalone in maiuscolo convertite in header section-case | +| `N. testo` → `### N.` | Sezioni numerate (con 1+ spazio dopo il punto) convertite in h3 | +| Unione paragrafi | Blocchi spezzati da salti pagina PDF uniti automaticamente | +| Whitespace | Spazi multipli normalizzati, righe vuote ridotte | + +Il profilo strutturale aggiornato viene salvato in `step-4//structure_profile.json`. + +#### Revisione assistita da Claude Code + +Dopo il pre-processing, usa la skill integrata per una revisione qualitativa: + +``` +/step4-review documento +``` + +La skill analizza `step-4//clean.md` e produce un report strutturato: + +``` +🔴 BLOCCANTI — problemi che compromettono il chunking +🟡 MINORI — artefatti visibili ma non bloccanti +🟢 OK — categorie senza problemi +``` + +Poi propone le correzioni e le applica solo su tua approvazione. + +#### Revisione manuale residua + +Apri `step-4//clean.md` nel tuo editor e verifica quanto segnalato +dalla skill. Il criterio di qualità rimane lo stesso: + +**Struttura target:** ```markdown # Titolo del documento @@ -309,31 +349,8 @@ guidato dal report dello step 1 e dal profilo dello step 3. Testo fluente, frasi complete, nessun artefatto. Ogni paragrafo è semanticamente autonomo. Una riga vuota separa le sezioni. - -### Sottosezione successiva - -Testo della sottosezione successiva... ``` -**Cosa rimuovi:** - -- Numeri di pagina isolati (una riga con solo `42`) -- Intestazioni e piè di pagina ripetitivi -- Righe vuote multiple — massimo una tra sezioni - -**Cosa correggi:** - -- Sillabazioni residue: `estra-\ntto` → `estratto` -- Righe spezzate artificialmente: unisci le righe - che appartengono alla stessa frase -- Parole unite per errore: `ilbene` → `il bene` - -**Cosa sistemi:** - -- Ogni sezione ha il suo titolo al livello corretto -- Nessuna sezione manca di `###` se ne aveva uno nell'originale -- Nessun titolo è duplicato o malformato - **Il criterio di qualità:** Leggi ogni sezione ad alta voce. Se suona naturale e fluente è corretta. Se si interrompe c'è una riga spezzata. Se suona ripetitiva c'è un artefatto. @@ -341,11 +358,11 @@ Se si interrompe c'è una riga spezzata. Se suona ripetitiva c'è un artefatto. **Traccia il lavoro:** ```bash -# Aggiorna notes/revision_log.md con ogni correzione rilevante -# Commita spesso per poter tornare indietro +# step-4/revision_log.md viene aggiornato automaticamente da revise.py +# Commita dopo la revisione manuale -git add processed/documento/clean.md notes/revision_log.md -git commit -m "revisione: sezioni 1-50 corrette" +git add step-4/revision_log.md +git commit -m "step-4: revisione nietzsche completata" ``` --- diff --git a/step-4/revise.py b/step-4/revise.py new file mode 100644 index 0000000..cf703a2 --- /dev/null +++ b/step-4/revise.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +""" +Step 4 — Revisione automatica del Markdown + +Trasforma clean.md da step-3 rivelando la struttura latente del documento. +Le trasformazioni sono euristiche universali che funzionano su qualsiasi PDF: + + - Normalizza whitespace multiplo (artefatto PDF) + - Riduce righe vuote multiple + - Rimuove marcatori **bold** nelle intestazioni esistenti + - Converte righe ALL-CAPS standalone → ## header (euristico, qualsiasi lingua) + - Converte sezioni numerate "N. testo" → ### N. (qualsiasi numerazione) + - Rimuove blocchi TOC (righe che iniziano con parole-chiave indice) + +Per ogni documento viene ricalcolato il profilo strutturale: il livello può +salire (es. livello 1 → 3) se le strutture latenti vengono rilevate. + +Output in step-4//: + raw.md — copia da step-3 (non modificare mai) + clean.md — MD revisionato + structure_profile.json — profilo aggiornato dopo la revisione + +Uso: + python step-4/revise.py # tutti i documenti in step-3/ + python step-4/revise.py --stem nietzsche # un solo documento + python step-4/revise.py --force # riesegui anche se già presente +""" + +import argparse +import json +import re +import shutil +import sys +from datetime import date +from pathlib import Path + +# Riusa la funzione analyze() già scritta nello step 3 +sys.path.insert(0, str(Path(__file__).parent.parent / "step-3")) +from detect_structure import analyze # noqa: E402 + + +# ─── Costanti ───────────────────────────────────────────────────────────────── + +# Parole-chiave che identificano blocchi TOC (da rimuovere) +_TOC_KEYWORDS = frozenset([ + "indice", "index", "contents", "table of contents", + "sommario", "inhaltsverzeichnis", "inhalt", +]) + +# Preposizioni/articoli da non capitalizzare nel title-case +_STOP_IT_EN = frozenset([ + # italiano + "di", "del", "della", "dei", "delle", "da", "in", "e", "il", "la", + "lo", "le", "gli", "un", "una", "per", "a", "al", "alla", "ai", + "alle", "con", "su", "sul", "sulla", "che", "o", + # inglese + "of", "the", "a", "an", "and", "or", "but", "in", "on", "at", + "to", "for", "with", "by", "from", "as", +]) + +# Ordinali italiani → romani (per titoli come "CAPITOLO PRIMO") +_ORDINALS_IT = { + "PRIMO": "I", "SECONDO": "II", "TERZO": "III", "QUARTO": "IV", + "QUINTO": "V", "SESTO": "VI", "SETTIMO": "VII", "OTTAVO": "VIII", + "NONO": "IX", "DECIMO": "X", +} + +# Ordinali inglesi → arabici (per "CHAPTER ONE") +_ORDINALS_EN = { + "ONE": "1", "TWO": "2", "THREE": "3", "FOUR": "4", "FIVE": "5", + "SIX": "6", "SEVEN": "7", "EIGHT": "8", "NINE": "9", "TEN": "10", +} + + +# ─── Utilità ────────────────────────────────────────────────────────────────── + +def _sentence_case(s: str) -> str: + """ + Sentence-case: prima lettera maiuscola, resto minuscolo. + Corretto per l'italiano e accettabile per l'inglese accademico. + """ + if not s: + return s + lower = s.lower() + return lower[0].upper() + lower[1:] + + +def _is_allcaps_line(line: str) -> bool: + """ + True se la riga è una candidata per conversione a ## header. + Criterio: tutti i caratteri alfabetici sono maiuscoli, lunghezza >= 3. + """ + stripped = line.strip() + letters = [c for c in stripped if c.isalpha()] + return ( + len(letters) >= 3 + and all(c.isupper() for c in letters) + and not stripped.startswith("#") + ) + + +def _allcaps_to_header(raw_line: str) -> str: + """ + Converte una riga ALL-CAPS in un ## header title-case. + Riconosce pattern specifici (CAPITOLO ORDINE, CHAPTER N) come bonus, + ma funziona in modalità generica su qualsiasi testo. + """ + text = raw_line.strip().rstrip('.').rstrip('?').strip() + + # ── Pattern italiano: "CAPITOLO PRIMO. TITOLO DEL CAPITOLO" + _ORD_IT_PAT = "|".join(_ORDINALS_IT.keys()) + m = re.match(rf'^CAPITOLO ({_ORD_IT_PAT})\. (.+)', text) + if m: + roman = _ORDINALS_IT[m.group(1)] + titolo = m.group(2).rstrip('.').rstrip('?').strip() + return f"## Capitolo {roman} — {_sentence_case(titolo)}" + + # ── Pattern inglese: "CHAPTER ONE. TITLE" o "CHAPTER 1. TITLE" + _ORD_EN_PAT = "|".join(_ORDINALS_EN.keys()) + m = re.match(rf'^CHAPTER ({_ORD_EN_PAT}|\d+)\.? (.+)', text) + if m: + n = _ORDINALS_EN.get(m.group(1), m.group(1)) + titolo = m.group(2).rstrip('.').rstrip('?').strip() + return f"## Chapter {n} — {_sentence_case(titolo)}" + + # ── Pattern generico con numerazione romana o arabica nel prefisso + m = re.match(r'^([IVXLCDM]+|[0-9]+)\. (.+)', text) + if m: + n = m.group(1) + titolo = m.group(2).rstrip('.').strip() + return f"## {n}. {_sentence_case(titolo)}" + + # ── Caso generico: tutto maiuscolo senza pattern riconoscibile + return f"## {_sentence_case(text)}" + + +def _is_toc_line(line: str) -> bool: + """True se la riga è l'intestazione di un blocco indice/TOC.""" + first_word = line.strip().split('.')[0].strip().lower() + return first_word in _TOC_KEYWORDS + + +# ─── Trasformazioni ──────────────────────────────────────────────────────────── + +def apply_transforms(text: str) -> tuple[str, dict]: + """ + Applica tutte le trasformazioni strutturali al testo MD. + Restituisce (testo_modificato, statistiche). + """ + stats = { + "toc_rimosso": False, + "n_header_allcaps": 0, + "n_sezioni_numerate": 0, + "n_paragrafi_uniti": 0, + } + + # ── 1. Rimuovi marcatori **bold** nelle intestazioni esistenti + # ## **Titolo** → ## Titolo + text = re.sub( + r'^(#{1,6})\s+\*\*(.+?)\*\*\s*$', + r'\1 \2', + text, flags=re.MULTILINE, + ) + + # ── 1b. Normalizza header esistenti con contenuto ALL-CAPS → sentence-case + # ## AL DI LA' DEL BENE E DEL MALE → ## Al di la' del bene e del male + def _norm_allcaps_header(m: re.Match) -> str: + hashes = m.group(1) + content = m.group(2).strip() + letters = [c for c in content if c.isalpha()] + if letters and all(c.isupper() for c in letters): + return f"{hashes} {_sentence_case(content)}" + return m.group(0) + + text = re.sub( + r'^(#{1,6}) (.+)$', + _norm_allcaps_header, + text, flags=re.MULTILINE, + ) + + # ── 2. Rimuovi blocco TOC (riga indice + contenuto inline sulla stessa riga) + # "INDICE. Capitolo 1 Capitolo 2 ..." → rimossa + lines = text.split('\n') + new_lines = [] + for line in lines: + if _is_toc_line(line): + stats["toc_rimosso"] = True + else: + new_lines.append(line) + text = '\n'.join(new_lines) + + # ── 3. Converti righe ALL-CAPS standalone → ## header + # Una riga è "standalone" se è preceduta/seguita da riga vuota + # oppure si trova all'inizio/fine del documento. + blocks = text.split('\n\n') + new_blocks = [] + for block in blocks: + stripped = block.strip() + # Blocco standalone = un'unica riga (nessun \n interno rilevante) + if '\n' not in stripped and _is_allcaps_line(stripped): + new_blocks.append(_allcaps_to_header(stripped)) + stats["n_header_allcaps"] += 1 + else: + # Controlla riga per riga per righe ALL-CAPS seguite da altri contenuti + sub_lines = block.split('\n') + converted = [] + for ln in sub_lines: + if _is_allcaps_line(ln) and len(ln.strip()) > 3: + converted.append(_allcaps_to_header(ln)) + stats["n_header_allcaps"] += 1 + else: + converted.append(ln) + new_blocks.append('\n'.join(converted)) + text = '\n\n'.join(new_blocks) + + # ── 4. Converti sezioni numerate "N. testo" → "### N.\n\ntesto" + # Riconosce: "1. Testo", "42. Testo" (due o più spazi dopo il punto) + def _num_repl(m: re.Match) -> str: + num = m.group(1) + testo = m.group(2).strip() + stats["n_sezioni_numerate"] += 1 + return f"### {num}.\n\n{testo}" + + # Pattern standard: "1. testo" o "1. testo" + text = re.sub( + r'^(\d+)\.\s+(.+)$', + _num_repl, + text, flags=re.MULTILINE, + ) + + # Pattern con lettera-suffisso: "65 a. testo" o "65a. testo" + def _num_letter_repl(m: re.Match) -> str: + num = m.group(1) + m.group(2) + testo = m.group(3).strip() + stats["n_sezioni_numerate"] += 1 + return f"### {num}.\n\n{testo}" + + text = re.sub( + r'^(\d+)\s*([a-z])\.\s+(.+)$', + _num_letter_repl, + text, flags=re.MULTILINE, + ) + + # ── 5. Unisci paragrafi spezzati da salti pagina PDF + # Criterio: blocco A non finisce con punteggiatura di fine frase, + # blocco B non inizia con maiuscola "di sezione" né è un header. + # Unione sicura: mai attraverso confini ###/##. + _SENTENCE_END = set('.?!»)\'"') + blocks = text.split('\n\n') + merged = [] + i = 0 + while i < len(blocks): + b = blocks[i] + stripped = b.strip() + # Prova a unire con il successivo se la frase è spezzata + while ( + i + 1 < len(blocks) + and stripped + and not stripped.startswith('#') + and stripped[-1] not in _SENTENCE_END + ): + nxt = blocks[i + 1].strip() + # Non unire se il successivo è un header o è vuoto + if not nxt or nxt.startswith('#'): + break + # Non unire se il successivo inizia con una cifra seguita da punto + # (sarebbe l'inizio di un nuovo aforisma non ancora convertito) + if re.match(r'^\d+\.', nxt): + break + b = stripped + ' ' + nxt + stripped = b.strip() + stats["n_paragrafi_uniti"] += 1 + i += 1 + merged.append(b) + i += 1 + text = '\n\n'.join(merged) + + # ── 6. Normalizza whitespace multiplo interno alle righe + # "parola parola" → "parola parola" (inclusi gli header) + lines = text.split('\n') + normalized = [] + for line in lines: + if not line.strip(): + normalized.append(line) + else: + normalized.append(re.sub(r' +', ' ', line)) + text = '\n'.join(normalized) + + # ── 7. Riduci righe vuote multiple a doppie + text = re.sub(r'\n{3,}', '\n\n', text) + + return text, stats + + +# ─── Aggiornamento revision log ──────────────────────────────────────────────── + +def update_revision_log( + log_path: Path, + stem: str, + profile_before: dict, + profile_after: dict, + t_stats: dict, +) -> None: + header_exists = log_path.exists() and log_path.stat().st_size > 0 + + avv = profile_after.get("avvertenze", []) + avv_str = "; ".join(avv) if avv else "nessuna" + + entry = f""" +## {stem} — {date.today().isoformat()} + +**Trasformazioni automatiche:** +- Normalizzazione whitespace multiplo e righe vuote +- Blocco TOC rimosso: {'sì' if t_stats['toc_rimosso'] else 'no'} +- Righe ALL-CAPS → ## header: {t_stats['n_header_allcaps']} +- Sezioni numerate → ### header: {t_stats['n_sezioni_numerate']} +- Paragrafi uniti (salti pagina PDF): {t_stats['n_paragrafi_uniti']} +- Livello struttura: {profile_before.get('livello_struttura', '?')} → {profile_after.get('livello_struttura', '?')} + +**Avvertenze residue:** {avv_str} + +**Revisioni manuali pendenti:** +- [ ] Verificare conversioni ALL-CAPS errate +- [ ] Controllare sezioni troppo corte o troppo lunghe +""" + + if not header_exists: + log_path.write_text("# Revision log\n" + entry, encoding="utf-8") + else: + existing = log_path.read_text(encoding="utf-8") + log_path.write_text(existing + entry, encoding="utf-8") + + +# ─── Per-document processing ───────────────────────────────────────────────── + +def process_stem(stem: str, project_root: Path, force: bool) -> bool: + src_dir = project_root / "step-3" / stem + out_dir = project_root / "step-4" / stem + raw_src = src_dir / "raw.md" + clean_src = src_dir / "clean.md" + profile_src = src_dir / "structure_profile.json" + clean_out = out_dir / "clean.md" + profile_out = out_dir / "structure_profile.json" + + print(f"\nDocumento: {stem}") + + if not clean_src.exists(): + print(f" ✗ clean.md non trovato in step-3/{stem}/ — skip") + return False + + if clean_out.exists() and not force: + print(f" ⚠️ clean.md già presente — skip") + print(f" (usa --force per rieseguire)") + return True + + out_dir.mkdir(parents=True, exist_ok=True) + + # Copia raw.md immutabile (riferimento) + if raw_src.exists(): + shutil.copy2(raw_src, out_dir / "raw.md") + print(f" Copiato raw.md da step-3/{stem}/") + + # Leggi profilo step-3 (per confronto nel report) + profile_before: dict = {} + if profile_src.exists(): + profile_before = json.loads(profile_src.read_text(encoding="utf-8")) + + # Applica trasformazioni + print(f" Applicazione trasformazioni strutturali...") + text = clean_src.read_text(encoding="utf-8") + text_revised, t_stats = apply_transforms(text) + + # Salva clean.md revisionato + clean_out.write_text(text_revised, encoding="utf-8") + + # Ricalcola profilo sul nuovo clean.md + profile_after = analyze(clean_out) + profile_out.write_text( + json.dumps(profile_after, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + # Report + lv_b = profile_before.get("livello_struttura", "?") + lv_a = profile_after["livello_struttura"] + _STRAT = {3: "h3_aware", 2: "h2_paragraph_split", 1: "paragraph", 0: "sliding_window"} + print(f" ✅ Livello struttura: {lv_b} → {lv_a} ({_STRAT.get(lv_a, '?')})") + print(f" h2: {profile_before.get('n_h2','?')} → {profile_after['n_h2']}") + print(f" h3: {profile_before.get('n_h3','?')} → {profile_after['n_h3']}") + print(f" TOC rimosso: {'sì' if t_stats['toc_rimosso'] else 'no'}") + print(f" Righe ALL-CAPS → ##: {t_stats['n_header_allcaps']}") + print(f" Sezioni numerate → ###: {t_stats['n_sezioni_numerate']}") + print(f" Paragrafi uniti (salti pagina): {t_stats['n_paragrafi_uniti']}") + for w in profile_after["avvertenze"]: + print(f" ⚠️ {w}") + + # Aggiorna revision log (direttamente in step-4/, non in sottocartella) + log_path = project_root / "step-4" / "revision_log.md" + update_revision_log(log_path, stem, profile_before, profile_after, t_stats) + print(f" ✅ step-4/revision_log.md aggiornato") + print(f" ✅ structure_profile.json salvato") + return True + + +# ─── Entry point ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + project_root = Path(__file__).parent.parent + + parser = argparse.ArgumentParser(description="Step 4 — Revisione automatica Markdown") + parser.add_argument("--stem", help="Nome del documento (sottocartella di step-3/)") + parser.add_argument("--force", action="store_true", help="Riesegui anche se già presente") + args = parser.parse_args() + + if args.stem: + stems = [args.stem] + else: + step3_dir = project_root / "step-3" + if not step3_dir.exists(): + print(f"Errore: cartella step-3/ non trovata in {project_root}") + sys.exit(1) + stems = sorted(p.name for p in step3_dir.iterdir() if p.is_dir()) + if not stems: + print(f"Errore: nessun documento trovato in step-3/") + sys.exit(1) + + results = [process_stem(s, project_root, args.force) for s in stems] + + ok = sum(results) + total = len(results) + print(f"\n{'✅' if all(results) else '⚠️ '} {ok}/{total} documenti revisionati") + + sys.exit(0 if all(results) else 1)