Pular para o conteúdo

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 fazA sua aplicação faz
armazena as conversas roteirizadas como o dataset, idênticas para todo sistemaconduz 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íticaregistra 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
ArquivoO que é
assistant.pya aplicação em teste: um assistente de planos com estado de sessão
systems.pyo adaptador: reproduz um roteiro, registra conversation/v1
judge_offline.pyo substituto roteirizado para o modelo juiz
evaluate.pyexecuta a avaliação, a comparação e a alternativa por turnos
release.yamla política para uma execução
comparison.yamla política para o candidato contra a linha de base
turns_release.yamla política para a alternativa por turnos
data/conversations.jsonl40 conversas roteirizadas
data/turns.jsonlas 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, ...}]}
CampoSignificado
turns[].indexqual turno roteirizado este responde; contíguo a partir de 1
turns[].answero que o assistente retornou, qualquer JSON
turns[].askedopcional, apenas para leitura; o motor casa por index
turns[].retrievalopcional, o que aquele turno recuperou, no formato retrieval/v1
declared_turnsquantos turnos o roteiro declarou
truncateda 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.py
baseline 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_00
output: {
  "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: False

O 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 conversaUm caso por turno, agrupado
Unidade da taxaconversas que correram bemturnos respondidos corretamente
Tamanho efetivo da amostrao número de conversasainda o número de conversas, não de turnos
Qual turno falhouleia a transcriçãocada turno tem o seu próprio veredito
Histórico que cada turno vêas respostas anteriores do próprio assistenteos turnos anteriores do usuário no roteiro, reproduzidos
Detecta desvio causado pelas próprias respostas anterioressimnão, cada turno parte de um histórico roteirizado
AvaliadoresConversationCompleted, 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 --live

O 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

SintomaCausa e correção
this evaluator needs exactly one conversation/v1 artifact; the case recorded 0O 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 falseA 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 1Numere 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 themAcrescente records=(CONVERSATION,) ao decorador @system.
Input tag 'conversation_completed' found using 'type' does not match any of the expected tags vindo de oloproof runOs avaliadores de conversa são apenas do SDK. Use um script, como aqui.
Respostas vazam entre conversasUma sessão é compartilhada entre casos. Crie uma por caso.
As regras de coerência nunca decidemO 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.