analista di bordo: un modello scrive la prosa del giorno, in un campo suo
Aggiunto il quarto livello della pagina, tenuto separato dagli altri tre: numeri -> misurati dal feed e dal DB Lettura -> regole deterministiche, ognuna col suo id Analisi -> questo: prosa di un modello, che puo' sbagliare Nota -> l'operatore NON scrive dentro `nota`, che era la richiesta letterale: quel campo e' dell'operatore, ed e' cio' che a rileggere il giornale fra sei mesi permette di sapere chi ha scritto cosa. L'agente ha `analisi`, marcato col modello, con l'ora e con l'esito del controllo sui numeri. Gira via `claude -p` (verificato con env -i che risponda nell'ambiente nudo di cron), una chiamata al giorno sul giorno CHIUSO, tolte le tool. Tre guardie, una per ogni modo in cui una prosa generata rovina un registro: - NUMERO INVENTATO: numeri_non_supportati() estrae ogni cifra dall'analisi e verifica che compaia in cio' che il modello ha ricevuto. Oltre tre numeri liberi l'analisi e' RIFIUTATA e la pagina resta senza. E' un controllo debole per costruzione, e lo dichiara: prende l'invenzione, non il ragionamento sbagliato. - COMMENTO DI SE': senza_analisi() toglie dalla pagina la sezione dell'agente prima di dargliela. Al primo giro reale il modello aveva letto la propria uscita precedente e prodotto un paragrafo sull'avviso che si era preso il giorno prima — un ciclo di retroazione che in poche settimane avrebbe riempito il giornale di meta-commento, e che nessun controllo automatico puo' distinguere da prosa valida. - ANALISI DI IERI SPACCIATA PER OGGI: se il modello non risponde, la pagina resta VUOTA e il perche' viene registrato (stato + motivo). Il silenzio non diventa continuita'. Corretto anche un falso positivo mio: il tripwire validava sulla sola pagina mentre il prompt include anche il blocco storico, quindi bocciava un'equity vera. Una guardia piu' stretta del contratto produce allarmi che si impara a ignorare. Ogni guardia ha un test in entrambe le direzioni. 695 test passano. Strategia, pesi, config INVARIATI. Nessun ordine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""L'agente che scrive l'analisi giornaliera — e i limiti dentro cui puo' scrivere.
|
||||
|
||||
PERCHE' UN CAMPO SUO E NON `nota`
|
||||
---------------------------------
|
||||
`nota` e' dell'operatore. Se un agente ci scrivesse dentro, fra sei mesi rileggendo il giornale
|
||||
non si distinguerebbe piu' *chi* ha scritto cosa — che e' esattamente la distinzione per cui il
|
||||
giornale esiste. L'agente ha il campo `analisi`, marcato col modello e con l'ora.
|
||||
|
||||
TRE LIVELLI, TENUTI SEPARATI IN PAGINA
|
||||
--------------------------------------
|
||||
numeri -> misurati dal feed e dal DB
|
||||
Lettura -> regole dichiarate, deterministiche, ognuna col suo id
|
||||
Analisi -> questo modulo: prosa di un modello, che puo' sbagliare
|
||||
Nota -> l'operatore
|
||||
|
||||
IL RISCHIO, E LA GUARDIA
|
||||
------------------------
|
||||
Il modo in cui una prosa generata rovina un registro non e' l'opinione sbagliata: e' il NUMERO
|
||||
inventato, che a rileggerlo sembra una misura. `numeri_non_supportati()` estrae ogni numero
|
||||
dall'analisi e verifica che compaia nella pagina data in pasto al modello. Oltre
|
||||
`MAX_NUMERI_LIBERI` l'analisi viene RIFIUTATA e si tiene la precedente.
|
||||
E' un controllo debole per costruzione (un numero giusto copiato nel posto sbagliato lo passa):
|
||||
serve contro l'invenzione, non contro il ragionamento sbagliato.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
MODELLO_DEFAULT = "claude-sonnet-5"
|
||||
MAX_PAROLE = 220
|
||||
MAX_NUMERI_LIBERI = 3
|
||||
TIMEOUT_S = 300
|
||||
|
||||
ISTRUZIONI = """Sei l'analista di bordo di un libro di trading quantitativo su BTC/ETH.
|
||||
Scrivi l'analisi di UNA giornata, in italiano, per un lettore che rileggera' questa pagina fra
|
||||
mesi per capire cosa stava succedendo.
|
||||
|
||||
REGOLE VINCOLANTI:
|
||||
1. Usa SOLO i numeri presenti nella pagina qui sotto. Non calcolarne di nuovi, non stimare, non
|
||||
arrotondare in modo diverso. Ogni cifra che scrivi deve comparire nella pagina.
|
||||
2. NON prevedere. Niente "probabilmente salira'", niente target, niente consigli operativi.
|
||||
3. NON ripetere la sezione Lettura: quella e' gia' scritta. Aggiungi cio' che le regole non
|
||||
possono vedere — cosa e' CAMBIATO rispetto ai giorni precedenti, cosa e' insolito, quale
|
||||
lettura alternativa dello stesso dato sarebbe altrettanto compatibile.
|
||||
4. Se una cosa non e' determinabile dai dati dati, dillo: "non misurabile qui" e' una risposta
|
||||
accettabile e preferibile a un'inferenza.
|
||||
5. Se noti qualcosa che merita un controllo umano, dillo in una frase, senza drammatizzare.
|
||||
6. Massimo %d parole. Prosa, niente elenchi puntati, niente titoli.
|
||||
|
||||
Il libro e' TP01 (trend long-flat, 75%%) + SKH01 (breakout, 25%%) su Deribit, capitale ~$635.
|
||||
Il progetto e' esplicitamente scettico: il suo campione e' corto e il P&L non distingue ancora
|
||||
l'edge dalla fortuna. Scrivere con quella consapevolezza e' parte del compito.
|
||||
""" % MAX_PAROLE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- guardia sui numeri
|
||||
|
||||
_NUM = re.compile(r"-?\d[\d.,]*")
|
||||
|
||||
|
||||
def _normalizza(tok: str) -> str:
|
||||
t = tok.replace(",", "").rstrip(".").lstrip("-").lstrip("0") or "0"
|
||||
if "." in t:
|
||||
t = t.rstrip("0").rstrip(".") or "0"
|
||||
return t
|
||||
|
||||
|
||||
def numeri_di(testo: str) -> set[str]:
|
||||
return {_normalizza(m.group()) for m in _NUM.finditer(testo)}
|
||||
|
||||
|
||||
def numeri_non_supportati(analisi: str, fonte: str) -> list[str]:
|
||||
"""Numeri che compaiono nell'analisi e NON nella pagina data al modello.
|
||||
|
||||
Tollera i numeri piccoli fino a 31 (giorni, conteggi, «2 su 3»): non sono cifre che possano
|
||||
fabbricare una misura, e includerli produrrebbe soprattutto falsi positivi.
|
||||
"""
|
||||
ok = numeri_di(fonte)
|
||||
fuori = []
|
||||
for t in sorted(numeri_di(analisi) - ok):
|
||||
try:
|
||||
if abs(float(t)) <= 31 and "." not in t:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
fuori.append(t)
|
||||
return fuori
|
||||
|
||||
|
||||
def valida(analisi: str, fonte: str) -> tuple[str, list[str]]:
|
||||
"""Ritorna (stato, motivi). Stato in {ok, sospetta, rifiutata}."""
|
||||
motivi: list[str] = []
|
||||
testo = analisi.strip()
|
||||
if not testo:
|
||||
return "rifiutata", ["risposta vuota"]
|
||||
n_par = len(testo.split())
|
||||
if n_par > MAX_PAROLE * 1.5:
|
||||
motivi.append(f"troppo lunga ({n_par} parole contro un tetto di {MAX_PAROLE})")
|
||||
fuori = numeri_non_supportati(testo, fonte)
|
||||
if fuori:
|
||||
motivi.append("numeri non presenti nella pagina: " + ", ".join(fuori))
|
||||
if len(fuori) > MAX_NUMERI_LIBERI:
|
||||
return "rifiutata", motivi
|
||||
return ("sospetta" if motivi else "ok"), motivi
|
||||
|
||||
|
||||
def pulisci(testo: str) -> str:
|
||||
"""Toglie titoli e recinti di codice: l'analisi entra in una sezione che ha gia' il suo."""
|
||||
righe = [r for r in testo.strip().splitlines() if not r.strip().startswith(("#", "```"))]
|
||||
return "\n".join(righe).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- chiamata al modello
|
||||
|
||||
def senza_analisi(md: str) -> str:
|
||||
"""Toglie la sezione `## Analisi (agente)` dalla pagina prima di darla al modello.
|
||||
|
||||
Senza questo il modello legge la PROPRIA uscita del giro precedente e la commenta: al primo
|
||||
giro reale ha prodotto un paragrafo sull'avviso del tripwire che si era preso il giorno prima.
|
||||
E' un ciclo di retroazione — ogni giorno un po' piu' di meta-commento e un po' meno analisi —
|
||||
e si vede solo guardando l'uscita, perche' nessun controllo puo' distinguerlo da prosa valida.
|
||||
La sezione `## Nota` resta: quella e' contesto dell'operatore, ed e' utile che la legga.
|
||||
"""
|
||||
fuori, salta = [], False
|
||||
for r in md.splitlines():
|
||||
if r.startswith("## "):
|
||||
salta = r.strip().startswith("## Analisi")
|
||||
if not salta:
|
||||
fuori.append(r)
|
||||
return "\n".join(fuori)
|
||||
|
||||
|
||||
def costruisci_prompt(pagina: str, storico: str) -> str:
|
||||
pagina = senza_analisi(pagina)
|
||||
return (f"{ISTRUZIONI}\n\n"
|
||||
f"=== PAGINA DI OGGI ===\n{pagina}\n\n"
|
||||
f"=== GIORNI PRECEDENTI (per vedere cosa e' cambiato) ===\n{storico}\n\n"
|
||||
f"Scrivi ora l'analisi, e nient'altro.")
|
||||
|
||||
|
||||
def interroga(prompt: str, modello: str = MODELLO_DEFAULT,
|
||||
timeout: int = TIMEOUT_S) -> tuple[str, str | None]:
|
||||
"""Ritorna (testo, errore). L'errore si RESTITUISCE, non si ingoia: senza, il giorno che
|
||||
l'agente smette di rispondere il giornale mostrerebbe l'analisi di ieri come se fosse di oggi.
|
||||
"""
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["claude", "-p", prompt, "--model", modello, "--allowed-tools", "", "--max-turns", "1"],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
except FileNotFoundError:
|
||||
return "", "CLI `claude` non trovata"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", f"timeout dopo {timeout}s"
|
||||
if p.returncode != 0:
|
||||
return "", f"uscita {p.returncode}: {(p.stderr or '').strip()[:200]}"
|
||||
return pulisci(p.stdout), None
|
||||
+15
-2
@@ -152,6 +152,8 @@ def costruisci(con, giorno: date) -> dict:
|
||||
|
||||
|
||||
def salva(con, voce: dict, nota: str | None = None) -> None:
|
||||
"""Ricalcola le sezioni misurate. NON tocca ne' `nota` (operatore) ne' `analisi` (agente):
|
||||
sono le uniche due cose in pagina che un ricalcolo non puo' rigenerare."""
|
||||
esistente = con.execute("SELECT nota FROM journal WHERE giorno=?", (voce["giorno"],)).fetchone()
|
||||
# la nota dell'operatore NON viene mai sovrascritta da un ricalcolo automatico
|
||||
nota_finale = nota if nota is not None else (esistente["nota"] if esistente else "")
|
||||
@@ -363,8 +365,9 @@ def _n(v, fmt="{:+.2f}", vuoto="n/d"):
|
||||
|
||||
def rendi_markdown(con, voce: dict) -> str:
|
||||
g, m, lb, p, s = voce["giorno"], voce["mercato"], voce["libro"], voce["pnl"], voce["salute"]
|
||||
nota = con.execute("SELECT nota FROM journal WHERE giorno=?", (g,)).fetchone()
|
||||
nota = nota["nota"] if nota else ""
|
||||
riga = con.execute("SELECT * FROM journal WHERE giorno=?", (g,)).fetchone()
|
||||
nota = (riga["nota"] if riga else "") or ""
|
||||
an = dict(riga) if riga else {}
|
||||
L = [f"# Giornale di bordo — {g}", "",
|
||||
f"*Scritto {voce['ts_scritto']}. Numeri misurati; nessuna interpretazione automatica.*", "",
|
||||
"## Mercato", "",
|
||||
@@ -425,6 +428,16 @@ def rendi_markdown(con, voce: dict) -> str:
|
||||
"giudizio: quello sta nella Nota.*", ""]
|
||||
icona = {"info": "", "nota": "📌 ", "attenzione": "⚠️ "}
|
||||
L += [f"- `[{r['id']}]` {icona[r['livello']]}{r['testo']}" for r in righe] or ["- *(nessuna regola accesa)*"]
|
||||
testo = (an.get("analisi") or "").strip()
|
||||
if testo:
|
||||
stato = an.get("analisi_stato") or "?"
|
||||
cap = (f"*Scritta da `{an.get('analisi_modello')}` il {an.get('analisi_ts')}. "
|
||||
f"Prosa di un modello: puo' sbagliare, e non e' una misura. "
|
||||
f"Controllo sui numeri: **{stato}**.*")
|
||||
L += ["", "## Analisi (agente)", "", cap, ""]
|
||||
if an.get("analisi_motivi"):
|
||||
L += [f"> ⚠️ {an['analisi_motivi']}", ""]
|
||||
L += [testo]
|
||||
L += ["", "## Nota", "", (nota if nota.strip() else "*(vuota — campo dell'operatore)*"), ""]
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
@@ -97,6 +97,26 @@ CREATE TABLE IF NOT EXISTS journal (
|
||||
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
||||
"""
|
||||
|
||||
# Colonne aggiunte dopo la prima versione dello schema. sqlite non ha ADD COLUMN IF NOT EXISTS:
|
||||
# si guarda il PRAGMA. `analisi` e' dell'AGENTE, `nota` resta dell'operatore — due campi, perche'
|
||||
# a rileggere il giornale fra sei mesi si deve sapere chi ha scritto cosa.
|
||||
COLONNE_TARDIVE = {
|
||||
"journal": [("analisi", "TEXT NOT NULL DEFAULT ''"),
|
||||
("analisi_modello", "TEXT"),
|
||||
("analisi_ts", "TEXT"),
|
||||
("analisi_stato", "TEXT"),
|
||||
("analisi_motivi", "TEXT")],
|
||||
}
|
||||
|
||||
|
||||
def _migra(con) -> None:
|
||||
for tabella, colonne in COLONNE_TARDIVE.items():
|
||||
presenti = {r[1] for r in con.execute(f"PRAGMA table_info({tabella})")}
|
||||
for nome, tipo in colonne:
|
||||
if nome not in presenti:
|
||||
con.execute(f"ALTER TABLE {tabella} ADD COLUMN {nome} {tipo}")
|
||||
con.commit()
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# parsing — puro, nessun I/O: prende testo, ritorna dati
|
||||
@@ -278,6 +298,7 @@ def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
|
||||
con = sqlite3.connect(path)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.executescript(SCHEMA)
|
||||
_migra(con)
|
||||
return con
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user