2026-04-20 11:36:18 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2026-06-04 14:18:17 +02:00
|
|
|
|
chunker.py — Chunking semantico da Markdown pulito
|
|
|
|
|
|
|
|
|
|
|
|
Input: sources/<stem>.md — Markdown già strutturato (H1/H2/H3 + paragrafi)
|
|
|
|
|
|
Output: chunks/<stem>/chunks.json
|
2026-05-20 16:07:40 +02:00
|
|
|
|
chunks/<stem>/meta.json
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
Regole:
|
|
|
|
|
|
- ogni paragrafo diventa un chunk; paragrafi di contesti diversi non si mescolano
|
|
|
|
|
|
- se un paragrafo supera MAX_CHARS viene spezzato a confine di frase (mai a metà)
|
|
|
|
|
|
- paragrafi brevi (< MIN_CHARS) vengono fusi col successivo finché non raggiungono
|
|
|
|
|
|
MIN_CHARS, a patto che abbiano lo stesso contesto heading
|
|
|
|
|
|
- tabelle, liste e codice sono blocchi atomici (non si spezzano)
|
|
|
|
|
|
|
2026-04-20 11:36:18 +02:00
|
|
|
|
Uso:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
python chunks/chunker.py --stem <stem>
|
2026-06-04 14:18:17 +02:00
|
|
|
|
python chunks/chunker.py # tutti gli stem con .md in sources/
|
2026-05-20 16:07:40 +02:00
|
|
|
|
python chunks/chunker.py --stem <stem> --force
|
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-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]:
|
|
|
|
|
|
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-06-04 14:18:17 +02:00
|
|
|
|
_SKIP_HEADINGS_LOWER = {h.lower() for h in cfg.SKIP_HEADINGS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_skip_heading(title: str) -> bool:
|
|
|
|
|
|
t = title.lower()
|
|
|
|
|
|
return any(t == s or t.startswith(s) for s in _SKIP_HEADINGS_LOWER)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
def parse_paragraphs(text: str) -> list[dict]:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
"""Estrae blocchi dal Markdown con il loro contesto heading.
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
Restituisce: [{"context": "H1 > H2 > H3", "text": "...", "kind": "text|table|list|code"}]
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
- Heading senza corpo non emettono chunk: aggiornano solo il contesto.
|
|
|
|
|
|
- Tabelle (righe |), liste (righe -/*) e code block (```) sono atomici.
|
|
|
|
|
|
- Sezioni in SKIP_HEADINGS vengono saltate completamente.
|
|
|
|
|
|
- Contenuto pre-heading saltato se SKIP_PRE_HEADING è True.
|
2026-05-20 16:07:40 +02:00
|
|
|
|
"""
|
2026-06-04 14:18:17 +02:00
|
|
|
|
headings = ["", "", ""] # H1, H2, H3
|
|
|
|
|
|
result: list[dict] = []
|
|
|
|
|
|
buf: list[str] = []
|
|
|
|
|
|
cur_kind = "text"
|
|
|
|
|
|
in_code = False
|
|
|
|
|
|
skip_level: int | None = None # livello heading che ha attivato lo skip
|
|
|
|
|
|
|
|
|
|
|
|
def current_context() -> str:
|
|
|
|
|
|
parts = [h for h in headings if h]
|
|
|
|
|
|
return " > ".join(parts[:cfg.CONTEXT_DEPTH]) if parts else "documento"
|
|
|
|
|
|
|
|
|
|
|
|
def is_skipping() -> bool:
|
|
|
|
|
|
return skip_level is not None
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
def flush() -> None:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
if is_skipping():
|
|
|
|
|
|
buf.clear()
|
|
|
|
|
|
return
|
|
|
|
|
|
if cfg.SKIP_PRE_HEADING and current_context() == "documento":
|
|
|
|
|
|
buf.clear()
|
|
|
|
|
|
return
|
2026-05-20 16:07:40 +02:00
|
|
|
|
body = "\n".join(buf).strip()
|
2026-04-20 11:36:18 +02:00
|
|
|
|
if body:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
result.append({"context": current_context(), "text": body, "kind": cur_kind})
|
2026-05-20 16:07:40 +02:00
|
|
|
|
buf.clear()
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
for line in text.splitlines():
|
2026-06-04 14:18:17 +02:00
|
|
|
|
# ── Code block toggle ─────────────────────────────────────────────────
|
|
|
|
|
|
if line.strip().startswith("```"):
|
|
|
|
|
|
if not in_code:
|
|
|
|
|
|
flush()
|
|
|
|
|
|
cur_kind = "code"
|
|
|
|
|
|
in_code = True
|
|
|
|
|
|
if not is_skipping():
|
|
|
|
|
|
buf.append(line)
|
|
|
|
|
|
else:
|
|
|
|
|
|
if not is_skipping():
|
|
|
|
|
|
buf.append(line)
|
|
|
|
|
|
in_code = False
|
|
|
|
|
|
flush()
|
|
|
|
|
|
cur_kind = "text"
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if in_code:
|
|
|
|
|
|
if not is_skipping():
|
|
|
|
|
|
buf.append(line)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── Heading ───────────────────────────────────────────────────────────
|
|
|
|
|
|
m = re.match(r"^(#{1,3}) (.+)", line)
|
|
|
|
|
|
if m:
|
2026-04-20 11:36:18 +02:00
|
|
|
|
flush()
|
2026-06-04 14:18:17 +02:00
|
|
|
|
level = len(m.group(1))
|
|
|
|
|
|
title = m.group(2).strip()
|
|
|
|
|
|
|
|
|
|
|
|
# chiudi skip se torniamo a livello pari o superiore
|
|
|
|
|
|
if skip_level is not None and level <= skip_level:
|
|
|
|
|
|
skip_level = None
|
|
|
|
|
|
|
|
|
|
|
|
headings[level - 1] = title
|
|
|
|
|
|
for i in range(level, 3):
|
|
|
|
|
|
headings[i] = ""
|
2026-05-20 16:07:40 +02:00
|
|
|
|
cur_kind = "text"
|
2026-06-04 14:18:17 +02:00
|
|
|
|
|
|
|
|
|
|
# apri skip se questo heading è nella lista
|
|
|
|
|
|
if _is_skip_heading(title):
|
|
|
|
|
|
skip_level = level
|
|
|
|
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── Tabella ───────────────────────────────────────────────────────────
|
|
|
|
|
|
if line.strip().startswith("|"):
|
2026-05-20 16:07:40 +02:00
|
|
|
|
if cur_kind != "table":
|
|
|
|
|
|
flush()
|
|
|
|
|
|
cur_kind = "table"
|
|
|
|
|
|
buf.append(line)
|
2026-06-04 14:18:17 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── Lista ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
if re.match(r"^\s*[-*]\s", line):
|
2026-05-20 16:07:40 +02:00
|
|
|
|
if cur_kind != "list":
|
|
|
|
|
|
flush()
|
|
|
|
|
|
cur_kind = "list"
|
|
|
|
|
|
buf.append(line)
|
2026-06-04 14:18:17 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── Riga vuota: chiude il paragrafo corrente ──────────────────────────
|
|
|
|
|
|
if line.strip() == "":
|
2026-04-20 11:36:18 +02:00
|
|
|
|
flush()
|
2026-05-20 16:07:40 +02:00
|
|
|
|
cur_kind = "text"
|
2026-06-04 14:18:17 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── Testo normale ─────────────────────────────────────────────────────
|
|
|
|
|
|
if cur_kind in ("table", "list", "code"):
|
|
|
|
|
|
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-06-04 14:18:17 +02:00
|
|
|
|
# ─── Merge paragrafi brevi ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def merge_short(paragraphs: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""Fonde paragrafi di testo consecutivi sotto MIN_CHARS con il successivo,
|
|
|
|
|
|
purché condividano lo stesso contesto heading."""
|
|
|
|
|
|
if not cfg.MERGE_SHORT_PARAGRAPHS:
|
|
|
|
|
|
return paragraphs
|
|
|
|
|
|
|
|
|
|
|
|
result: list[dict] = []
|
|
|
|
|
|
buf_para: dict | None = None
|
|
|
|
|
|
buf_text: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
def flush_buf() -> None:
|
|
|
|
|
|
nonlocal buf_para, buf_text
|
|
|
|
|
|
if buf_para is not None:
|
|
|
|
|
|
result.append({**buf_para, "text": buf_text})
|
|
|
|
|
|
buf_para = None
|
|
|
|
|
|
buf_text = ""
|
|
|
|
|
|
|
|
|
|
|
|
for para in paragraphs:
|
|
|
|
|
|
if para["kind"] != "text":
|
|
|
|
|
|
flush_buf()
|
|
|
|
|
|
result.append(para)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if buf_para is None:
|
|
|
|
|
|
buf_para = para
|
|
|
|
|
|
buf_text = para["text"]
|
|
|
|
|
|
elif buf_para["context"] == para["context"] and len(buf_text) < cfg.MIN_CHARS:
|
|
|
|
|
|
buf_text = buf_text + "\n\n" + para["text"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
flush_buf()
|
|
|
|
|
|
buf_para = para
|
|
|
|
|
|
buf_text = para["text"]
|
|
|
|
|
|
|
|
|
|
|
|
flush_buf()
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
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]:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
"""Genera chunk da parse_paragraphs (dopo eventuale merge).
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
- Blocchi atomici (table, list, code): un chunk, mai spezzato.
|
|
|
|
|
|
- Testo: un chunk per paragrafo; se supera MAX_CHARS, spezza a confine frase.
|
2026-05-20 16:07:40 +02:00
|
|
|
|
"""
|
2026-06-04 14:18:17 +02:00
|
|
|
|
chunks: list[dict] = []
|
|
|
|
|
|
idx = 0
|
2026-05-20 16:07:40 +02:00
|
|
|
|
|
|
|
|
|
|
for para in paragraphs:
|
|
|
|
|
|
text = para["text"]
|
|
|
|
|
|
context = para["context"]
|
|
|
|
|
|
kind = para["kind"]
|
|
|
|
|
|
sezione, titolo = context_to_meta(context)
|
|
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
def emit(body: str) -> None:
|
|
|
|
|
|
nonlocal idx
|
2026-05-20 16:07:40 +02:00
|
|
|
|
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),
|
|
|
|
|
|
})
|
2026-06-04 14:18:17 +02:00
|
|
|
|
idx += 1
|
|
|
|
|
|
|
|
|
|
|
|
# ── Atomici ───────────────────────────────────────────────────────────
|
|
|
|
|
|
if kind in ("table", "list", "code"):
|
|
|
|
|
|
emit(text)
|
2026-04-20 11:36:18 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
# ── Testo: split a confine di frase se supera MAX_CHARS ───────────────
|
2026-05-20 16:07:40 +02:00
|
|
|
|
sents = split_sentences(text)
|
|
|
|
|
|
if not sents:
|
|
|
|
|
|
continue
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
current: list[str] = []
|
2026-05-20 16:07:40 +02:00
|
|
|
|
for sent in sents:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
projected = len(" ".join(current + [sent]))
|
|
|
|
|
|
if projected <= cfg.MAX_CHARS or not current:
|
2026-05-20 16:07:40 +02:00
|
|
|
|
current.append(sent)
|
2026-04-20 11:36:18 +02:00
|
|
|
|
else:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
emit(" ".join(current))
|
|
|
|
|
|
current = [sent]
|
|
|
|
|
|
|
|
|
|
|
|
if current:
|
|
|
|
|
|
emit(" ".join(current))
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
# ─── Merge frasi spezzate ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
_SENT_END = re.compile(
|
|
|
|
|
|
"[.!?;:"
|
|
|
|
|
|
+ chr(0xBB) + ")\\\\"
|
|
|
|
|
|
+ chr(0x2018) + chr(0x2019)
|
|
|
|
|
|
+ chr(0x201C) + chr(0x201D)
|
|
|
|
|
|
+ "\"'"
|
|
|
|
|
|
+ chr(0x2014) + chr(0x2013) + chr(0x2026) + chr(0xB7)
|
|
|
|
|
|
+ "]$"
|
|
|
|
|
|
+ r"|\d[\d.,/]*$" # numero o versione
|
|
|
|
|
|
+ r"|\$$" # formula LaTeX inline
|
|
|
|
|
|
+ r"|\}$" # blocco LaTeX
|
|
|
|
|
|
+ r"|>$" # tag HTML
|
|
|
|
|
|
+ r"|\\\\$" # \\ LaTeX
|
|
|
|
|
|
+ r"|\|$" # riga tabella
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _body(chunk: dict) -> str:
|
|
|
|
|
|
text = chunk["text"]
|
|
|
|
|
|
nl = text.find("\n")
|
|
|
|
|
|
return text[nl + 1:] if nl != -1 else text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def merge_broken_sentences(chunks: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""Fonde chunk consecutivi con lo stesso contesto quando il primo termina
|
|
|
|
|
|
senza punteggiatura di fine frase (frase spezzata dal sorgente)."""
|
|
|
|
|
|
result: list[dict] = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(chunks):
|
|
|
|
|
|
c = dict(chunks[i])
|
|
|
|
|
|
body = _body(c)
|
|
|
|
|
|
|
|
|
|
|
|
while (
|
|
|
|
|
|
i + 1 < len(chunks)
|
|
|
|
|
|
and chunks[i + 1]["context"] == c["context"]
|
|
|
|
|
|
and not _SENT_END.search(body.rstrip())
|
|
|
|
|
|
):
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
next_body = _body(chunks[i])
|
|
|
|
|
|
body = body.rstrip() + " " + next_body.lstrip()
|
|
|
|
|
|
|
|
|
|
|
|
chunk_text = f"[{c['context']}]\n{body}"
|
|
|
|
|
|
c["text"] = chunk_text
|
|
|
|
|
|
c["n_chars"] = len(chunk_text)
|
|
|
|
|
|
result.append(c)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
for idx, c in enumerate(result):
|
|
|
|
|
|
c["chunk_id"] = f"c{idx}"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
return result
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
# ─── Pipeline per documento ───────────────────────────────────────────────────
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
def process_stem(stem: str, project_root: Path, force: bool) -> bool:
|
|
|
|
|
|
md_path = project_root / "sources" / f"{stem}_output" / "auto" / f"{stem}.md"
|
|
|
|
|
|
if not md_path.exists():
|
|
|
|
|
|
md_path = project_root / "sources" / f"{stem}.md"
|
|
|
|
|
|
if not md_path.exists():
|
|
|
|
|
|
print(f" ✗ {stem}.md non trovato (cercato in sources/{stem}_output/auto/ e sources/)")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
out_dir = project_root / "chunks" / stem
|
|
|
|
|
|
out_file = out_dir / "chunks.json"
|
|
|
|
|
|
|
2026-04-20 11:36:18 +02:00
|
|
|
|
if out_file.exists() and not force:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
print(f" ↩ chunks/{stem}/chunks.json già presente — skip (usa --force per rigenerare)")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
return True
|
|
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
print(f"[chunker] {stem}")
|
|
|
|
|
|
|
|
|
|
|
|
text = md_path.read_text(encoding="utf-8")
|
2026-05-20 16:07:40 +02:00
|
|
|
|
paragraphs = parse_paragraphs(text)
|
|
|
|
|
|
|
|
|
|
|
|
if not paragraphs:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
print(f" ✗ Nessun paragrafo estratto da {md_path.name}")
|
2026-05-20 16:07:40 +02:00
|
|
|
|
return False
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
if cfg.MERGE_SHORT_PARAGRAPHS:
|
|
|
|
|
|
paragraphs = merge_short(paragraphs)
|
|
|
|
|
|
|
2026-05-20 16:07:40 +02:00
|
|
|
|
chunks = make_chunks(paragraphs)
|
2026-06-04 14:18:17 +02:00
|
|
|
|
chunks = merge_broken_sentences(chunks)
|
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({
|
2026-06-04 14:18:17 +02:00
|
|
|
|
"stem": stem,
|
|
|
|
|
|
"source": str(md_path.relative_to(project_root)),
|
|
|
|
|
|
"max_chars": cfg.MAX_CHARS,
|
|
|
|
|
|
"min_chars": cfg.MIN_CHARS,
|
|
|
|
|
|
"merge_short": cfg.MERGE_SHORT_PARAGRAPHS,
|
|
|
|
|
|
"strategy": "one_paragraph_per_chunk",
|
|
|
|
|
|
}, ensure_ascii=False, indent=2),
|
2026-05-20 16:07:40 +02:00
|
|
|
|
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-06-04 14:18:17 +02:00
|
|
|
|
sources_dir = project_root / "sources"
|
2026-04-20 11:36:18 +02:00
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
parser = argparse.ArgumentParser(description="Markdown pulito → chunks.json")
|
|
|
|
|
|
parser.add_argument("--stem", help="Nome documento (es. analisi2)")
|
2026-05-20 16:07:40 +02:00
|
|
|
|
parser.add_argument("--force", action="store_true",
|
2026-06-04 14:18:17 +02:00
|
|
|
|
help="Rigenera chunks.json anche se già presente")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
if args.stem:
|
|
|
|
|
|
stems = [args.stem]
|
|
|
|
|
|
else:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
found = set()
|
|
|
|
|
|
for p in sources_dir.glob("*_output/auto/*.md"):
|
|
|
|
|
|
found.add(p.stem)
|
|
|
|
|
|
for p in sources_dir.glob("*.md"):
|
|
|
|
|
|
found.add(p.stem)
|
|
|
|
|
|
stems = sorted(found)
|
2026-04-20 11:36:18 +02:00
|
|
|
|
if not stems:
|
2026-06-04 14:18:17 +02:00
|
|
|
|
print("Nessun file .md trovato in sources/")
|
2026-04-20 11:36:18 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-06-04 14:18:17 +02:00
|
|
|
|
results = [process_stem(s, project_root, args.force) for s in stems]
|
2026-05-20 16:07:40 +02:00
|
|
|
|
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)
|