diff --git a/src/live/analista.py b/src/live/analista.py index ba0812e..0ef3b9c 100644 --- a/src/live/analista.py +++ b/src/live/analista.py @@ -241,15 +241,22 @@ def per_telegram(voce: dict, analisi: str, stato: str, motivi: list[str] | None """ testo = testata(voce) if analisi.strip(): - corpo = _b(analisi.strip()) - corpo = re.sub(r"\*\*(.+?)\*\*", r"\1", corpo) # il grassetto del giornale + grezzo = analisi.strip() if stato == "sospetta": testo += "\n\n⚠️ controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "" - testo += "\n\n" + corpo + # (#35 C13) il taglio va sul GREZZO, PRIMA di _b (html.escape) e PRIMA di **->: un + # taglio sul testo gia' composto puo' spezzare un'entita' (es. "&am") o lasciare un + # aperto, e Telegram rifiuta l'INTERO messaggio ("can't parse entities") — il taglio si + # perde tutto, non si accorcia. Il marcatore si aggiunge dopo, fuori dall'escape (e' + # testo nostro, senza `&`/`<`: non ha bisogno di escape). + taglio = "\n\n[…tagliato: la voce intera e' in docs/journal/%s.md]" % voce["giorno"] + budget = LIMITE_TELEGRAM - len(testo) - len("\n\n") - len(taglio) + tagliata = len(grezzo) > budget + if tagliata: + grezzo = grezzo[:max(budget, 0)] + corpo = re.sub(r"\*\*(.+?)\*\*", r"\1", _b(grezzo)) # il grassetto del giornale + testo += "\n\n" + corpo + (taglio if tagliata else "") else: testo += ("\n\n⚠️ analisi non disponibile (" + _b(stato) + "): " + _b("; ".join(motivi or ["motivo non registrato"])) + "") - if len(testo) > LIMITE_TELEGRAM: - taglio = "\n\n[…tagliato: la voce intera e' in docs/journal/%s.md]" % voce["giorno"] - testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio return testo diff --git a/src/live/revisione.py b/src/live/revisione.py index ed1f5be..c48cc74 100644 --- a/src/live/revisione.py +++ b/src/live/revisione.py @@ -258,10 +258,14 @@ def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: date return (f"🚨 Revisione {CADENZA} NON eseguita {adesso:%Y-%m-%d}\n" f"{html.escape(errore)}\n{html.escape(str(path))}") sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)" - corpo = html.escape(sintesi, quote=False) tagliato = "" - if len(corpo) > LIMITE_TELEGRAM: - corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]" + # (#35 C13) il taglio va sul testo GREZZO, PRIMA di html.escape: tagliare dopo puo' spezzare + # un'entita' a meta' (es. "&am") e Telegram rifiuta l'intero messaggio ("can't parse + # entities") invece di accorciarlo. Il marcatore di taglio si aggiunge dopo, fuori dall'escape + # (e' testo nostro, senza `&`/`<`: non ha bisogno di escape). + if len(sintesi) > LIMITE_TELEGRAM: + sintesi, tagliato = sintesi[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]" + corpo = html.escape(sintesi, quote=False) return (f"🔍 Revisione {CADENZA} {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n" f"{corpo}{tagliato}\n\n{html.escape(str(path))}") diff --git a/tests/test_analista.py b/tests/test_analista.py index 1348f7d..8c877fa 100644 --- a/tests/test_analista.py +++ b/tests/test_analista.py @@ -345,6 +345,21 @@ def test_il_taglio_a_4096_si_DICHIARA(): assert "tagliato" in m and "docs/journal/2026-08-23.md" in m +def test_taglio_a_cavallo_di_entita_e_tag_resta_HTML_valido(): + """(#35 C13) Il taglio e' sul GREZZO, prima di _b/html.escape e prima di **->: un '&' o + un '**grassetto**' a cavallo del punto di taglio non deve produrre un'entita' spezzata + (es. '&am') ne' un senza chiusura -- Telegram rifiuta l'INTERO messaggio altrimenti + ("can't parse entities"). Il pattern si ripete per tutta la lunghezza, cosi' il punto di + taglio (qualunque sia) cade sempre dentro o a cavallo di un '&' o di '**...**'.""" + pattern = "parola & altra parola **in grassetto** ancora testo " + analisi = pattern * 200 # ben oltre qualunque budget qui + m = A.per_telegram(_voce_piena(), analisi, "ok", []) + assert "tagliato" in m + assert re.search(r"&(?!amp;|lt;|gt;)", m) is None # nessuna entita' spezzata o '&' nudo + assert m.count("") == m.count("") # ogni aperto e' chiuso + assert m.count("") == m.count("") + + def test_un_messaggio_normale_non_viene_toccato(): m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", []) assert "tagliato" not in m and m.endswith("Analisi breve.") diff --git a/tests/test_revisione.py b/tests/test_revisione.py index 2917f89..1cddf2a 100644 --- a/tests/test_revisione.py +++ b/tests/test_revisione.py @@ -132,6 +132,18 @@ def test_telegram_porta_la_sintesi_e_dichiara_il_taglio(): assert "[… sintesi tagliata" in lunga and len(lunga) < 4096 +def test_taglio_a_cavallo_di_una_entita_resta_HTML_valido(): + """(#35 C13) Un '&' esattamente al taglio non deve spezzarsi in un'entita' a meta' (es. + '&am'): il taglio e' sul GREZZO, prima di html.escape, quindi il carattere e' incluso o + escluso per intero — mai spezzato a meta' entita'.""" + sintesi = "a" * (R.LIMITE_TELEGRAM - 1) + "&resto che verra' tagliato via di sicuro" + tg = R.per_telegram(_revisione(sintesi=sintesi), None, "ok", ADESSO, Path("p.md")) + assert "[… sintesi tagliata" in tg + corpo = tg.split("\n\n", 1)[1].split("\n[…", 1)[0] + assert corpo == "a" * (R.LIMITE_TELEGRAM - 1) + "&" # il '&' e' incluso PER INTERO + assert "&am;" not in tg and "resto che verra'" not in tg # niente entita' a meta', niente coda + + # ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no def _albero(root: Path) -> set[str]: