step-4: add revise.py, step4-review skill, README update
- 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
This commit is contained in:
@@ -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/<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)
|
||||
Reference in New Issue
Block a user