revisione, analista: taglio Telegram sul testo grezzo, prima di escape/markup (#35 C13)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:15:22 +00:00
parent d80323ff59
commit 12291709ed
4 changed files with 47 additions and 9 deletions
+13 -6
View File
@@ -241,15 +241,22 @@ def per_telegram(voce: dict, analisi: str, stato: str, motivi: list[str] | None
""" """
testo = testata(voce) testo = testata(voce)
if analisi.strip(): if analisi.strip():
corpo = _b(analisi.strip()) grezzo = analisi.strip()
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", corpo) # il grassetto del giornale
if stato == "sospetta": if stato == "sospetta":
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>" testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
testo += "\n\n" + corpo # (#35 C13) il taglio va sul GREZZO, PRIMA di _b (html.escape) e PRIMA di **-><b>: un
# taglio sul testo gia' composto puo' spezzare un'entita' (es. "&am") o lasciare un <b>
# 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<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % 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"<b>\1</b>", _b(grezzo)) # il grassetto del giornale
testo += "\n\n" + corpo + (taglio if tagliata else "")
else: else:
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): " testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>") + _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
if len(testo) > LIMITE_TELEGRAM:
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio
return testo return testo
+7 -3
View File
@@ -258,10 +258,14 @@ def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: date
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n" return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>") f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)" sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
corpo = html.escape(sintesi, quote=False)
tagliato = "" tagliato = ""
if len(corpo) > LIMITE_TELEGRAM: # (#35 C13) il taglio va sul testo GREZZO, PRIMA di html.escape: tagliare dopo puo' spezzare
corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]" # 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"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n" return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n"
f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>") f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>")
+15
View File
@@ -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 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 **-><b>: un '&' o
un '**grassetto**' a cavallo del punto di taglio non deve produrre un'entita' spezzata
(es. '&am') ne' un <b> 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("<b>") == m.count("</b>") # ogni <b> aperto e' chiuso
assert m.count("<i>") == m.count("</i>")
def test_un_messaggio_normale_non_viene_toccato(): def test_un_messaggio_normale_non_viene_toccato():
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", []) m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
assert "tagliato" not in m and m.endswith("Analisi breve.") assert "tagliato" not in m and m.endswith("Analisi breve.")
+12
View File
@@ -132,6 +132,18 @@ def test_telegram_porta_la_sintesi_e_dichiara_il_taglio():
assert "[… sintesi tagliata" in lunga and len(lunga) < 4096 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) + "&amp;" # 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 # ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
def _albero(root: Path) -> set[str]: def _albero(root: Path) -> set[str]: