1631dff80d
- revise.py: automatic pre-processing (ALL-CAPS→##, numbered sections→###, TOC removal, broken paragraph merging, whitespace normalization); supports N. and Na. numbering patterns; universal heuristics - .claude/commands/step4-review.md: Claude Code skill for qualitative review of clean.md (🔴/🟡/🟢 report + interactive fixes) - README: document step-4 workflow with revise.py and /step4-review - .gitignore: exclude step-4/*/ and step-4/revision_log.md
434 lines
16 KiB
Python
434 lines
16 KiB
Python
#!/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/<stem>/:
|
|
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)
|