From d9c55cd22f6ca62c0a97bd8be77093c7fe43747a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:36:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20ledger=20di=20feedback=20(resh=20feedba?= =?UTF-8?q?ck)=20=E2=80=94=20primo=20anello=20del=20loop=20di=20calibrazio?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggiunge un canale strutturato per il giudizio dell'utente su un run già persistito, append-only come il resto del ledger SQLite: oggi resh scrive la propria storia ma non la rilegge mai — senza questo, una futura calibrazione (RF, Δε) non ha dati da cui imparare. persistenza.py: - tabella `feedback` (ambito eps/patologia/patologia_mancante/nota, target+ancora per il rilievo, annotatore, ts) — append-only: una correzione è una nuova riga, mai un UPDATE. - save_feedback/list_feedback/feedback_effettivo/run_summary: validano run/target/verdetto, risolvono "l'ultima vince" per (ambito,target). - export_feedback_dataset: dataset piatto (feature del rilievo + eps + componenti + label) con n_revisioni/contraddetta per non trattare l'annotatore come un oracolo coerente — anti-overfitting fin dal dato. cli.py: sottocomando `feedback` — interattivo con ANNOTAZIONE CIECA (il giudizio di resh su una patologia si rivela solo DOPO il verdetto dell'utente, per non contaminare il segnale) + flag non interattivi (--eps/--pat/--manca), --list, --pending, --export. tests/test_feedback.py: batteria no-ML (save/list/effettivo/append-only/ validazioni/export), aggiunta alla CI. README (IT+EN): sezione Uso con i comandi feedback e la premessa che il ledger accumula per ora, la calibrazione resta un passo dichiarato non promesso. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016MnNuFahrz482cFEqCEnoq --- .github/workflows/ci.yml | 1 + README.en.md | 18 ++ README.md | 18 ++ cli.py | 192 +++++++++++++++++++ persistenza.py | 391 +++++++++++++++++++++++++++++++++++++++ tests/test_feedback.py | 189 +++++++++++++++++++ 6 files changed, 809 insertions(+) create mode 100644 tests/test_feedback.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05485a4..500c5e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,4 @@ jobs: python tests/test_postprocess_inclosura.py python tests/test_persistenza_doc.py python tests/test_trilemma_predetect.py + python tests/test_feedback.py diff --git a/README.en.md b/README.en.md index da58853..9518024 100644 --- a/README.en.md +++ b/README.en.md @@ -109,6 +109,24 @@ resh mio_testo.md (`python -m resh.cli` is equivalent to `resh` if you prefer invoking the module.) +**Feedback on a past run** (the user's judgment into the ledger, append-only — zero ML, +runs everywhere): + +```bash +resh feedback Ψ_abc123456789_001 # interactive: eps, then pathology by pathology +resh feedback Ψ_..._001 --eps troppo_basso # non-interactive +resh feedback Ψ_..._001 --pat 3 --verdetto falso_positivo +resh feedback Ψ_..._001 --list # feedback history for the run +resh feedback --pending # runs with no feedback yet +resh feedback --export # dataset → resh/data/dataset_feedback.csv +``` + +The interactive flow is **blind by construction**: it shows the pathology's type and passage, +never resh's confidence or source, until you've given your own verdict — otherwise the +feedback would echo resh's judgment instead of being an independent signal to learn from. +resh does not yet read this data back to correct itself: the ledger accumulates it toward a +future calibration (declared, not promised). + Library usage: ```python diff --git a/README.md b/README.md index 126e47c..686fd5a 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,24 @@ resh documento my_paper.md --completo --lang en (`python -m resh.cli` resta equivalente a `resh` se si preferisce invocare il modulo.) +**Feedback su un run già fatto** (giudizio dell'utente nel ledger, append-only — zero ML, +gira ovunque): + +```bash +resh feedback Ψ_abc123456789_001 # interattivo: eps, poi patologia per patologia +resh feedback Ψ_..._001 --eps troppo_basso # non interattivo +resh feedback Ψ_..._001 --pat 3 --verdetto falso_positivo +resh feedback Ψ_..._001 --list # storia di feedback del run +resh feedback --pending # run senza alcun feedback +resh feedback --export # dataset → resh/data/dataset_feedback.csv +``` + +Il flusso interattivo è **cieco per costruzione**: mostra tipo e passaggio della patologia, +mai la confidenza o la fonte di resh, finché non hai dato il tuo verdetto — altrimenti il +feedback sarebbe un'eco del giudizio di resh, non un segnale indipendente da cui imparare. +resh non rilegge ancora questi dati per correggersi: il ledger li accumula in vista di una +calibrazione futura (dichiarata, non promessa). + Uso come libreria: ```python diff --git a/cli.py b/cli.py index 7ea0ab1..fcd2ccb 100644 --- a/cli.py +++ b/cli.py @@ -21,6 +21,17 @@ python -m resh.cli report-doc [RUN_UID] [--doc HASH_PREFIX] [--out report.md] RIGENERA il report markdown dal rapporto_json salvato (zero call LLM). Senza argomenti: ultimo run. + +Sottocomando FEEDBACK (giudizio dell'utente su un run già persistito, ledger append-only): + python -m resh.cli feedback + Interattivo: verdetto su ε, poi per ogni patologia — ANNOTAZIONE CIECA + (conf/sev/fonte/confermata di resh si rivelano solo DOPO il verdetto). + python -m resh.cli feedback --eps troppo_basso|troppo_alto|ok [--nota "..."] + python -m resh.cli feedback --pat N --verdetto valida|falso_positivo [--nota "..."] + python -m resh.cli feedback --manca "descrizione" [--nota "..."] + python -m resh.cli feedback --list # storia di feedback del run + python -m resh.cli feedback --pending # run senza alcun feedback + python -m resh.cli feedback --export # dataset → resh/data/dataset_feedback.csv """ from __future__ import annotations @@ -334,12 +345,193 @@ def _curate_main(argv: list[str]) -> int: return curate_main(argv) +# ─── sottocomando feedback (giudizio utente, ledger append-only) ────────── + + +def _feedback_list_main(run_uid: str) -> int: + from . import persistenza + rows = persistenza.list_feedback(run_uid=run_uid) + if not rows: + print(f"(nessun feedback registrato per {run_uid})") + return 0 + for r in rows: + tgt = f" #{r['target']}" if r.get("target") is not None else "" + nota = f" — {r['nota']}" if r.get("nota") else "" + print(f"{r['ts_creazione']} [{r['ambito']}{tgt}] {r['verdetto']}{nota} " + f"(annotatore: {r['annotatore']})") + return 0 + + +def _feedback_pending_main() -> int: + from . import persistenza + già_visti = {r["run_uid"] for r in persistenza.list_feedback()} + runs = persistenza.list_runs() + persistenza.list_runs_documento() + pending = [r for r in runs if r["run_uid"] not in già_visti] + pending.sort(key=lambda r: r["ts_creazione"], reverse=True) + if not pending: + print("(nessun run in attesa di feedback)") + return 0 + for r in pending: + eps = r.get("eps_resh", r.get("eps_doc")) + eps_str = f"{eps:.4f}" if eps is not None else " — " + print(f"{r['run_uid']} {r['ts_creazione']} ε={eps_str}") + return 0 + + +def _feedback_interactive_main(run_uid: str) -> int: + from . import persistenza + + summary = persistenza.run_summary(run_uid) + if summary is None: + print(f"[ERRORE] run_uid non trovato: {run_uid}") + return 1 + + eps = summary["eps"] + pats = summary["patologie"] + eps_str = f"{eps:.4f}" if eps is not None else "—" + print(f"Run {run_uid} · ε = {eps_str} · {len(pats)} patologie rilevate\n") + + while True: + resp = input("ε: [o]k [a]lto (troppo alto) [b]asso (troppo basso) [s]kip → ").strip().lower() + if resp in ("o", "a", "b", "s"): + break + print(" (o/a/b/s)") + if resp != "s": + verdetto = {"o": "ok", "a": "troppo_alto", "b": "troppo_basso"}[resp] + nota = input(" nota (opzionale, invio per saltare): ").strip() or None + persistenza.save_feedback(run_uid, "eps", verdetto, nota=nota) + print(f" → registrato: eps={verdetto}\n") + + # Annotazione CIECA: mostra solo tipo + passaggio, MAI il giudizio di resh + # (conf/sev/fonte/confermata) prima del verdetto dell'utente — altrimenti + # il feedback sarebbe l'eco di resh, non un segnale indipendente. + if summary["source"] != "analisi": + print("(feedback per-patologia non disponibile sui run documentali, v1)") + elif not pats: + print("(nessuna patologia rilevata in questo run)") + else: + for i, p in enumerate(pats): + det = p.get("dettaglio", {}) or {} + passo = (det.get("match") or det.get("frase") or det.get("contesto") + or det.get("argomento") or "") + passo = str(passo).replace("\n", " ") + if len(passo) > 200: + passo = passo[:197] + "…" + print(f"[{i}] tipo={p.get('tipo')}" + (f" «{passo}»" if passo else "")) + while True: + resp = input(" [v]alida [f]also positivo [s]kip [q]uit → ").strip().lower() + if resp in ("v", "f", "s", "q"): + break + print(" (v/f/s/q)") + if resp == "q": + print("Interrotto.") + return 0 + if resp == "s": + continue + verdetto = {"v": "valida", "f": "falso_positivo"}[resp] + nota = input(" nota (opzionale): ").strip() or None + persistenza.save_feedback(run_uid, "patologia", verdetto, target=i, nota=nota) + fl2 = det.get("fallacia_l2", "-") + conf = p.get("confidence") + sev = p.get("severita") + conf_str = f"{conf:.2f}" if isinstance(conf, (int, float)) else conf + sev_str = f"{sev:.2f}" if isinstance(sev, (int, float)) else sev + print(f" → registrato: {verdetto} " + f"(resh: conf={conf_str} sev={sev_str} fonte={det.get('fonte', '-')} " + f"confermata={det.get('confermata')} fallacia_l2={fl2})\n") + + manca = input("Patologie che resh non ha rilevato? (testo libero, invio per saltare): ").strip() + if manca: + persistenza.save_feedback(run_uid, "patologia_mancante", manca) + print(" → registrata patologia mancante\n") + + print("Feedback completato.") + return 0 + + +def _feedback_main(argv: list[str]) -> int: + ap = argparse.ArgumentParser( + prog="resh.cli feedback", + description="Registra il giudizio dell'utente su un run già persistito (ledger append-only).") + ap.add_argument("run_uid", nargs="?", default=None, + help="run_uid (Ψ_...) su cui dare feedback") + ap.add_argument("--eps", type=str, default=None, + choices=["ok", "troppo_alto", "troppo_basso"], + help="verdetto sull'epsilon complessivo del run") + ap.add_argument("--pat", type=int, default=None, metavar="N", + help="indice della patologia (vedi --list o il report)") + ap.add_argument("--verdetto", type=str, default=None, + choices=["valida", "falso_positivo"], + help="verdetto sulla patologia indicata da --pat") + ap.add_argument("--manca", type=str, default=None, metavar="DESCRIZIONE", + help="segnala una patologia che resh non ha rilevato") + ap.add_argument("--nota", type=str, default=None, help="nota libera per il feedback") + ap.add_argument("--list", action="store_true", help="mostra la storia di feedback del run") + ap.add_argument("--pending", action="store_true", + help="elenca i run senza alcun feedback registrato (più recenti prima)") + ap.add_argument("--export", action="store_true", + help="esporta il dataset di training (resh/data/dataset_feedback.csv)") + args = ap.parse_args(argv) + + from . import persistenza + + if args.export: + persistenza.export_feedback_dataset() + return 0 + + if args.pending: + return _feedback_pending_main() + + if not args.run_uid: + print("[ERRORE] serve un run_uid (o --export / --pending)") + return 1 + + if args.list: + return _feedback_list_main(args.run_uid) + + agito = False + if args.eps: + try: + persistenza.save_feedback(args.run_uid, "eps", args.eps, nota=args.nota) + print(f"[OK] feedback eps={args.eps} registrato su {args.run_uid}") + except ValueError as exc: + print(f"[ERRORE] {exc}") + return 1 + agito = True + if args.pat is not None: + if not args.verdetto: + print("[ERRORE] --pat richiede --verdetto valida|falso_positivo") + return 1 + try: + persistenza.save_feedback(args.run_uid, "patologia", args.verdetto, + target=args.pat, nota=args.nota) + print(f"[OK] feedback patologia #{args.pat}={args.verdetto} registrato su {args.run_uid}") + except ValueError as exc: + print(f"[ERRORE] {exc}") + return 1 + agito = True + if args.manca: + try: + persistenza.save_feedback(args.run_uid, "patologia_mancante", args.manca, nota=args.nota) + print(f"[OK] patologia mancante registrata su {args.run_uid}") + except ValueError as exc: + print(f"[ERRORE] {exc}") + return 1 + agito = True + + if agito: + return 0 + + return _feedback_interactive_main(args.run_uid) + + _SUBCOMMANDS = { "documento": _documento_main, "obiettivo": _obiettivo_main, "runs": _runs_main, "report-doc": _report_doc_main, "curate": _curate_main, + "feedback": _feedback_main, } diff --git a/persistenza.py b/persistenza.py index f6230ba..774d461 100644 --- a/persistenza.py +++ b/persistenza.py @@ -35,6 +35,7 @@ from __future__ import annotations +import csv import datetime import hashlib import json @@ -47,6 +48,8 @@ from .schemas import RapportoResh +_DATA_DIR = Path(__file__).resolve().parent / "data" + # ─── DB schema & init ────────────────────────────────────────────────── @@ -118,6 +121,22 @@ CREATE INDEX IF NOT EXISTS idx_analisi_doc_dochash ON analisi_documento(doc_hash); CREATE INDEX IF NOT EXISTS idx_analisi_doc_ts ON analisi_documento(ts_creazione); + +CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_uid TEXT NOT NULL, -- FK logica verso analisi / analisi_documento + ambito TEXT NOT NULL, -- 'eps' | 'patologia' | 'patologia_mancante' | 'nota' + target TEXT, -- ambito='patologia': indice in patologie_strutturate + ancora TEXT, -- "tipo|fallacia_l2|span" della patologia, per verifica + verdetto TEXT NOT NULL, -- eps: ok|troppo_alto|troppo_basso + -- patologia: valida|falso_positivo + -- patologia_mancante/nota: testo libero + nota TEXT, + annotatore TEXT NOT NULL, -- chi giudica (env P3_RESH_ANNOTATORE o $USER) + ts_creazione TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_feedback_runuid ON feedback(run_uid); """ @@ -976,3 +995,375 @@ def get_run_documento( out = dict(row) out["rapporto"] = json.loads(out.pop("rapporto_json")) return out + + +# ─── feedback dell'utente (ledger append-only) ───────────────────────── +# +# Primo anello del loop di calibrazione: resh scrive la propria storia +# (save_run/save_run_documento) ma finora non la rileggeva mai. Qui si +# registra il giudizio dell'utente su un run già persistito — a livello di +# singolo rilievo (patologia) o di ε complessivo — append-only come il resto +# del ledger. `export_feedback_dataset` produce il dataset per la futura +# calibrazione (RF/Δε): quella parte resta fuori scope qui, solo cattura. + +_AMBITI_VERDETTI = { + "eps": {"ok", "troppo_alto", "troppo_basso"}, + "patologia": {"valida", "falso_positivo"}, +} +_AMBITI_LIBERI = {"patologia_mancante", "nota"} # verdetto = testo libero, no target + + +def _default_annotatore() -> str: + return (os.getenv("P3_RESH_ANNOTATORE") or os.getenv("USER") + or os.getenv("USERNAME") or "sconosciuto") + + +def _run_source(conn: sqlite3.Connection, run_uid: str) -> Optional[str]: + """`'analisi'` | `'analisi_documento'` | `None` (run inesistente).""" + if conn.execute("SELECT 1 FROM analisi WHERE run_uid=?", (run_uid,)).fetchone(): + return "analisi" + if conn.execute("SELECT 1 FROM analisi_documento WHERE run_uid=?", (run_uid,)).fetchone(): + return "analisi_documento" + return None + + +def _patologie_di_run(conn: sqlite3.Connection, run_uid: str) -> Optional[list[dict]]: + """`patologie_strutturate` del run — solo run per-testo (tabella `analisi`). + + `None` se il run non esiste in `analisi` (incluso il caso: è un run + documentale — le sue patologie sono stringhe legacy per chunk, non + indicizzabili in modo stabile; il feedback per-patologia non le copre, v1). + """ + row = conn.execute( + "SELECT rapporto_json FROM analisi WHERE run_uid=?", (run_uid,) + ).fetchone() + if row is None: + return None + rapporto = json.loads(row["rapporto_json"]) + return rapporto.get("patologie_strutturate") or [] + + +def _ancora_patologia(pat: dict) -> str: + """`"tipo|fallacia_l2|span"` — ridondanza di verifica per il `target`.""" + tipo = pat.get("tipo", "?") + det = pat.get("dettaglio", {}) or {} + fl2 = det.get("fallacia_l2", "-") + span = pat.get("span_char") + span_str = f"{span[0]}-{span[1]}" if span else "-" + return f"{tipo}|{fl2}|{span_str}" + + +def run_summary(run_uid: str, db_path: Optional[Path] = None) -> Optional[dict]: + """Riepilogo minimo di un run per la CLI di feedback. + + Ritorna `{"source": "analisi"|"analisi_documento", "eps": float|None, + "patologie": list[dict]}` — `patologie` è `[]` per i run documentali (v1: + solo feedback `eps`/`nota` lì). `None` se il run non esiste in nessuna + delle due tabelle. + """ + db = init_db(db_path) + conn = _connect(db) + try: + source = _run_source(conn, run_uid) + if source is None: + return None + if source == "analisi": + row = conn.execute( + "SELECT eps_resh FROM analisi WHERE run_uid=?", (run_uid,)).fetchone() + pats = _patologie_di_run(conn, run_uid) or [] + return {"source": source, "eps": row["eps_resh"] if row else None, "patologie": pats} + row = conn.execute( + "SELECT eps_doc FROM analisi_documento WHERE run_uid=?", (run_uid,)).fetchone() + return {"source": source, "eps": row["eps_doc"] if row else None, "patologie": []} + finally: + conn.close() + + +def save_feedback( + run_uid: str, + ambito: str, + verdetto: str, + *, + target: Optional[int] = None, + nota: Optional[str] = None, + annotatore: Optional[str] = None, + db_path: Optional[Path] = None, +) -> dict: + """Registra un feedback dell'utente su un run già persistito. + + Append-only: per correggersi si aggiunge una NUOVA riga (mai UPDATE); + `feedback_effettivo` risolve l'ultima riga per (run_uid, ambito, target). + L'incoerenza dell'utente nel tempo resta nel ledger come dato, non viene + sovrascritta — a parità con l'onestà che resh chiede ai testi. + + `ambito`: + - `"eps"`: `verdetto` in {ok, troppo_alto, troppo_basso}, nessun `target`. + - `"patologia"`: `verdetto` in {valida, falso_positivo}, `target` = indice + nella lista `patologie_strutturate` del run (solo run per-testo, v1). + - `"patologia_mancante"` / `"nota"`: `verdetto` = testo libero, nessun + `target` — righe che si ACCUMULANO (non "l'ultima vince"). + + Solleva `ValueError` su: run inesistente, ambito ignoto, verdetto fuori dal + vocabolario dell'ambito, `target` mancante/fuori range/non applicabile. + """ + if ambito not in _AMBITI_VERDETTI and ambito not in _AMBITI_LIBERI: + raise ValueError( + f"ambito sconosciuto: {ambito!r} (attesi: " + f"{sorted(_AMBITI_VERDETTI) + sorted(_AMBITI_LIBERI)})") + + if ambito != "patologia" and target is not None: + raise ValueError(f"target non applicabile per ambito={ambito!r}") + + db = init_db(db_path) + conn = _connect(db) + try: + source = _run_source(conn, run_uid) + if source is None: + raise ValueError(f"run_uid non trovato: {run_uid}") + + ancora = None + if ambito == "patologia": + if target is None: + raise ValueError("ambito='patologia' richiede `target` (indice del rilievo)") + if source != "analisi": + raise ValueError( + f"feedback per-patologia non supportato su run documentali ({run_uid}): " + "le patologie sono stringhe legacy per chunk, non indicizzabili (v1)") + pats = _patologie_di_run(conn, run_uid) or [] + if not (0 <= target < len(pats)): + raise ValueError( + f"target={target} fuori range: il run ha {len(pats)} patologie " + f"(0..{len(pats) - 1})") + ancora = _ancora_patologia(pats[target]) + + if ambito in _AMBITI_VERDETTI: + vocab = _AMBITI_VERDETTI[ambito] + if verdetto not in vocab: + raise ValueError( + f"verdetto {verdetto!r} non valido per ambito={ambito!r} " + f"(attesi: {sorted(vocab)})") + else: + if not (verdetto or "").strip(): + raise ValueError(f"ambito={ambito!r} richiede un verdetto (testo) non vuoto") + + now = datetime.datetime.now().isoformat(timespec="seconds") + chi = annotatore or _default_annotatore() + target_str = str(target) if target is not None else None + + cur = conn.execute( + """INSERT INTO feedback + (run_uid, ambito, target, ancora, verdetto, nota, annotatore, ts_creazione) + VALUES (?,?,?,?,?,?,?,?)""", + (run_uid, ambito, target_str, ancora, verdetto, nota, chi, now), + ) + conn.commit() + row_id = cur.lastrowid + finally: + conn.close() + + return { + "id": row_id, "run_uid": run_uid, "ambito": ambito, "target": target, + "ancora": ancora, "verdetto": verdetto, "nota": nota, + "annotatore": chi, "ts_creazione": now, + } + + +def list_feedback( + run_uid: Optional[str] = None, + ambito: Optional[str] = None, + db_path: Optional[Path] = None, +) -> list[dict]: + """Storia COMPLETA del feedback (append-only: nessuna riga persa/sovrascritta).""" + db = init_db(db_path) + conn = _connect(db) + try: + clauses, params = [], [] + if run_uid: + clauses.append("run_uid=?"); params.append(run_uid) + if ambito: + clauses.append("ambito=?"); params.append(ambito) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + cur = conn.execute( + f"SELECT * FROM feedback {where} ORDER BY ts_creazione ASC", params) + return [dict(r) for r in cur.fetchall()] + finally: + conn.close() + + +def feedback_effettivo(run_uid: str, db_path: Optional[Path] = None) -> dict: + """Risolve la storia append-only in stato CORRENTE del giudizio dell'utente. + + Per `ambito` a target singolo (`eps`, `patologia`) l'ultima riga per + `ts_creazione` vince; `patologia_mancante`/`nota` si accumulano (sono log, + non correzioni di un target). È la vista che il futuro calcolo di Δε + consumerà — non ancora usata per modulare ε qui. + + Ritorna `{"eps": dict|None, "patologie": {target_str: dict}, "patologie_mancanti": [...], "note": [...]}`. + """ + rows = list_feedback(run_uid=run_uid, db_path=db_path) + eps_fb: Optional[dict] = None + pat_fb: dict[str, dict] = {} + mancanti: list[dict] = [] + note: list[dict] = [] + for r in rows: # ASC: l'ultima occorrenza sovrascrive le precedenti + if r["ambito"] == "eps": + eps_fb = r + elif r["ambito"] == "patologia": + pat_fb[r["target"]] = r + elif r["ambito"] == "patologia_mancante": + mancanti.append(r) + elif r["ambito"] == "nota": + note.append(r) + return {"eps": eps_fb, "patologie": pat_fb, + "patologie_mancanti": mancanti, "note": note} + + +def export_feedback_dataset( + db_path: Optional[Path] = None, + *, + out_dir: Optional[Path] = None, +) -> Optional[Path]: + """Esporta un dataset piatto per la futura calibrazione (RF/Δε) — solo + cattura qui, il training resta un obiettivo successivo. + + `out_dir` (default `resh/data/`): directory di scrittura del CSV — separato + da `db_path` così i test possono usare un DB temporaneo SENZA scrivere + nella cartella dati reale del repo. + + Una riga per (run_uid, ambito, target) risolto — feature del rilievo + (tipo, fallacia_l2, fonte, confidence, severità, lunghezza span, + `confermata` deterministico di resh) + contesto del run (ε, componenti + `comp_*`) + `label` = verdetto CORRENTE dell'utente. `patologia_mancante` + e `nota` producono righe separate (senza feature di rilievo). + + Anti-overfitting: `n_revisioni` e `contraddetta` per riga (quante volte + l'utente ha corretto quel giudizio, e se i verdetti divergono) — il + training futuro potrà pesare meno le label instabili invece di trattare + l'annotatore come un oracolo coerente. `ts_feedback`/`annotatore` per ogni + riga abilitano split temporali e, in futuro, disaccordo inter-annotatore. + I segnali originali di resh restano come feature: la calibrazione impara + una correzione RELATIVA ad essi, mai una sostituzione silenziosa. + + Scrive `resh/data/dataset_feedback.csv` + sidecar `.meta.json` (schema e + conteggio righe, per non mischiare export di schemi diversi inosservati). + Ritorna `None` se non c'è alcun feedback registrato o nulla di esportabile + (es. patologie i cui indici non sono più validi — scartate, mai finte). + """ + from .epsilon import COMPONENTI as _EPS_COMPONENTI + + db = init_db(db_path) + conn = _connect(db) + try: + all_fb = [dict(r) for r in conn.execute( + "SELECT * FROM feedback ORDER BY ts_creazione ASC").fetchall()] + finally: + conn.close() + + if not all_fb: + print("Nessun feedback registrato.") + return None + + storia: dict[tuple, list[dict]] = {} + for r in all_fb: + key = (r["run_uid"], r["ambito"], r["target"]) + storia.setdefault(key, []).append(r) + + def _riga_base() -> dict: + row = { + "tipo": "", "fallacia_l2": "", "fonte": "", + "confidence": None, "severita": None, "span_len": None, + "confermata_resh": None, "eps": None, + } + for k in _EPS_COMPONENTI: + row[f"comp_{k}"] = None + return row + + rows: list[dict] = [] + conn = _connect(db) + try: + for (run_uid, ambito, target), hist in storia.items(): + ultimo = hist[-1] # ASC → l'ultimo vince + n_rev = len(hist) + contraddetta = int(len({h["verdetto"] for h in hist}) > 1) + + if ambito in ("patologia_mancante", "nota"): + # log, non correzioni: ogni riga della storia resta un record + for h in hist: + row = _riga_base() + row.update({ + "run_uid": run_uid, "ambito": ambito, + "label": h["verdetto"], "nota": h.get("nota") or "", + "n_revisioni": 1, "contraddetta": 0, + "ts_feedback": h["ts_creazione"], "annotatore": h["annotatore"], + }) + rows.append(row) + continue + + run_row = conn.execute( + "SELECT eps_resh, componenti_epsilon FROM analisi WHERE run_uid=?", + (run_uid,)).fetchone() + comp = json.loads(run_row["componenti_epsilon"]) if run_row else {} + row = _riga_base() + row["run_uid"] = run_uid + row["ambito"] = ambito + row["eps"] = run_row["eps_resh"] if run_row else None + for k in _EPS_COMPONENTI: + row[f"comp_{k}"] = comp.get(k) + + if ambito == "patologia": + pats = _patologie_di_run(conn, run_uid) or [] + idx = int(target) + if not (0 <= idx < len(pats)): + continue # run/patologie cambiati da allora: scarta, non inventa + pat = pats[idx] + det = pat.get("dettaglio", {}) or {} + span = pat.get("span_char") + row["tipo"] = pat.get("tipo") + row["fallacia_l2"] = det.get("fallacia_l2", "") + row["fonte"] = det.get("fonte", "") + row["confidence"] = pat.get("confidence") + row["severita"] = pat.get("severita") + row["span_len"] = (span[1] - span[0]) if span else None + row["confermata_resh"] = det.get("confermata") + + row["label"] = ultimo["verdetto"] + row["nota"] = ultimo.get("nota") or "" + row["n_revisioni"] = n_rev + row["contraddetta"] = contraddetta + row["ts_feedback"] = ultimo["ts_creazione"] + row["annotatore"] = ultimo["annotatore"] + rows.append(row) + finally: + conn.close() + + if not rows: + print("Nessun dato esportabile (patologie/target non più validi?).") + return None + + fieldnames = list(rows[0].keys()) + for r in rows: + for k in r: + if k not in fieldnames: + fieldnames.append(k) + + target_dir = Path(out_dir) if out_dir is not None else _DATA_DIR + target_dir.mkdir(parents=True, exist_ok=True) + out_csv = target_dir / "dataset_feedback.csv" + with open(out_csv, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + meta = { + "generato": datetime.datetime.now().isoformat(timespec="seconds"), + "righe": len(rows), + "colonne": fieldnames, + "comp_keys": list(_EPS_COMPONENTI), + } + meta_file = out_csv.with_suffix(".meta.json") + meta_file.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + + print(f"Dataset feedback esportato: {out_csv.absolute()}") + print(f" righe: {len(rows)} | colonne: {len(fieldnames)}") + print(f" meta: {meta_file.absolute()}") + return out_csv diff --git a/tests/test_feedback.py b/tests/test_feedback.py new file mode 100644 index 0000000..ba96058 --- /dev/null +++ b/tests/test_feedback.py @@ -0,0 +1,189 @@ +"""Batteria di contrasto — feedback (ledger append-only, giudizio utente). + +Zero LLM, zero modelli: costruisce un `RapportoResh` a mano, lo persiste in un +DB temporaneo, ed esercita `save_feedback`/`list_feedback`/`feedback_effettivo`/ +`export_feedback_dataset` end-to-end — incluse le validazioni e la semantica +append-only (una correzione è una NUOVA riga, mai un UPDATE). + +Uso: `python tests/test_feedback.py` (exit 1 se regressione) +""" + +from __future__ import annotations + +import csv +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from resh.schemas import ( + AutoritaCriteri, Patologia, PremessaAnalisi, RapportoResh, Teleologia, + TipoPatologia, +) + + +def _rapporto_minimo() -> RapportoResh: + pats = [ + Patologia( + tipo=TipoPatologia.FALLACIA_LOGICA, severita=0.6, confidence=0.55, + span_char=(10, 40), + dettaglio={"fallacia_l2": "petitio_principii", "fonte": "regex_it", + "match": "ovviamente vero", "confermata": False}, + ), + Patologia( + tipo=TipoPatologia.NON_SEQUITUR, severita=0.9, confidence=0.85, + span_char=(50, 90), + dettaglio={"argomento": "premessa debole", "tesi": "conclusione forte"}, + ), + ] + return RapportoResh( + testo="Testo di prova per il feedback.", + premesse=PremessaAnalisi(esplicite=["p1"], score=0.5), + inventario=[], + verifiche=[], + teleologia=Teleologia(obiettivo_dichiarato="dimostrare X", + obiettivo_latente=None, coerenza=0.8), + autorita=AutoritaCriteri(fonte="sconosciuta", expertise=False, credibilita=0.65), + eps_resh=0.6234, + patologie=[p.as_message() for p in pats], + yaml_output={"backend": {"annotazione": "fallback", "fuzzy": "fallback"}}, + patologie_strutturate=pats, + componenti_epsilon={"validita_formale": 0.7, "assenza_fallacie": 0.6}, + ) + + +def main() -> int: + from resh.persistenza import ( + export_feedback_dataset, feedback_effettivo, list_feedback, + run_summary, save_feedback, save_run, + ) + + print("=" * 66) + print("Batteria di contrasto — feedback (ledger append-only)") + print("=" * 66) + + errori: list[str] = [] + tmp_dir = Path(tempfile.mkdtemp(prefix="resh_feedback_")) + db_path = tmp_dir / "feedback.db" + testo_path = tmp_dir / "prova.md" + testo_path.write_text("Testo di prova per il feedback.", encoding="utf-8") + + try: + rapporto = _rapporto_minimo() + esito = save_run(rapporto, file_path=testo_path, db_path=db_path) + run_uid = esito["run_uid"] + + # ── run_summary: esistenza + eps + patologie ──────────────────── + summ = run_summary(run_uid, db_path=db_path) + if summ is None or summ["source"] != "analisi" or len(summ["patologie"]) != 2: + errori.append(f"run_summary inatteso: {summ}") + if run_summary("Ψ_nonexistent_999", db_path=db_path) is not None: + errori.append("run_summary: atteso None per run inesistente") + + # ── 1) feedback eps + patologia valida ────────────────────────── + save_feedback(run_uid, "eps", "troppo_basso", nota="il sillogismo regge", db_path=db_path) + fb = save_feedback(run_uid, "patologia", "falso_positivo", target=0, db_path=db_path) + if not fb["ancora"] or "petitio_principii" not in fb["ancora"]: + errori.append(f"ancora patologia inattesa: {fb['ancora']!r}") + + eff = feedback_effettivo(run_uid, db_path=db_path) + if eff["eps"] is None or eff["eps"]["verdetto"] != "troppo_basso": + errori.append("feedback_effettivo: eps non risolto correttamente") + if "0" not in eff["patologie"] or eff["patologie"]["0"]["verdetto"] != "falso_positivo": + errori.append("feedback_effettivo: patologia #0 non risolta correttamente") + + # ── 2) correzione: append-only, l'ultima riga vince ───────────── + save_feedback(run_uid, "patologia", "valida", target=0, nota="mi correggo", db_path=db_path) + storia = list_feedback(run_uid=run_uid, ambito="patologia", db_path=db_path) + if len(storia) != 2: + errori.append(f"list_feedback: attese 2 righe (append-only), trovate {len(storia)}") + eff2 = feedback_effettivo(run_uid, db_path=db_path) + if eff2["patologie"]["0"]["verdetto"] != "valida": + errori.append("feedback_effettivo: la correzione non ha vinto sull'ultima riga") + + # ── 3) validazioni ────────────────────────────────────────────── + casi_invalidi = [ + ("run inesistente", + lambda: save_feedback("Ψ_nonexistent_999", "eps", "ok", db_path=db_path)), + ("target fuori range", + lambda: save_feedback(run_uid, "patologia", "valida", target=99, db_path=db_path)), + ("verdetto fuori vocabolario", + lambda: save_feedback(run_uid, "eps", "boh", db_path=db_path)), + ("ambito ignoto", + lambda: save_feedback(run_uid, "non_esiste", "x", db_path=db_path)), + ("target non applicabile per eps", + lambda: save_feedback(run_uid, "eps", "ok", target=0, db_path=db_path)), + ] + for label, fn in casi_invalidi: + try: + fn() + errori.append(f"atteso ValueError per: {label}") + except ValueError: + pass + + # ── 4) patologia_mancante + nota (log, non correzioni) ────────── + save_feedback(run_uid, "patologia_mancante", "petitio al §2 non rilevata", db_path=db_path) + save_feedback(run_uid, "nota", "run interessante per calibrazione", db_path=db_path) + + # ── 5) export dataset (out_dir = tmp, NON la resh/data/ reale) ── + out_csv = export_feedback_dataset(db_path=db_path, out_dir=tmp_dir) + if out_csv is None or not out_csv.exists(): + errori.append("export_feedback_dataset: CSV non prodotto") + else: + with out_csv.open(encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + + righe_pat = [r for r in rows if r["ambito"] == "patologia" and r["run_uid"] == run_uid] + if len(righe_pat) != 1: + errori.append(f"export: attesa 1 riga patologia, trovate {len(righe_pat)}") + else: + r = righe_pat[0] + if r["label"] != "valida": + errori.append(f"export: label attesa 'valida' (ultimo verdetto), trovata {r['label']!r}") + if r["n_revisioni"] != "2": + errori.append(f"export: n_revisioni atteso '2', trovato {r['n_revisioni']!r}") + if r["contraddetta"] != "1": + errori.append(f"export: contraddetta atteso '1', trovato {r['contraddetta']!r}") + if r["fallacia_l2"] != "petitio_principii": + errori.append(f"export: fallacia_l2 attesa 'petitio_principii', trovata {r['fallacia_l2']!r}") + + mancanti = [r for r in rows if r["ambito"] == "patologia_mancante"] + if len(mancanti) != 1: + errori.append(f"export: attesa 1 riga patologia_mancante, trovate {len(mancanti)}") + + meta_file = out_csv.with_suffix(".meta.json") + if not meta_file.exists(): + errori.append("export: sidecar .meta.json mancante") + + # ── feedback per-patologia su run documentale: non supportato v1 ─ + try: + save_feedback("Ψ_finto12345678_D001", "patologia", "valida", target=0, db_path=db_path) + errori.append("atteso ValueError per feedback patologia su run inesistente/documentale") + except ValueError: + pass + + finally: + try: + for f in tmp_dir.glob("*"): + f.unlink() + except OSError: + pass + try: + tmp_dir.rmdir() + except OSError: + pass + + print("-" * 66) + if errori: + for e in errori: + print(f" FAIL {e}") + print(f"REGRESSIONE: {len(errori)} problema/i") + return 1 + print("OK — feedback: save/list/effettivo/append-only/validazioni/export tutti verdi") + return 0 + + +if __name__ == "__main__": + sys.exit(main())