Vai al contenuto

Guide

Tutorial: generazione di testo con un giudice a rubrica

Valuta una funzione che scrive testo libero, qui un riassuntore di ticket, con controlli di formato e un giudice a rubrica; misura quel giudice rispetto alle etichette di una persona prima che possa decidere qualcosa; poi confronta una modifica reale. Il giudice gira su questa macchina senza modello e senza rete, e un passo facoltativo lo sostituisce con un modello reale.

Che cosa costruirai

Un riassuntore che trasforma un ticket di assistenza in una o due frasi. "Buono" è un giudizio, non una corrispondenza di stringhe, quindi il successo del compito è deciso da un giudice LLM con una rubrica: il riassunto riporta i fatti di cui un operatore ha bisogno? Due valutatori deterministici controllano il formato, che non richiede un riferimento. Termini come caso, esecuzione, metrica, giudice e gate sono definiti in Concetti.

La stessa forma si adatta all'estrazione o a qualsiasi altra generazione: una funzione restituisce testo in un dizionario, il riferimento dice che cosa deve contenere una buona risposta, e una rubrica dice come decidere.

Prerequisiti

  • Python 3.11 o successivo, e Oloproof in un ambiente virtuale:
python3 -m venv .venv
. .venv/bin/activate
pip install oloproof
  • Il progetto di esempio, incluso nel pacchetto. Copialo in una nuova directory e lavora lì:
oloproof init --example generation ticket-summaries
cd ticket-summaries
  • La porta 8799 libera per il giudice sostitutivo (altrimenti cambiala in entrambi i punti).

Ogni passo fino a "Facoltativo: un modello reale come giudice" è offline e deterministico: nessuna chiave API, nessun account di provider, nessun costo.

I file

ticket-summaries/
  app.py                        the summariser under test (baseline)
  app_v2.py                     the candidate change
  judge_server.py               a stand-in judge speaking the OpenAI API on 127.0.0.1
  rubrics/covers_facts.md       the judge's rubric
  oloproof.yaml                 the suite
  release.yaml                  rules for a run
  compare.yaml                  a rule for a comparison
  data/tickets.jsonl            20 cases
  labels/reviewer_verdicts.csv  one person's verdicts on the baseline's summaries
  fill_labels.py                copies those verdicts into a labelling sheet

Esegui ogni comando da ticket-summaries/.

Il giudice sostitutivo, e ciò che non è

Un giudice a rubrica è un valutatore che invia un prompt (la rubrica, l'input del caso, il suo expected e l'output) a un modello e ne rilegge {"pass": true|false, "rationale": "..."}. Oloproof parla con qualsiasi server che implementi la chat API di OpenAI, e un server su localhost non richiede chiavi.

judge_server.py è un server di questo tipo, ma non è un modello. Fa passare un riassunto solo quando contiene ogni frase elencata sotto must_mention nell'expected del caso, senza badare a maiuscole e minuscole. È una regola fissa, quindi il tutorial dà gli stessi numeri su ogni macchina. Non può accorgersi di un fatto inventato, cosa che a un vero giudice basato su modello viene chiesta. Avvialo in un secondo terminale e lascialo in esecuzione:

python judge_server.py --port 8799
stand-in judge on http://127.0.0.1:8799/v1

L'applicazione e il suo adattatore

# app.py
@system(name="ticket-summariser", version="first-sentence")
def summarise(case: dict[str, Any]) -> dict[str, str]:
    return {"summary": sentences(str(case["ticket"]))[0]}

L'adattatore per un'applicazione Python è la funzione: riceve l'input del caso e restituisce un dizionario. Per il tuo generatore, chiama al suo interno il tuo modello o la tua catena e restituisci il testo sotto una chiave. Oloproof la chiama una volta per caso e mette in cache l'output in base al sorgente della funzione e alla version dichiarata; non gestisce il tuo client del modello, i prompt o lo stato. Elenca i file che la funzione legge, come un template di prompt, sotto system.code_paths.

