revisione settimanale in sola lettura: revisore fable, rapporto firmato + Telegram, lunedi' 06:15 UTC

- src/live/revisione.py: materiale (CLAUDE.md, config, crontab, git, monitor_health, stati dei
  sorveglianti, 7 g di giornale e diari, ~195k caratteri), prompt con dieci regole (sola lettura, fonte
  per ogni segnalazione, solo numeri del materiale, §3 non si ripropone, idee nuove solo citando la
  memoria, gate con data e criterio), guardia sui numeri, rapporto firmato (P13), Telegram = Sintesi
  con taglio dichiarato; modello muto o risposta senza titoli -> rapporto «NON eseguita» + 🚨 (P5).
- scripts/live/revisione.py (guardia cli.valida, --secco/--no-telegram/--quiet/--giorno/--modello),
  scripts/cron_review.sh, crontab `15 6 * * 1`.
- primo giro reale: il prompt su argv moriva con `Argument list too long` senza rapporto ne' allerta ->
  prompt su stdin, ogni eccezione diventa errore dichiarato; `analista.pulisci` toglieva i titoli.
  Secondo giro: docs/revisioni/2026-09-09.md, stato sospetta (cifre in formato italiano), nessun URGENTE.
- scartati con la memoria: autoregolazione dei parametri, generazione automatica di strategie, macro
  da internet (diario 2026-09-09c, memoria 40).
