Guias
Tutorial: uma conversa de vários turnos
Um passo a passo executável para avaliar um assistente conversacional com o SDK Python: a sua aplicação reproduz uma conversa roteirizada numa sessão nova, registra o que respondeu como um artefato conversation/v1, e dois avaliadores julgam cada conversa inteira. Ele roda offline com um substituto roteirizado para o modelo juiz, depois compara uma correção candidata e termina com a alternativa de avaliar os turnos como casos agrupados.
Termos como intervalo, estado de decisão e ação de lançamento são definidos em Conceitos; Registrar o que um sistema fez cobre os artefatos.
O que o Oloproof faz e não faz aqui
| O Oloproof faz | A sua aplicação faz |
|---|---|
| armazena as conversas roteirizadas como o dataset, idênticas para todo sistema | conduz a conversa: faz cada turno roteirizado em ordem |
| verifica que todo turno roteirizado foi respondido (ConversationCompleted) | é dona do estado da sessão, inicia uma sessão nova por caso e a reinicia |
| julga a transcrição inteira com um modelo (ConversationJudge) | decide o que acontece quando não consegue continuar, e registra que parou |
| calcula intervalos, compara dois sistemas e decide contra uma política | registra o artefato conversation/v1 |
O Oloproof não tem simulador de usuário: ele nunca escreve um turno do usuário, então o lado do usuário é o que o dataset roteiriza. Ele não tem métrica por turno dentro de uma conversa registrada, e não consegue reproduzir uma conversa registrada contra um sistema novo. Os avaliadores de conversa existem apenas no SDK Python: ConversationCompleted e ConversationJudge não são tipos de avaliador em oloproof.yaml, por isso este tutorial usa um script em vez de oloproof run.
Pré-requisitos
- Python 3.11 ou posterior e pip install oloproof, como no quickstart.
- Os arquivos de exemplo, que vêm com o pacote. Copie-os para um diretório novo para que o armazenamento da execução fique lá:
oloproof init --example conversation ~/oloproof-conversation
cd ~/oloproof-conversation| Arquivo | O que é |
|---|---|
| assistant.py | a aplicação em teste: um assistente de planos com estado de sessão |
| systems.py | o adaptador: reproduz um roteiro, registra conversation/v1 |
| judge_offline.py | o substituto roteirizado para o modelo juiz |
| evaluate.py | executa a avaliação, a comparação e a alternativa por turnos |
| release.yaml | a política para uma execução |
| comparison.yaml | a política para o candidato contra a linha de base |
| turns_release.yaml | a política para a alternativa por turnos |
| data/conversations.jsonl | 40 conversas roteirizadas |
| data/turns.jsonl | as mesmas conversas, um caso por turno |
Nenhuma chave, nenhuma rede e nenhum custo de provedor, até a etapa ao vivo opcional no final.
A aplicação
assistant.py responde perguntas sobre três planos de preço. Ele guarda um único estado, o plano de que a conversa trata, para que uma pergunta de seguimento como "Does that include SSO?" possa resolver "that". A linha de base tem uma falha deliberada: ela não lembra o plano, então um seguimento é respondido sobre o plano padrão. Um usuário que pede uma pessoa encerra a conversa com HandoffRequested.
class PlanAssistant:
def __init__(self, *, remembers_plan):
self.remembers_plan = remembers_plan
self.reset()
def reset(self):
"""Forget everything, so one conversation never leaks into the next."""
self.current_plan = None
def ask(self, question): ...Esta é a parte que você substitui pela sua própria aplicação: um cliente de chatbot, uma sessão de agente, uma sessão HTTP com o seu serviço. Seja o que for, ela é dona do seu estado e do seu reset; o Oloproof vê apenas o que o adaptador registra.
O dataset: o roteiro é a entrada
Uma linha de data/conversations.jsonl é uma conversa:
{"expected": {"plan": "enterprise"}, "id": "conv_00", "input": {"turns": ["What does the enterprise plan cost?", "Does that include SSO?"]}, "metadata": {"pattern": "pronoun_followup"}}Os turnos do usuário são conteúdo do dataset, cobertos pelo digest da suíte e idênticos para todo sistema medido contra eles; é isso que torna dois sistemas comparáveis. expected é a referência mostrada ao juiz. As 40 conversas são 24 com um seguimento que não nomeia plano, 12 que nomeiam o plano em todo turno e 4 que pedem uma pessoa no turno dois de três.
O adaptador
systems.py inicia uma sessão nova por caso, faz cada turno roteirizado em ordem e registra o que voltou:
from oloproof import CONVERSATION, current_case, system
def replay(case, *, remembers_plan):
script = [str(turn) for turn in case["turns"]]
session = PlanAssistant(remembers_plan=remembers_plan) # a new session per case
turns = []
truncated = False
for index, question in enumerate(script, start=1):
try:
reply = session.ask(question)
except HandoffRequested:
truncated = True # the recording stops here and says so
break
turns.append({"index": index, "asked": question, "answer": reply["answer"]})
current_case().artifact(
CONVERSATION,
{"turns": turns, "declared_turns": len(script), "truncated": truncated},
)
last = turns[-1]["answer"] if turns else None
return {"answer": last, "turns_answered": len(turns)}
@system(name="plan-assistant", version="baseline", records=(CONVERSATION,))
def baseline(case):
return replay(case, remembers_plan=False)
@system(name="plan-assistant", version="candidate-remembers-plan", records=(CONVERSATION,))
def candidate(case):
return replay(case, remembers_plan=True)Uma sessão nova por caso importa: o Oloproof executa os casos em paralelo e sem ordem fixa, e uma sessão compartilhada entre casos deixaria o estado de uma conversa vazar para outra. records= declara que o sistema registra o artefato; sem ele os avaliadores de conversa são recusados antes de qualquer execução, em vez de contar todo caso como ausente.
O artefato conversation/v1
O que a linha de base registrou para conv_00, a partir de oloproof export RUN_ID (o cases.jsonl do bundle):
{"declared_turns": 2, "truncated": false, "turns": [{"answer": "The enterprise plan costs a price agreed per contract.", "asked": "What does the enterprise plan cost?", "index": 1, ...}, {"answer": "The starter plan does not include SSO.", "asked": "Does that include SSO?", "index": 2, ...}]}E para uma conversa que pediu uma pessoa:
{"declared_turns": 3, "truncated": true, "turns": [{"answer": "The team plan costs $20 a month.", "asked": "What does the team plan cost?", "index": 1, ...}]}| Campo | Significado |
|---|---|
| turns[].index | qual turno roteirizado este responde; contíguo a partir de 1 |
| turns[].answer | o que o assistente retornou, qualquer JSON |
| turns[].asked | opcional, apenas para leitura; o motor casa por index |
| turns[].retrieval | opcional, o que aquele turno recuperou, no formato retrieval/v1 |
| declared_turns | quantos turnos o roteiro declarou |
| truncated | a gravação para antes do fim do roteiro, seja o que for que a interrompeu |
O artefato é verificado quando é registrado: uma gravação com menos turnos do que os declarados precisa dizer truncated: true, os índices precisam ser contíguos, e uma gravação não pode responder mais turnos do que os que lhe foram feitos. Um artefato malformado interrompe a execução com um SystemContractError.
Os dois avaliadores, e por que os dois
- ConversationCompleted é determinístico: o assistente respondeu todo turno roteirizado? Ele roda primeiro porque qualquer outra afirmação sobre uma conversa que parou no turno um de três é uma afirmação sobre outra conversa. Uma conversa truncada é reprovada nele; isso é um resultado, não um caso ausente.
- ConversationJudge é um juiz de modelo sobre a transcrição inteira, cada turno do usuário e do assistente, porque as falhas pelas quais um produto conversacional é culpado são relacionais: uma resposta que contradiz a do turno anterior só está errada ao lado dela. Uma conversa truncada é julgada pelo que foi registrado, e a transcrição diz ao juiz onde ela parou.
evaluators = [
ConversationCompleted(),
ConversationJudge(criterion="plan_coherent", provider=..., model=..., rubric_text=RUBRIC),
]Julgar offline
Um juiz precisa de um modelo. Para rodar sem rede, judge_offline.py passa um provedor roteirizado, o mesmo auxiliar que os próprios testes do Oloproof usam (FakeProvider, dos internos do motor, não da API pública). Ele responde a todo prompt de juiz por uma regra fixa: aprova quando todo turno do assistente nomeia o plano que a referência nomeia. Isso torna os julgamentos determinísticos e o tutorial reprodutível. Ele não mede nada sobre como um modelo juiz real se comporta.
A política
release.yaml:
version: 1
confidence_level: 0.95
block_on: [FAIL, INSUFFICIENT_EVIDENCE, MANUAL_REVIEW]
rules:
- {id: completion-floor, metric: conversation_completed, min: 0.75}
- {id: coherence-floor, metric: plan_coherent, min: 0.80}Execute
python evaluate.pybaseline run run_...
conversation_completed: 0.900 [0.763, 0.972] over 40 conversations
plan_coherent: 0.400 [0.249, 0.567] over 40 conversations
gate BLOCK (exit 3)
completion-floor: PASS (lower_bound_meets_minimum)
coherence-floor: INSUFFICIENT_EVIDENCE (evaluator_not_validated)
first failing conversation:
conversation_completed: passed=True {'turns_recorded': 2, 'turns_declared': 2, 'truncated': False}
plan_coherent: passed=False {'provider_model': 'offline-rule'}Como ler:
- A conclusão é 36 de 40, os quatro repasses a uma pessoa. O seu limite inferior supera 0.75, então essa regra é PASS.
- A coerência é 40%, e a sua regra mostra INSUFFICIENT_EVIDENCE com evaluator_not_validated, não FAIL. As regras de um juiz não decidem até que o juiz tenha sido medido contra rótulos humanos (require_validated_evaluators vem ativado por padrão; Juízes explica). A estimativa ainda é mostrada, e ainda é evidência: ela só não pode liberar nem bloquear sozinha.
- gate BLOCK (exit 3): a política bloqueia em INSUFFICIENT_EVIDENCE. A saída 3 é esse estado, não uma falha.
Validar o substituto offline não teria sentido, pois é uma regra escrita para este exemplo. Com um juiz real, rotule uma amostra da execução com oloproof review RUN_ID --criterion plan_coherent --by YOU --sample 20 e depois execute oloproof evaluators validate EVALUATOR_ID --by YOU.
Inspecione uma conversa reprovada
O SDK grava no mesmo armazenamento que a CLI lê, .oloproof/ no diretório de onde você executou:
oloproof inspect RUN_ID --case conv_00output: {
"answer": "The starter plan does not include SSO.",
"turns_answered": 2
}
judgments:
conversation_completed: passed
plan_coherent: failed
judge text, not verified:
every answer is about enterprise: FalseO usuário perguntou sobre o plano enterprise e o seguimento foi respondido sobre o plano starter. oloproof inspect RUN_ID --failures lista toda conversa reprovada; todos os 24 seguimentos sem nome de plano falham da mesma forma. A próxima ação está na aplicação: manter o plano no estado da sessão.
Uma mudança candidata, e a comparação
candidate em systems.py define remembers_plan=True. evaluate.py executa os dois sistemas sobre os mesmos roteiros e os compara caso a caso sob comparison.yaml:
rules:
- {id: coherence-better, kind: superiority, metric: plan_coherent}
- {id: completion-no-worse, kind: non_inferiority, metric: conversation_completed, margin: 0.05}comparison, candidate minus baseline
conversation_completed: +0.000 [-0.127, +0.127] over 40 pairs
plan_coherent: +0.600 [+0.337, +0.817] over 40 pairs
gate BLOCK (exit 3)
coherence-better: INSUFFICIENT_EVIDENCE (evaluator_not_validated)
completion-no-worse: INSUFFICIENT_EVIDENCE (interval_overlaps_margin)A diferença de coerência é grande e o seu intervalo exclui zero, mas o juiz não está validado, então a sua regra ainda não decide. A conclusão não mudou, e 40 pares não conseguem mostrar que ela está dentro de cinco pontos: o intervalo chega a 12.7 pontos para cada lado. Os dois apontam para o mesmo próximo passo: validar o juiz e acrescentar conversas.
A alternativa: turnos como casos, analisados como clusters
Um veredito por conversa diz com que frequência uma conversa correu bem, não qual turno deu errado. Como o Oloproof não tem métrica por turno dentro de uma conversa registrada, o outro caminho é fazer de cada turno o seu próprio caso e ligar os turnos de uma conversa com group_id:
{"expected": {"plan": "enterprise"}, "group_id": "conv_00", "id": "conv_00_t2", "input": {"history": ["What does the enterprise plan cost?"], "question": "Does that include SSO?"}}O sistema reproduz o histórico roteirizado numa sessão nova e depois responde o turno:
@system(name="plan-assistant-turns", version="baseline")
def turn_baseline(case):
session = PlanAssistant(remembers_plan=False)
for earlier in case["history"]:
session.ask(str(earlier))
return session.ask(str(case["question"]))Os turnos de uma conversa não são independentes, então, assim que algum caso tem um group_id, a suíte é analisada por cluster com um método aproximado que uma política precisa aceitar (turns_release.yaml define allow_approximate_methods: true; Casos agrupados explica):
turns as cases: turn_plan 0.667 [0.588, 0.749] over 72 turns
gate BLOCK (exit 1)
turn-plan-floor: FAIL (upper_bound_below_minimum)A troca:
| Um caso por conversa | Um caso por turno, agrupado | |
|---|---|---|
| Unidade da taxa | conversas que correram bem | turnos respondidos corretamente |
| Tamanho efetivo da amostra | o número de conversas | ainda o número de conversas, não de turnos |
| Qual turno falhou | leia a transcrição | cada turno tem o seu próprio veredito |
| Histórico que cada turno vê | as respostas anteriores do próprio assistente | os turnos anteriores do usuário no roteiro, reproduzidos |
| Detecta desvio causado pelas próprias respostas anteriores | sim | não, cada turno parte de um histórico roteirizado |
| Avaliadores | ConversationCompleted, ConversationJudge (apenas SDK) | qualquer avaliador, em YAML ou no SDK |
A alternativa por turnos exclui as quatro conversas com repasse a uma pessoa, então os seus 72 turnos vêm de 36 conversas. Aqui ela consegue decidir onde o juiz não conseguiu, porque ExactMatch é determinístico e não precisa de validação.
Opcional: um modelo juiz ao vivo
Esta etapa precisa de um modelo servido na sua máquina. Ela não é executada pelo tutorial offline nem pelo seu teste. Com o Ollama em execução e llama3.1 baixado:
python evaluate.py --liveO juiz então chama http://localhost:11434/v1 com provider="openai_compatible". Um servidor local (loopback) não precisa de chave e não envia nada para fora da máquina. Um provedor em nuvem precisa da sua chave no ambiente, envia cada transcrição a esse provedor e custa dinheiro por julgamento. Os vereditos de um modelo real diferem dos do substituto, então os números acima vão mudar, e as suas regras continuam mostrando evaluator_not_validated até que você o valide.
Solução de problemas
| Sintoma | Causa e correção |
|---|---|
| this evaluator needs exactly one conversation/v1 artifact; the case recorded 0 | O adaptador não chamou current_case().artifact(CONVERSATION, ...), ou lançou um erro antes. Registre mesmo quando a conversa para cedo. |
| malformed conversation/v1 artifact: ... 0 of 2 turns recorded and truncated is false | A execução para com um SystemContractError. Uma gravação com menos turnos que declared_turns precisa definir truncated: true. |
| conversation turn indexes must be contiguous starting at 1 | Numere os turnos 1, 2, 3 pelo turno roteirizado que eles respondem. |
| evaluator 'conversation_completed' needs conversation/v1 artifacts, but system ... does not declare that it records them | Acrescente records=(CONVERSATION,) ao decorador @system. |
| Input tag 'conversation_completed' found using 'type' does not match any of the expected tags vindo de oloproof run | Os avaliadores de conversa são apenas do SDK. Use um script, como aqui. |
| Respostas vazam entre conversas | Uma sessão é compartilhada entre casos. Crie uma por caso. |
| As regras de coerência nunca decidem | O juiz não está validado. Valide-o, ou defina require_validated_evaluators: false sabendo o que faz. |
Limitações
- Sem simulador de usuário: todo turno do usuário vem do roteiro do dataset, então a conversa não pode se ramificar conforme o que o assistente disse.
- Sem métrica por turno dentro de uma conversa registrada; use turnos como casos agrupados, com a troca descrita acima.
- Sem reprodução de conversa: uma conversa registrada não pode ser reexecutada contra outro sistema. Comparar dois sistemas significa que cada um reproduz o mesmo roteiro.
- ConversationCompleted e ConversationJudge existem apenas no SDK Python.
- Apenas texto: o juiz recebe texto JSON, nunca imagens ou áudio.
- O juiz offline é uma regra roteirizada. Os seus vereditos mostram a mecânica, não a precisão de um juiz real.
Para onde ir em seguida
- Juízes cobre provedores, validação e recalibração.
- Casos agrupados cobre group_id e a opção por métodos aproximados.
- A API Python cobre evaluate e evaluate_comparison.
- Agentes cobre a mesma fronteira para o laço de ferramentas de um agente.