Il dataset

{"id":"t01","input":{"ticket":"Hello. Order 1042 arrived with a cracked screen. I would like a replacement, not a refund."},"expected":{"must_mention":["1042","cracked","replacement"]}}
{"id":"t06","input":{"ticket":"Please cancel my subscription at the end of this month. I am moving abroad."},"expected":{"must_mention":["cancel","end of this month"]}}

input è ciò che la funzione riceve. expected è il riferimento che il giudice legge: qui una lista di fatti che il riassunto deve riportare, non un riassunto di riferimento completo, perché molti riassunti diversi sono corretti. L'output per t01 è {"summary": "Hello."}.

Scegliere i valutatori

version: 1
project: ticket-summaries
dataset: data/tickets.jsonl
system:
  name: ticket-summariser
  version: first-sentence
  callable: app:summarise
  timeout_s: 30
evaluators:
  - type: json_schema
    criterion: format_valid
    field: null
    schema:
      type: object
      required: [summary]
      properties:
        summary: {type: string, minLength: 1}
      additionalProperties: false
  - type: regex
    criterion: short_enough
    field: summary
    pattern: '^.{1,160}$'
    pass_if: match
  - type: rubric_judge
    criterion: covers_facts
    provider: openai_compatible
    model: stand-in-judge
    base_url: http://127.0.0.1:8799/v1
    rubric_file: rubrics/covers_facts.md
CriterioValutatoreRichiede expectedMisura
format_validjson_schemanoformato: un unico campo stringa non vuoto
short_enoughregexnoformato: al massimo 160 caratteri
covers_factsrubric_judgesìsuccesso del compito, come lo definisce la rubrica

Hello. supera entrambi i controlli di formato. Solo il giudice dice che è un riassunto inutile. Un giudice può anche funzionare senza riferimento: una rubrica come "PASS if the summary contains no greeting" legge solo l'input e l'output, e un caso senza expected viene comunque giudicato. Ciò che allora non può fare è verificare i fatti rispetto a una risposta di cui ti fidi.

La rubrica:

PASS when the summary states every fact listed under must_mention in the expected answer, in
words a support agent would recognise, and adds nothing the ticket does not say.
FAIL when any listed fact is missing, changed or contradicted.

La policy

version: 1
confidence_level: 0.95
block_on: [FAIL, INSUFFICIENT_EVIDENCE, MANUAL_REVIEW]
require_validated_evaluators: true
rules:
  - id: valid-format
    metric: format_valid
    kind: observed_count
    max_failures: 0
  - id: short-enough
    metric: short_enough
    kind: observed_count
    max_failures: 0
  - id: covers-facts-floor
    metric: covers_facts
    min: 0.60

require_validated_evaluators: true è l'impostazione predefinita del motore, scritta qui per esteso perché è il punto di questo tutorial: un giudice che nessuno ha confrontato con delle persone non può decidere una regola.

Eseguilo

oloproof run
Run run_01M4... [DECIDED/COMPLETE]
Gate: BLOCK (exit 3)
│ valid-format       │ format_valid │ PASS                  │ observed_failures_within_limit │
│ short-enough       │ short_enough │ PASS                  │ observed_failures_within_limit │
│ covers-facts-floor │ covers_facts │ INSUFFICIENT_EVIDENCE │ evaluator_not_validated        │
covers-facts-floor: the judge (or model or custom evaluator) behind this rule has not been measured against
people yet, so it may not decide.
  Label a sample:  oloproof review run_01M4... --criterion covers_facts --by YOU --sample 20
  Then measure it: oloproof evaluators validate EVALUATOR_ID --by YOU (ids: oloproof evaluators list)
│ format_valid │ 100.0%   │ [83.1%, 100.0%] │ 20 / 20 observed · 0 missing · 0 excluded │
│ short_enough │ 100.0%   │ [83.1%, 100.0%] │ 20 / 20 observed · 0 missing · 0 excluded │
│ covers_facts │ 45.0%    │ [23.0%, 68.5%]  │ 9 / 20 observed · 0 missing · 0 excluded  │
Cache: execution 0 hit/20 miss; judgment 0 hit/60 miss

