diff --git a/src/live/revisione.py b/src/live/revisione.py index ed1f5be..14351d0 100644 --- a/src/live/revisione.py +++ b/src/live/revisione.py @@ -39,8 +39,16 @@ FINESTRA_GIORNALE_G = 7 # pagine del giornale lette FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale) FINESTRA_COMMIT_G = 14 # commit letti CARTELLA = Path("docs") / "revisioni" -MAX_CHARS_VOCE = 90_000 # CLAUDE.md sta sotto; il resto e' molto piu' piccolo +MAX_CHARS_VOCE = 90_000 # il resto delle voci e' molto piu' piccolo +# #37 C12. CLAUDE.md e' arrivato a 89.590 caratteri (99,5% di MAX_CHARS_VOCE, 27/09): al primo +# paragrafo aggiunto si sarebbe troncato. Tetto SUO, piu' largo — il vincolo vero resta +# MAX_CHARS_TOTALE, applicato A PARTE in `materiale()` a prescindere dal tetto di ogni voce: la +# somma dei tetti per-voce (che supera 400k) non e' il limite, lo e' il secondo giro di `_tronca`. +MAX_CHARS_CLAUDE_MD = 150_000 MAX_CHARS_TOTALE = 400_000 +SOGLIA_AVVISO_TETTO = 0.95 # avviso quando una voce supera questa frazione del SUO tetto +_MARCA_VICINO_TETTO = "[… vicino al tetto:" # marcatore condiviso: materiale() lo scrive, i + # consumatori (prompt, rapporto) lo rileggono MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre @@ -89,6 +97,12 @@ def _tronca(testo: str, n: int | None = None) -> tuple[str, bool]: return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True +def _cap_voce(nome: str) -> int: + """Tetto PER-VOCE: CLAUDE.md ne ha uno suo, piu' largo (#37 C12). Il totale + (MAX_CHARS_TOTALE) resta un vincolo A PARTE, applicato in `materiale()` a valle di questo.""" + return MAX_CHARS_CLAUDE_MD if nome.startswith("CLAUDE.md") else MAX_CHARS_VOCE + + def _cmd(args: list[str], cwd: Path) -> str: """Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo» non e' «va tutto bene»).""" @@ -175,7 +189,18 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]: out, tot = [], 0 for nome, testo in voci: - t, tr = _tronca(testo) + cap = _cap_voce(nome) + # #37 C12: avviso PRIMA di troncare, quando la voce e' vicina al SUO tetto (non ancora al + # tetto totale, sotto): la nota entra nel testo, cosi' arriva sia al prompt del revisore + # sia — via _MARCA_VICINO_TETTO — al rapporto, come [TRONCATO] oggi. Il margine + # (`+ len(nota) <= cap`) e' voluto: la nota stessa non deve MAI far scattare il + # troncamento che sta segnalando in anticipo. + if len(testo) >= SOGLIA_AVVISO_TETTO * cap: + nota = (f"\n{_MARCA_VICINO_TETTO} {len(testo):,} di {cap:,} caratteri " + f"({100 * len(testo) / cap:.0f}%)]") + if len(testo) + len(nota) <= cap: + testo = testo + nota + t, tr = _tronca(testo, cap) if tot + len(t) > MAX_CHARS_TOTALE: t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True tot += len(t) @@ -186,7 +211,8 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]: def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str: parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"] for nome, testo, tr in voci: - parti.append(f"\n=== {nome}{' [TRONCATO]' if tr else ''} ===\n{testo}\n") + tag = " [TRONCATO]" if tr else (" [VICINO AL TETTO]" if _MARCA_VICINO_TETTO in testo else "") + parti.append(f"\n=== {nome}{tag} ===\n{testo}\n") parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.") return "\n".join(parti) @@ -236,7 +262,8 @@ def rapporto(testo: str | None, errore: str | None, stato: str, motivi: list[str "- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file " "scritto e' questo", "", "| voce | caratteri | troncata |", "|---|---|---|"] - coda += [f"| {n} | {len(t):,} | {'si' if tr else ''} |" for n, t, tr in voci] + coda += [f"| {n} | {len(t):,} | " + f"{'si' if tr else ('quasi' if _MARCA_VICINO_TETTO in t else '')} |" for n, t, tr in voci] return "\n".join(testa + corpo + coda) + "\n" @@ -312,7 +339,8 @@ def scrivi(root: Path, adesso: datetime | None = None, modello: str = MODELLO, * print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, " f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else "")) for n, t, tr in voci: - print(f" {len(t):>8,} {n}{' [troncata]' if tr else ''}") + tag = " [troncata]" if tr else (" [vicino al tetto]" if _MARCA_VICINO_TETTO in t else "") + print(f" {len(t):>8,} {n}{tag}") if secco: return esito diff --git a/tests/test_revisione.py b/tests/test_revisione.py index 2917f89..3fca293 100644 --- a/tests/test_revisione.py +++ b/tests/test_revisione.py @@ -68,14 +68,65 @@ def test_i_sorveglianti_vengono_prima_delle_voci_grandi(tmp_path): def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch): + """CLAUDE.md e' la prima voce e ha un tetto SUO (MAX_CHARS_CLAUDE_MD, #37 C12): si monkeypatcha + quello, non il MAX_CHARS_VOCE generico, che non lo tocca piu'.""" r = _radice(tmp_path) (r / "CLAUDE.md").write_text("x" * 1000) - monkeypatch.setattr(R, "MAX_CHARS_VOCE", 100) + monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 100) n, t, tr = R.materiale(r, ADESSO)[0] assert tr and "[… troncato a 100 caratteri su 1,000]" in t assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO) +def test_claude_md_ha_un_tetto_suo_distinto_dal_generico(tmp_path, monkeypatch): + """#37 C12: CLAUDE.md sfiora MAX_CHARS_VOCE con un margine minimo — deve avere un tetto suo + piu' largo, cosi' un piccolo aggiornamento non lo tronca.""" + r = _radice(tmp_path) + n_chars = R.MAX_CHARS_VOCE + 1_000 # sopra il generico, ma sotto MAX_CHARS_CLAUDE_MD + assert n_chars < R.MAX_CHARS_CLAUDE_MD + (r / "CLAUDE.md").write_text("x" * n_chars) + n, t, tr = R.materiale(r, ADESSO)[0] + assert n.startswith("CLAUDE.md") + assert not tr, "sotto il tetto SUO (150k): non deve troncare, anche se supera i 90k generici" + + +def test_voce_vicina_al_tetto_e_avvisata_nel_prompt_e_nel_rapporto(tmp_path, monkeypatch): + """#37 C12: al 95% del tetto (non ancora troncata) l'avviso entra nel testo, e da li' arriva + sia al prompt del revisore sia alla tabella del rapporto — come [TRONCATO] oggi.""" + r = _radice(tmp_path) + monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 10_000) + (r / "CLAUDE.md").write_text("x" * 9_600) # 96% di 10.000, margine largo per la nota + n, t, tr = R.materiale(r, ADESSO)[0] + assert not tr, "non e' troncata: e' vicina" + assert R._MARCA_VICINO_TETTO in t + assert "[VICINO AL TETTO]" in R.costruisci_prompt([(n, t, tr)], ADESSO) + rap = R.rapporto("revisione", None, "ok", [], "claude-fable-5-1", ADESSO, [(n, t, tr)]) + assert "| quasi |" in rap + + +def test_voce_lontana_dal_tetto_non_e_avvisata(tmp_path): + voci = R.materiale(_radice(tmp_path), ADESSO) + claude_md = [t for n, t, tr in voci if n.startswith("CLAUDE.md")][0] + assert R._MARCA_VICINO_TETTO not in claude_md + + +def test_il_totale_resta_sotto_il_tetto_anche_se_la_somma_dei_tetti_lo_supera(tmp_path, monkeypatch): + """#37 C12: CLAUDE.md (150k) + il resto delle voci (90k l'una) puo' superare facilmente i 400k + di MAX_CHARS_TOTALE in somma — il tetto totale e' applicato A PARTE, a valle, e resta il vero + vincolo: non deve MAI sfondare, qualunque sia il tetto di ogni singola voce.""" + r = _radice(tmp_path) + (r / "CLAUDE.md").write_text("x" * R.MAX_CHARS_CLAUDE_MD) # al SUO tetto: 150k + j = r / "docs" / "journal" + for i in range(1, 10): # sovrascrive i giornali della finestra + g = (ADESSO - timedelta(days=i)).date().isoformat() + (j / f"{g}.md").write_text("y" * R.MAX_CHARS_VOCE) # ognuno al SUO tetto: 90k + voci = R.materiale(r, ADESSO) + tot = sum(len(t) for _, t, _ in voci) + somma_tetti = sum(R._cap_voce(n) for n, _, _ in voci) + assert somma_tetti > R.MAX_CHARS_TOTALE, "il caso e' quello che sfonderebbe se il totale non fosse a parte" + assert tot <= R.MAX_CHARS_TOTALE + + def test_il_prompt_porta_le_regole_e_la_data(tmp_path): p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO) assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p