2026-04-20 11:36:18 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2026-05-20 16:07:40 +02:00
|
|
|
|
Pipeline di chunking unificata (Stage 1 + Stage 2)
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
Stage 1 — Ottimizzazione Markdown (md_optimizer):
|
|
|
|
|
|
Legge _content_list_v2.json + _model.json di MinerU e produce _clean.md
|
|
|
|
|
|
con gerarchia H1/H2/H3 pulita (TOC, frontespizi e sommari rimossi).
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
Stage 2 — Chunking semantico:
|
|
|
|
|
|
Divide il _clean.md in chunk semantici:
|
|
|
|
|
|
- un chunk per paragrafo (mai due paragrafi nello stesso chunk)
|
|
|
|
|
|
- split a confine di frase se il paragrafo supera MAX_CHARS
|
|
|
|
|
|
- overlap di OVERLAP_SENTENCES frasi tra chunk consecutivi
|
|
|
|
|
|
- tabelle e liste sono blocchi atomici (non si spezzano)
|
|
|
|
|
|
|
|
|
|
|
|
Input: sources/<stem>/auto/<stem>_content_list_v2.json
|
|
|
|
|
|
sources/<stem>/auto/<stem>_model.json (opzionale)
|
|
|
|
|
|
Output: sources/<stem>/auto/<stem>_clean.md
|
|
|
|
|
|
chunks/<stem>/chunks.json
|
|
|
|
|
|
chunks/<stem>/meta.json
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
Uso:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
python chunks/chunker.py --stem <stem>
|
|
|
|
|
|
python chunks/chunker.py # tutti gli stem in sources/
|
|
|
|
|
|
python chunks/chunker.py --stem <stem> --force
|
|
|
|
|
|
python chunks/chunker.py --stem <stem> --skip-optimize # salta Stage 1
|
2026-04-20 11:36:18 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-05-11 15:45:24 +02:00
|
|
|
|
_HERE = Path(__file__).resolve().parent
|
|
|
|
|
|
if str(_HERE) not in sys.path:
|
|
|
|
|
|
sys.path.insert(0, str(_HERE))
|
|
|
|
|
|
import config as cfg
|
2026-05-20 16:07:40 +02:00
|
|
|
|
from md_optimizer import optimize as _optimize_md
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Utilità ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def split_sentences(text: str) -> list[str]:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
parts = re.split(cfg.SENTENCE_SPLIT_RE, text.strip())
|
2026-04-20 11:36:18 +02:00
|
|
|
|
return [p.strip() for p in parts if p.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
def context_to_meta(context: str) -> tuple[str, str]:
|
|
|
|
|
|
"""Divide 'H1 > H2 > H3' in (sezione, titolo) per ingest/verify."""
|
|
|
|
|
|
parts = [p.strip() for p in context.split(" > ") if p.strip()]
|
|
|
|
|
|
if len(parts) >= 2:
|
|
|
|
|
|
return " > ".join(parts[:-1]), parts[-1]
|
|
|
|
|
|
return (parts[0] if parts else ""), ""
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
# ─── Parser Markdown ──────────────────────────────────────────────────────────
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
def parse_paragraphs(text: str) -> list[dict]:
|
|
|
|
|
|
"""Estrae blocchi dal _clean.md con il loro contesto heading.
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
Restituisce: [{"context": "H1 > H2 > H3", "text": "...", "kind": "text|table|list"}]
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
Ogni riga vuota chiude il paragrafo corrente. Tabelle (righe con |) e
|
|
|
|
|
|
liste (righe con -) vengono accumulate come blocchi atomici.
|
|
|
|
|
|
"""
|
|
|
|
|
|
h1 = h2 = h3 = ""
|
|
|
|
|
|
result: list[dict] = []
|
|
|
|
|
|
buf: list[str] = []
|
|
|
|
|
|
cur_kind = "text"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
def flush() -> None:
|
|
|
|
|
|
body = "\n".join(buf).strip()
|
2026-04-20 11:36:18 +02:00
|
|
|
|
if body:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
parts = [p for p in [h1, h2, h3] if p]
|
|
|
|
|
|
context = " > ".join(parts) if parts else "documento"
|
|
|
|
|
|
result.append({"context": context, "text": body, "kind": cur_kind})
|
|
|
|
|
|
buf.clear()
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
|
if re.match(r"^# ", line):
|
|
|
|
|
|
flush()
|
2026-05-20 16:07:40 +02:00
|
|
|
|
h1, h2, h3 = line[2:].strip(), "", ""
|
|
|
|
|
|
cur_kind = "text"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
elif re.match(r"^## ", line):
|
|
|
|
|
|
flush()
|
2026-05-20 16:07:40 +02:00
|
|
|
|
h2, h3 = line[3:].strip(), ""
|
|
|
|
|
|
cur_kind = "text"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
elif re.match(r"^### ", line):
|
|
|
|
|
|
flush()
|
2026-05-20 16:07:40 +02:00
|
|
|
|
h3 = line[4:].strip()
|
|
|
|
|
|
cur_kind = "text"
|
|
|
|
|
|
elif line.strip().startswith("|"):
|
|
|
|
|
|
if cur_kind != "table":
|
|
|
|
|
|
flush()
|
|
|
|
|
|
cur_kind = "table"
|
|
|
|
|
|
buf.append(line)
|
|
|
|
|
|
elif line.strip().startswith("- "):
|
|
|
|
|
|
if cur_kind != "list":
|
|
|
|
|
|
flush()
|
|
|
|
|
|
cur_kind = "list"
|
|
|
|
|
|
buf.append(line)
|
|
|
|
|
|
elif line.strip() == "":
|
2026-04-20 11:36:18 +02:00
|
|
|
|
flush()
|
2026-05-20 16:07:40 +02:00
|
|
|
|
cur_kind = "text"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
else:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
if cur_kind in ("table", "list"):
|
|
|
|
|
|
flush()
|
|
|
|
|
|
cur_kind = "text"
|
|
|
|
|
|
buf.append(line)
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
flush()
|
2026-05-20 16:07:40 +02:00
|
|
|
|
return result
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
# ─── Chunking ─────────────────────────────────────────────────────────────────
|
2026-05-11 15:45:24 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
def make_chunks(paragraphs: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""Genera chunk dal risultato di parse_paragraphs.
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
Regole:
|
|
|
|
|
|
- un chunk = un paragrafo (o sotto-parte se > MAX_CHARS)
|
|
|
|
|
|
- split solo a confine di frase; una frase che supera MAX_CHARS è emessa intera
|
|
|
|
|
|
- l'ultima frase del chunk N viene preposta al chunk N+1 (overlap)
|
|
|
|
|
|
- tabelle e liste: blocco atomico (mai spezzato)
|
|
|
|
|
|
"""
|
|
|
|
|
|
chunks: list[dict] = []
|
|
|
|
|
|
overlap_tail: list[str] = []
|
|
|
|
|
|
idx = 0
|
|
|
|
|
|
|
|
|
|
|
|
for para in paragraphs:
|
|
|
|
|
|
text = para["text"]
|
|
|
|
|
|
context = para["context"]
|
|
|
|
|
|
kind = para["kind"]
|
|
|
|
|
|
sezione, titolo = context_to_meta(context)
|
|
|
|
|
|
|
|
|
|
|
|
# ── Blocchi atomici (tabelle, liste) ──────────────────────────────────
|
|
|
|
|
|
if kind in ("table", "list"):
|
|
|
|
|
|
prefix = " ".join(overlap_tail) + " " if overlap_tail else ""
|
|
|
|
|
|
body = (prefix + text).strip()
|
|
|
|
|
|
chunk_text = f"[{context}]\n{body}"
|
|
|
|
|
|
chunks.append({
|
|
|
|
|
|
"chunk_id": f"c{idx}",
|
|
|
|
|
|
"text": chunk_text,
|
|
|
|
|
|
"sezione": sezione,
|
|
|
|
|
|
"titolo": titolo,
|
|
|
|
|
|
"context": context,
|
|
|
|
|
|
"n_chars": len(chunk_text),
|
|
|
|
|
|
})
|
|
|
|
|
|
idx += 1
|
|
|
|
|
|
sents = split_sentences(text)
|
|
|
|
|
|
overlap_tail = sents[-cfg.OVERLAP_SENTENCES:] if cfg.OVERLAP_SENTENCES else []
|
2026-04-20 11:36:18 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
# ── Paragrafo testo: split a confine di frase ─────────────────────────
|
|
|
|
|
|
sents = split_sentences(text)
|
|
|
|
|
|
if not sents:
|
|
|
|
|
|
continue
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
current: list[str] = list(overlap_tail)
|
|
|
|
|
|
has_primary: bool = False
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
for sent in sents:
|
|
|
|
|
|
candidate_len = len(" ".join(current + [sent]))
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
if candidate_len <= cfg.MAX_CHARS or not has_primary:
|
|
|
|
|
|
current.append(sent)
|
|
|
|
|
|
has_primary = True
|
2026-04-20 11:36:18 +02:00
|
|
|
|
else:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
body = " ".join(current)
|
|
|
|
|
|
chunk_text = f"[{context}]\n{body}"
|
|
|
|
|
|
chunks.append({
|
|
|
|
|
|
"chunk_id": f"c{idx}",
|
|
|
|
|
|
"text": chunk_text,
|
|
|
|
|
|
"sezione": sezione,
|
|
|
|
|
|
"titolo": titolo,
|
|
|
|
|
|
"context": context,
|
|
|
|
|
|
"n_chars": len(chunk_text),
|
|
|
|
|
|
})
|
|
|
|
|
|
idx += 1
|
|
|
|
|
|
overlap_tail = current[-cfg.OVERLAP_SENTENCES:] if cfg.OVERLAP_SENTENCES else []
|
|
|
|
|
|
current = list(overlap_tail) + [sent]
|
|
|
|
|
|
has_primary = True
|
|
|
|
|
|
|
|
|
|
|
|
if has_primary:
|
|
|
|
|
|
body = " ".join(current)
|
|
|
|
|
|
chunk_text = f"[{context}]\n{body}"
|
|
|
|
|
|
chunks.append({
|
|
|
|
|
|
"chunk_id": f"c{idx}",
|
|
|
|
|
|
"text": chunk_text,
|
|
|
|
|
|
"sezione": sezione,
|
|
|
|
|
|
"titolo": titolo,
|
|
|
|
|
|
"context": context,
|
|
|
|
|
|
"n_chars": len(chunk_text),
|
|
|
|
|
|
})
|
|
|
|
|
|
idx += 1
|
|
|
|
|
|
overlap_tail = current[-cfg.OVERLAP_SENTENCES:] if cfg.OVERLAP_SENTENCES else []
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
# ─── Pipeline per documento ───────────────────────────────────────────────────
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
def process_stem(stem: str, project_root: Path,
|
|
|
|
|
|
force: bool, skip_optimize: bool) -> bool:
|
|
|
|
|
|
"""Esegue Stage 1 (ottimizzazione MD) + Stage 2 (chunking) per un documento."""
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
# ── Stage 1: ottimizzazione Markdown ──────────────────────────────────────
|
|
|
|
|
|
if not skip_optimize:
|
|
|
|
|
|
ok = _optimize_md(stem, project_root, force=force)
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"\n[Stage 1] skip (--skip-optimize)")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
# ── Stage 2: chunking ─────────────────────────────────────────────────────
|
|
|
|
|
|
clean_md = project_root / "sources" / stem / "auto" / f"{stem}_clean.md"
|
|
|
|
|
|
out_dir = project_root / "chunks" / stem
|
|
|
|
|
|
out_file = out_dir / "chunks.json"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
print(f"[Stage 2] Chunking: {stem}")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
if not clean_md.exists():
|
2026-05-20 16:07:40 +02:00
|
|
|
|
print(f" ✗ {stem}_clean.md non trovato")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
if out_file.exists() and not force:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
print(f" ↩ chunks.json già presente — skip chunking")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
return True
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
text = clean_md.read_text(encoding="utf-8")
|
|
|
|
|
|
paragraphs = parse_paragraphs(text)
|
|
|
|
|
|
|
|
|
|
|
|
if not paragraphs:
|
|
|
|
|
|
print(f" ✗ Nessun paragrafo estratto da {clean_md.name}")
|
|
|
|
|
|
return False
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
chunks = make_chunks(paragraphs)
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
if not chunks:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
print(f" ✗ Nessun chunk generato")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-05-12 11:09:28 +02:00
|
|
|
|
(out_dir / "meta.json").write_text(
|
2026-05-20 16:07:40 +02:00
|
|
|
|
json.dumps({
|
|
|
|
|
|
"min_chars": cfg.MIN_CHARS,
|
|
|
|
|
|
"max_chars": cfg.MAX_CHARS,
|
|
|
|
|
|
"target_chars": cfg.MAX_CHARS,
|
|
|
|
|
|
"overlap": cfg.OVERLAP_SENTENCES,
|
|
|
|
|
|
"strategy": "paragraph_overlap",
|
|
|
|
|
|
}, ensure_ascii=False),
|
|
|
|
|
|
encoding="utf-8",
|
2026-05-12 11:09:28 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
lengths = [c["n_chars"] for c in chunks]
|
|
|
|
|
|
over_max = sum(1 for l in lengths if l > cfg.MAX_CHARS)
|
|
|
|
|
|
under_min = sum(1 for l in lengths if l < cfg.MIN_CHARS)
|
|
|
|
|
|
avg = int(sum(lengths) / len(lengths))
|
|
|
|
|
|
|
|
|
|
|
|
print(f" ✅ {len(chunks)} chunk | media {avg} char | max {max(lengths)} char")
|
|
|
|
|
|
if over_max:
|
|
|
|
|
|
print(f" ⚠️ {over_max} chunk superano MAX_CHARS={cfg.MAX_CHARS}")
|
|
|
|
|
|
if under_min:
|
|
|
|
|
|
print(f" ℹ️ {under_min} chunk sotto MIN_CHARS={cfg.MIN_CHARS}")
|
|
|
|
|
|
print(f" → chunks/{stem}/chunks.json")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
# ─── Entry point ──────────────────────────────────────────────────────────────
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
project_root = Path(__file__).parent.parent
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description="Pipeline unificata MinerU → _clean.md → chunks.json"
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument("--stem", help="Nome documento (sottocartella di sources/)")
|
|
|
|
|
|
parser.add_argument("--force", action="store_true",
|
|
|
|
|
|
help="Rigenera _clean.md e chunks.json anche se esistono")
|
|
|
|
|
|
parser.add_argument("--skip-optimize", action="store_true",
|
|
|
|
|
|
help="Salta Stage 1 (usa _clean.md già presente)")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
if args.stem:
|
|
|
|
|
|
stems = [args.stem]
|
|
|
|
|
|
else:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
sources_dir = project_root / "sources"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
stems = sorted(
|
2026-05-20 16:07:40 +02:00
|
|
|
|
p.name for p in sources_dir.iterdir()
|
|
|
|
|
|
if p.is_dir()
|
|
|
|
|
|
and (p / "auto" / f"{p.name}_content_list_v2.json").exists()
|
2026-04-20 11:36:18 +02:00
|
|
|
|
)
|
|
|
|
|
|
if not stems:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
print("Errore: nessun documento MinerU trovato in sources/")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
results = [
|
|
|
|
|
|
process_stem(s, project_root, args.force, args.skip_optimize)
|
|
|
|
|
|
for s in stems
|
|
|
|
|
|
]
|
|
|
|
|
|
ok = sum(results)
|
|
|
|
|
|
print(f"\n{'✅' if all(results) else '⚠️ '} {ok}/{len(results)} documenti processati")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
sys.exit(0 if all(results) else 1)
|