Le regole di formato passano. Il giudice ha fatto passare 9 riassunti su 20, ma la regola è INSUFFICIENT_EVIDENCE con il motivo evaluator_not_validated, e il gate blocca con uscita 3. La regola non ha deciso sul 45%: il tasso di errore di un giudice è ignoto finché non viene misurato, quindi un intervallo costruito sui suoi verdetti porterebbe con sé un errore non dichiarato. Il motore lo riporta come INSUFFICIENT_EVIDENCE, non come MANUAL_REVIEW o FAIL: mancano le evidenze per decidere, e l'output stampa i due comandi che le forniscono.

Esamina i fallimenti

oloproof inspect RUN_ID --failures
11 of 20 cases failed, errored or did not finish

t01
  output: {"summary": "Hello."}
  covers_facts: failed
    judge text, not verified: missing: 1042, cracked, replacement

t02
  output: {"summary": "I was charged twice for order 2210."}
  covers_facts: failed
    judge text, not verified: missing: 49
...

La motivazione del giudice viene mostrata come "judge text, not verified": è la spiegazione del modello, non un'evidenza. Lo schema è comunque chiaro: la prima frase è spesso un saluto.

Misura il giudice rispetto a una persona

La validazione confronta i verdetti del giudice con quelli di una persona sulle stesse risposte. Estrai un campione casuale dei casi dell'esecuzione in un foglio. I verdetti del giudice ne sono esclusi, così chi etichetta non ne viene condizionato:

oloproof labels export RUN_ID --criterion covers_facts --sample 20 --local --out sample.csv
Wrote 20 cases to sample.csv, drawn at random with seed 2701013296, without the judge's verdict.
  This is a local sample, good-faith only, because it was drawn on this machine.
Fill in `passed` (pass or fail) and `labelled_by` on each row you judge, then run `oloproof labels import sample.csv`.

--local estrae su questa macchina senza chiedere a uno spazio di lavoro ospitato; il seed lo sceglie il motore. Con 20 casi, un campione di 20 li comprende tutti. In pratica, una persona legge il ticket e il riassunto di ogni riga e compila passed. Per questo tutorial, labels/reviewer_verdicts.csv contiene i verdetti che un revisore ha dato sui riassunti della baseline, e fill_labels.py li copia nel foglio:

python fill_labels.py sample.csv
oloproof labels import sample.csv
filled 20 rows of sample.csv
Recorded 20 labels from sample.csv (20 measurement).

Il revisore è stato in disaccordo con il giudice una volta: su t02 ("I was charged twice for order 2210.") ha ritenuto irrilevante l'importo mancante e lo ha fatto passare. Le etichette nominano la risposta esatta che hanno giudicato, quindi questi verdetti valgono solo per l'esecuzione della baseline.

Trova l'id della versione del giudice e validalo:

oloproof evaluators list
oloproof evaluators validate EVALUATOR_ID --by alice
covers_facts  LLM_JUDGE  UNVALIDATED  (declared)  sha256:a662...

covers_facts: sha256:a662... is now VALIDATED
  agreement 95.0% [75.1%, 99.9%] · 19 of 20 labelled cases agreed · 0 labelled but not judged · kappa 0.900
  bias -5.0 points [-32.4, +20.7] · the judge's pass rate minus the people's · 20 cases · 0 labelled but not judged
  passes what people pass 90.0% [55.4%, 99.8%] · the judge passed 9 of 10 cases people passed · 0 labelled but not judged
  fails what people fail 100.0% [69.1%, 100.0%] · the judge failed 10 of 10 cases people failed · 0 labelled but not judged