- test: tests/test_revisione.py (18) + 2 in test_cli_flag; suite 1031/1031.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjj5vPEBoAJrB23P6RjKzs
This commit is contained in:
Adriano Dal Pastro
2026-09-09 22:07:50 +00:00
parent 0a2f780d3c
commit 8f2e6c5c2c
8 changed files with 899 additions and 3 deletions
+346
View File
@@ -0,0 +1,346 @@
"""Revisione settimanale in SOLA LETTURA: un revisore diverso dall'autore rilegge il sistema.
PERCHE' ESISTE (2026-09-09). Nove sorveglianti in cron guardano ognuno la propria grandezza
(venue, fee, edge, scala, USDE, VRP-f, health, balance, paper-monitor); il giornale e l'analista
leggono UN giorno. Nessuno rilegge il sistema nel suo insieme: stato, gate con le date, decisioni
vincolanti, debiti aperti, cosa e' cambiato nei diari e nei commit. La revisione con un secondo
modello (regola del 2026-09-03: chi rivede non e' chi ha scritto) ha trovato in una sessione due
conclusioni false e un look-ahead che l'autore aveva riletto tre volte. Questo modulo la schedula.
COSA FA. Raccoglie il materiale (CLAUDE.md, config, crontab, log git, monitor_health, giornale
degli ultimi giorni, diari recenti, stato dei sorveglianti), lo da' a un modello DIVERSO da quello
che scrive il codice, e salva il rapporto FIRMATO in `docs/revisioni/<data>.md`, con una sintesi su
Telegram. Il rapporto e' l'opinione di un lettore fallibile (P13): dice cosa non torna, cosa e'
maturo da decidere, cosa propone. Non e' parte del registro delle misure.
COSA NON FA, PER COSTRUZIONE. Non tocca config, libro, cron, codice, dati: l'unico file che
scrive e' il rapporto. Non esegue proposte: le scrive, e l'operatore decide. Non riapre le
decisioni vincolanti (CLAUDE.md §3) se la colonna «cosa la riapre» non e' soddisfatta, e non
propone «idee nuove» senza citare il file di memoria che ha gia' ucciso l'idea simile. Un ciclo
che si autoregola e' esattamente cio' che `scale_watch` e `venue_watch` trattano come anomalia.
Il silenzio non e' una revisione: se il modello non risponde, il rapporto lo dice e Telegram lo
dice, invece di lasciare un lunedi' vuoto che si legge come «tutto bene».
"""
from __future__ import annotations
import html
import json
import subprocess
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from src.live import analista as A
from src.live import monitor_health as MH
MODELLO = "claude-fable-5-1" # il revisore: NON il modello che scrive il codice
CADENZA = "settimanale"
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_TOTALE = 400_000
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
TITOLI = ("## Sintesi", "## Cosa non torna", "## Cosa e' maturo da decidere",
"## Proposte (nessuna azione eseguita)", "## Cosa ho letto e cosa mi mancava")
ISTRUZIONI = """Sei il REVISORE settimanale di un progetto di trading quantitativo (BTC/ETH su Deribit, libro
live con soldi veri, ~$4.500). Sei un modello DIVERSO da quello che scrive codice e analisi del
progetto: il tuo valore e' che non condividi il suo ragionamento. Leggi il materiale qui sotto e
scrivi la revisione, in italiano, per l'operatore (che decide) e per l'agente (che esegue).
REGOLE VINCOLANTI:
1. SOLA LETTURA. Non proponi comandi da eseguire ora, non scrivi codice: descrivi cosa non torna
e cosa andrebbe verificato o deciso, e da chi.
2. OGNI SEGNALAZIONE CITA LA FONTE: file e sezione (es. «CLAUDE.md §4, gate STATARB»), data della
pagina di giornale, nome del diario, hash del commit. Una segnalazione senza fonte non vale.
3. USA SOLO I NUMERI DEL MATERIALE. Puoi sottrarre e confrontare, ma ogni cifra che scrivi deve
essere rintracciabile qui sotto; un controllo automatico elenchera' quelle che non lo sono.
4. LE DECISIONI VINCOLANTI (CLAUDE.md §3) NON SI RIPROPONGONO. Puoi dire che una condizione della
colonna «cosa la riapre» sembra soddisfatta, citando il dato; non puoi consigliare di cambiare
la decisione per altri motivi.
5. UN'IDEA «NUOVA» SI PROPONE SOLO citando il file di memoria (docs/memory/…) o il paragrafo che ha
gia' ucciso l'idea piu' simile, e dicendo in che cosa questa differisce. Se non lo sai, non la
proponi: scrivi «da verificare in memoria prima di proporla».
6. I GATE HANNO UNA DATA (CLAUDE.md §4). Elenca quelli la cui data cade entro 30 giorni o e' gia'
passata, con il criterio scritto e lo stato che il materiale mostra. Non anticipare un verdetto:
un gate si decide alla sua data, coi criteri scritti prima.
7. DISTINGUI misurato da dedotto. «Non determinabile da questo materiale» e' una risposta buona.
8. NIENTE PREVISIONI di mercato, niente consigli di direzione, niente target.
9. Se qualcosa richiede l'operatore SUBITO (fondi, credenziali, un sorvegliante fermo che copre un
gate, una modifica di config non registrata), mettilo nella Sintesi con la parola URGENTE.
10. Massimo ~1.000 parole. Usa ESATTAMENTE questi titoli, in quest'ordine, e nient'altro prima:
%s
Sotto «Cosa ho letto e cosa mi mancava» di' quali fonti hai usato e quale dato ti sarebbe
servito e non c'era (e' l'elenco da cui il materiale della prossima settimana migliora).
""" % "\n ".join(TITOLI)
# ---------------------------------------------------------------- materiale
def _tronca(testo: str, n: int | None = None) -> tuple[str, bool]:
n = MAX_CHARS_VOCE if n is None else n # letto a runtime: un default e' catturato alla definizione (debito 12)
if len(testo) <= n:
return testo, False
return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True
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»)."""
try:
p = subprocess.run(args, cwd=str(cwd), capture_output=True, text=True, timeout=60)
except Exception as exc: # noqa: BLE001 — un comando di lettura che esplode si dichiara
return f"non leggibile: {type(exc).__name__}: {str(exc)[:120]}"
if p.returncode != 0:
return f"non leggibile: uscita {p.returncode}: {(p.stderr or p.stdout).strip()[:200]}"
return p.stdout
def _coda(path: Path, n: int) -> str:
if not path.exists():
return "(assente)"
righe = path.read_text().splitlines()
return "\n".join(righe[-n:]) if righe else "(vuoto)"
def _data_nel_nome(p: Path) -> date | None:
try:
return date.fromisoformat(p.name[:10])
except ValueError:
return None
def _monitor_health(root: Path, adesso: datetime) -> str:
try:
righe = MH.check_all(root, now=adesso)
except Exception as exc: # noqa: BLE001 — si dichiara, non si tace (P3)
return f"non leggibile: {type(exc).__name__}: {exc}"
out = ["monitor | stato | perche' | gate a rischio"]
for r in righe:
out.append(f"{r.get('name')} | {r.get('status')} | {r.get('why')} | {r.get('gate') or '-'}")
return "\n".join(out)
def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
"""Le voci (nome, testo, troncato) date al revisore. Tutto in SOLA LETTURA.
L'ordine e' quello in cui un lettore umano le aprirebbe: prima le regole e lo stato dichiarato,
poi cio' che gira (sorveglianti: piccoli, e per questo PRIMA delle voci grandi, cosi' il tetto
totale non li taglia mai), poi cio' che e' successo (giornale, diari).
"""
oggi = adesso.date()
voci: list[tuple[str, str]] = []
voci.append(("CLAUDE.md (stato, book, numeri, decisioni vincolanti, gate, debiti, regole)",
(root / "CLAUDE.md").read_text() if (root / "CLAUDE.md").exists() else "(assente)"))
cfg = root / "config" / "live.json"
voci.append(("config/live.json (unica autorita' dei guardrail live)",
cfg.read_text() if cfg.exists() else "(assente)"))
voci.append(("crontab installata (crontab -l)", _cmd(["crontab", "-l"], root)))
since = (oggi - timedelta(days=FINESTRA_COMMIT_G)).isoformat()
voci.append((f"git log dal {since} + stato del working tree",
_cmd(["git", "log", f"--since={since}", "--format=%h %ad %s", "--date=short"], root)
+ "\n--- git status --short ---\n" + _cmd(["git", "status", "--short"], root)))
voci.append(("monitor_health (i forward-monitor stanno registrando?)", _monitor_health(root, adesso)))
live = root / "data" / "live"
stati = {
"scale_watch_state.json": (live / "scale_watch_state.json", 999),
"scale_history.jsonl (ultime 3)": (live / "scale_history.jsonl", 3),
"vrp_f_watch/state.json": (root / "data" / "vrp_f_watch" / "state.json", 999),
"usde_watch.jsonl (ultime 3)": (live / "usde_watch.jsonl", 3),
"usde_convert.jsonl (ultime 3)": (live / "usde_convert.jsonl", 3),
"balance_watch.jsonl (ultime 2)": (live / "balance_watch.jsonl", 2),
"venue_news.jsonl (ultime 5)": (live / "venue_news.jsonl", 5),
"movimenti_dichiarati.jsonl": (live / "movimenti_dichiarati.jsonl", 50),
"equity_seen.json (watermark)": (live / "equity_seen.json", 999),
}
for nome, (p, n) in stati.items():
voci.append((f"stato {nome}", _coda(p, n)))
for i in range(FINESTRA_GIORNALE_G, 0, -1):
g = oggi - timedelta(days=i)
p = root / "docs" / "journal" / f"{g.isoformat()}.md"
if p.exists():
voci.append((f"giornale {g.isoformat()}", p.read_text()))
diari = sorted(p for p in (root / "docs" / "diary").glob("*.md")
if (_data_nel_nome(p) or date.min) >= oggi - timedelta(days=FINESTRA_DIARI_G))
for p in diari:
voci.append((f"diario {p.name}", p.read_text()))
out, tot = [], 0
for nome, testo in voci:
t, tr = _tronca(testo)
if tot + len(t) > MAX_CHARS_TOTALE:
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
tot += len(t)
out.append((nome, t, tr))
return out
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")
parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.")
return "\n".join(parti)
# ---------------------------------------------------------------- guardia
def valida(testo: str, fonte: str) -> tuple[str, list[str]]:
"""(stato, motivi) — stato in {ok, sospetta, rifiutata}. Rifiutata = non e' una revisione
(vuota o senza i titoli); sospetta = numeri non rintracciabili nel materiale, elencati."""
motivi: list[str] = []
t = testo.strip()
if not t:
return "rifiutata", ["risposta vuota"]
mancanti = [x for x in TITOLI if x not in t]
if mancanti:
return "rifiutata", [f"titoli mancanti: {', '.join(mancanti)}"]
fuori = A.numeri_non_supportati(t, fonte)
if fuori:
motivi.append(f"{len(fuori)} numeri non presenti nel materiale: " + ", ".join(fuori[:30]))
if len(fuori) > MAX_NUMERI_LIBERI:
return "sospetta", motivi
return ("sospetta" if motivi else "ok"), motivi
# ---------------------------------------------------------------- rapporto
def rapporto(testo: str | None, errore: str | None, stato: str, motivi: list[str], modello: str,
adesso: datetime, voci: list[tuple[str, str, bool]]) -> str:
testa = [f"# Revisione {CADENZA} — {adesso:%Y-%m-%d}", ""]
if errore:
testa += [f"## Revisione NON eseguita",
"",
f"Il modello `{modello}` non ha risposto: **{errore}**. Non c'e' una revisione questa "
"settimana, e questo file esiste perche' un lunedi' senza rapporto si leggerebbe "
"come «tutto bene» (P5).", ""]
corpo = []
else:
testa += [f"*Scritta il {adesso:%Y-%m-%dT%H:%M:%S}Z dal modello `{modello}`, revisore diverso "
"dall'autore del codice, in SOLA LETTURA: nessun file del repo, nessuna config, nessun "
"ordine e' stato toccato. E' l'opinione di un lettore fallibile (P13): ogni numero e "
"ogni proposta vanno verificati alla fonte prima di agire. Guardia sui numeri: "
f"**{stato}**" + (f" — {'; '.join(motivi)}" if motivi else "") + ".*", ""]
corpo = [testo.strip(), ""]
coda = ["---", "## Firma e materiale", "",
f"- revisore: `{modello}` · cadenza {CADENZA} · giornale {FINESTRA_GIORNALE_G} g · "
f"diari {FINESTRA_DIARI_G} g · commit {FINESTRA_COMMIT_G} g",
"- 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]
return "\n".join(testa + corpo + coda) + "\n"
def _sezione(testo: str, titolo: str) -> str:
if titolo not in testo:
return ""
dopo = testo.split(titolo, 1)[1]
for t in TITOLI:
if t != titolo and t in dopo:
dopo = dopo.split(t, 1)[0]
return dopo.strip()
def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: datetime,
path: Path) -> str:
"""La Sintesi, con la testata e il taglio DICHIARATO (un taglio silenzioso e' peggio di
un'assenza: si legge come un pensiero finito)."""
if errore:
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
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]"
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>")
# ---------------------------------------------------------------- modello
def interroga(prompt: str, modello: str = MODELLO, timeout: int = TIMEOUT_S) -> tuple[str, str | None]:
"""(testo, errore) dalla CLI `claude -p`, col prompt su STDIN.
Non su argv: al primo giro reale (09/09, 195k caratteri) `analista.interroga` e' morto con
`OSError: Argument list too long` PRIMA di chiamare il modello — e l'eccezione, non catturata,
ha lasciato zero rapporto e zero Telegram: il silenzio che questo modulo esiste per vietare.
Qui ogni eccezione della chiamata e' un errore RESTITUITO (P3), mai propagato.
"""
try:
p = subprocess.run(
["claude", "-p", "--model", modello, "--allowed-tools", "", "--max-turns", "1"],
input=prompt, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
return "", "CLI `claude` non trovata"
except subprocess.TimeoutExpired:
return "", f"timeout dopo {timeout}s"
except Exception as exc: # noqa: BLE001 — si restituisce, non si tace
return "", f"{type(exc).__name__}: {str(exc)[:200]}"
if p.returncode != 0:
return "", A.motivo_uscita(p.returncode, p.stdout, p.stderr)
return p.stdout.strip(), None # NON `analista.pulisci`: toglierebbe i titoli «## …» che la guardia richiede
# ---------------------------------------------------------------- orchestrazione
def scrivi(root: Path, adesso: datetime | None = None, modello: str = MODELLO, *,
secco: bool = False, telegram: bool = True, quiet: bool = False,
interroga=None, invia=None) -> dict:
"""Un giro: materiale → modello → rapporto → Telegram. Ritorna cosa e' successo.
`interroga(prompt, modello, timeout) -> (testo, errore)` e `invia(html) -> bool` sono
INIETTATI (default: `interroga` di questo modulo — prompt su stdin — e `notifier.send`) cosi'
i test non spendono e non mandano. Se uno dei due SOLLEVA, l'eccezione diventa un errore nel
rapporto: un giro che muore a meta' non deve lasciare un lunedi' vuoto. Con `secco` non si chiama il modello e non si scrive niente: si stampa la taglia.
"""
adesso = adesso or datetime.now(timezone.utc)
voci = materiale(root, adesso)
prompt = costruisci_prompt(voci, adesso)
esito = {"data": adesso.date().isoformat(), "voci": len(voci), "caratteri": len(prompt),
"modello": modello, "secco": secco}
if not quiet:
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 ''}")
if secco:
return esito
interroga = interroga or globals()["interroga"]
try:
testo, errore = interroga(prompt, modello, TIMEOUT_S)
except Exception as exc: # noqa: BLE001
testo, errore = "", f"eccezione nella chiamata al modello: {type(exc).__name__}: {str(exc)[:200]}"
stato, motivi = ("rifiutata", [errore]) if errore else valida(testo, prompt)
if not errore and stato == "rifiutata":
errore, testo = "risposta non e' una revisione: " + "; ".join(motivi), None
cartella = root / CARTELLA
cartella.mkdir(parents=True, exist_ok=True)
path = cartella / f"{adesso:%Y-%m-%d}.md"
path.write_text(rapporto(testo, errore, stato, motivi, modello, adesso, voci))
esito.update(stato=stato, errore=errore, motivi=motivi, file=str(path))
if telegram:
from src.live import notifier as N
invia = invia or (lambda h: N.send(h, tentativi=2))
try:
esito["telegram"] = bool(invia(per_telegram(testo, errore, stato, adesso, path)))
except Exception as exc: # noqa: BLE001
esito["telegram"] = False
esito["telegram_errore"] = f"{type(exc).__name__}: {str(exc)[:200]}"
if not quiet:
tg = "" if "telegram" not in esito else (" telegram: inviato" if esito["telegram"] else
f" telegram: NON inviato ({esito.get('telegram_errore', 'send False')})")
print(f" -> {stato}" + (f" ({errore})" if errore else "") + f" {path}{tg}")
return esito