Leggi gli intervalli, non il 95%: 20 etichette mostrano un accordo di almeno il 75,1%. Una policy può chiedere di più con minimum_evaluator_agreement, che confronta quel limite inferiore, e validate rifiuta un giudice al di sotto. La guida Giudici tratta la soglia, la distorsione, le sonde e oloproof review per etichettare nel terminale.

Ora decidi di nuovo l'esecuzione memorizzata senza chiamare il riassuntore né il giudice:

oloproof gate RUN_ID --policy release.yaml
valid-format: PASS (observed_failures_within_limit)
short-enough: PASS (observed_failures_within_limit)
covers-facts-floor: INSUFFICIENT_EVIDENCE (interval_overlaps_threshold)
  no sample size would make this PASS: the observed rate (0.500) is itself below the threshold (0.600), so more cases would move it toward FAIL
Gate: BLOCK (exit 3)

Ora il giudice può decidere, e la decisione riguarda il riassuntore: il tasso che riporta, 0.500, non è il 45% del giudice. Poiché questa esecuzione ha un campione cieco e casuale di etichette di misura, il gate legge il giudice corretto da quelle etichette ("Judge-corrected gates" nella guida Giudici). La correzione è PPI, prediction-powered inference: usa il campione etichettato per misurare quanto il tasso del giudice si discosta da quello delle persone, e sposta la stima e allarga l'intervallo di altrettanto. È anche ciò a cui si riferiscono le note dell'esportazione su PPI. In ogni caso, la baseline non raggiunge la soglia, e più casi non lo cambierebbero.

Fai una modifica reale

app_v2.py salta i convenevoli brevi e tiene le due frasi successive. Copialo sopra app.py, imposta version: skip-pleasantries sotto system in oloproof.yaml, lascia il giudice in esecuzione, e:

oloproof run
Gate: ALLOW (exit 0)
│ covers-facts-floor │ covers_facts │ PASS  │ lower_bound_meets_minimum      │
│ covers_facts │ 100.0%   │ [83.1%, 100.0%] │ 20 / 20 observed · 0 missing · 0 excluded │
Cache: execution 0 hit/20 miss; judgment 6 hit/54 miss

Il giudice è la stessa versione validata, quindi la sua regola decide direttamente. Sei giudizi sono arrivati dalla cache, su riassunti che entrambe le versioni hanno scritto in modo identico. Nessuno ha etichettato questi nuovi riassunti; è la validazione del giudice a far valere i suoi verdetti.

Confronta il candidato con la baseline

version: 1
confidence_level: 0.95
block_on: [FAIL, INSUFFICIENT_EVIDENCE, MANUAL_REVIEW]
require_validated_evaluators: true
rules:
  - id: covers-more-facts
    kind: superiority
    metric: covers_facts
oloproof compare CANDIDATE_RUN_ID BASELINE_RUN_ID --policy compare.yaml
format_valid: +0.0 points [-23.6, +23.6] · 20 paired · 0 missing · 0 excluded
short_enough: +0.0 points [-23.6, +23.6] · 20 paired · 0 missing · 0 excluded
covers_facts: +55.0 points [+13.0, +84.4] · 20 paired · 0 missing · 0 excluded
Decisions
  covers-more-facts  covers_facts  superiority  PASS  difference_above_zero
Gate: ALLOW (exit 0)

Un confronto non applica la correzione PPI: confronta i verdetti del giudice stesso sulle due esecuzioni, ed è per questo che il guadagno parte dal 45% del giudice e non dallo 0.500 corretto visto sopra. Undici riassunti sono migliorati e nessuno è peggiorato; l'intervallo del guadagno è interamente sopra lo zero, quindi la regola di superiorità passa e il comando esce con 0. Il formato è protetto dalle regole di esecuzione, che non ammettono fallimenti, anziché da un confronto: su 20 casi un confronto tra due punteggi di formato perfetti potrebbe solo dire che la differenza è entro 23,6 punti.

Facoltativo: un modello reale come giudice

Questo passo lascia il percorso offline. Richiede un server di modelli e, con un provider cloud, una chiave e del denaro.

  • In locale, senza chiave e senza costi: Ollama, LM Studio o llama.cpp su localhost. Scarica un modello di chat (per Ollama, ollama pull llama3.1).
  • Cloud: provider: anthropic o openai con api_key_env che nomina la variabile contenente la tua chiave, oppure openai_compatible con base_url e api_key_env. Ogni caso è una chiamata al giudice (due quando la prima risposta non è JSON valido), fatturata alle tariffe del tuo provider, e Oloproof non chiama mai di nuovo un giudice per una risposta che ha già giudicato.

Scrivi la bozza del giudice in un file a sé, come apparirebbe sotto evaluators::

# live_judge.yaml
type: rubric_judge
criterion: covers_facts
provider: openai_compatible
model: llama3.1
base_url: http://localhost:11434/v1
rubric_file: rubrics/covers_facts.md

e provala sulle risposte che il tuo revisore ha già etichettato, senza validarla né adottarla:

oloproof evaluators try live_judge.yaml

I server locali rispondono a una richiesta alla volta per impostazione predefinita; aggiungi concurrency: {system: 2, judge: 2} a oloproof.yaml così le chiamate in coda non vanno in timeout. Un'esecuzione di questo passo con un piccolo modello locale (qwen2.5vl) su un portatile ha stampato:

covers_facts: draft sha256:b88a... on 20 labelled cases · 20 judged now, 0 from cache, 11 errored
  agreement 88.9% [19.1%, 99.9%] · 8 of 9 labelled cases agreed · 11 labelled but not judged · kappa 0.769

Undici chiamate sono andate in timeout, e l'intervallo di accordo conta ciascuna in entrambi i sensi, quindi scende fino al 19,1%: un giudice che non risponde non viene misurato. Il rimedio è un modello più grande, un timeout più lungo o meno chiamate concorrenti. Per adottare il modello, mettilo in oloproof.yaml al posto del sostituto. È una nuova versione del valutatore: la sua configurazione (modello, endpoint, rubrica) è la sua identità, quindi la validazione del sostituto non si trasferisce. Esegui di nuovo la baseline con esso e validalo rispetto alle etichette, come sopra.

Risoluzione dei problemi

SintomoCausa e rimedio
covers_facts tutto mancante, no_observationsIl server del giudice non è in esecuzione o non è su base_url. Ogni chiamata al giudice è andata in errore; oloproof inspect RUN_ID --failures mostra perché.
evaluator_not_validated dopo la validazioneHai cambiato il giudice (modello, endpoint, porta, rubrica) e creato una nuova versione. Valida quella.
labels import rifiuta il file e nomina una rigaLa riga nomina un caso o un'esecuzione che l'esecuzione non contiene; esporta di nuovo dall'esecuzione che etichetti.
labels export dice che non è stato possibile raggiungere uno spazio di lavoroHai effettuato l'accesso a uno, quindi gli ha chiesto di estrarre. --local estrae invece qui.
Un giudice cloud fallisce prima di qualsiasi chiamataLa sua chiave non è nella variabile indicata da api_key_env.

Limitazioni

  • Il giudice sostitutivo è una corrispondenza di frasi. Dimostra il flusso di lavoro, non la qualità del giudizio.
  • Non ci sono valutatori BLEU, ROUGE o di similarità tra embedding. Nell'SDK, scrivine uno con @evaluator; oloproof.yaml non può ancora nominare un valutatore personalizzato.
  • Un giudice vede testo: il JSON dell'input, del riferimento e dell'output. Non vede immagini né audio.
  • Venti etichette danno un intervallo di accordo ampio. Etichettane di più, a caso e alla cieca, per un giudice su cui fai affidamento.
  • Un campione locale vale solo in buona fede. Per un giudice su cui altre persone fanno affidamento, fai il push dell'esecuzione e lascia che sia uno spazio di lavoro ospitato a estrarre il campione (Giudici).