Aller au contenu

Guides

Tutoriel : une conversation à plusieurs tours

Un parcours exécutable pour évaluer un assistant conversationnel avec le SDK Python : votre application rejoue une conversation scriptée contre une session fraîche, enregistre ce qu’elle a répondu comme un artefact conversation/v1, et deux évaluateurs jugent chaque conversation entière. Il s’exécute hors ligne avec un remplaçant scripté pour le modèle juge, compare ensuite une correction candidate, et se termine par l’alternative qui évalue les tours comme des cas groupés.

Les termes comme intervalle, état de décision et action de publication sont définis dans les Concepts ; Enregistrer ce qu’a fait un système couvre les artefacts.

Ce qu’Oloproof fait et ne fait pas ici

Oloproof faitVotre application fait
stocke les conversations scriptées comme jeu de données, identiques pour chaque systèmepilote la conversation : pose chaque tour scripté dans l’ordre
vérifie que chaque tour scripté a reçu une réponse (ConversationCompleted)possède l’état de session, démarre une session fraîche par cas, et la réinitialise
juge la transcription entière avec un modèle (ConversationJudge)décide de ce qui se passe quand elle ne peut pas continuer, et enregistre qu’elle s’est arrêtée
calcule les intervalles, compare deux systèmes, et décide selon une politiqueenregistre l’artefact conversation/v1

Oloproof n’a pas de simulateur d’utilisateur : il n’écrit jamais un tour d’utilisateur, si bien que le côté de l’utilisateur est ce que scripte le jeu de données. Il n’a pas de métrique par tour au sein d’une conversation enregistrée, et il ne peut pas rejouer une conversation enregistrée contre un nouveau système. Les évaluateurs de conversation n’existent que dans le SDK Python : ConversationCompleted et ConversationJudge ne sont pas des types d’évaluateur dans oloproof.yaml, si bien que ce tutoriel utilise un script plutôt que oloproof run.

Prérequis

  • Python 3.11 ou ultérieur et pip install oloproof, comme dans le démarrage rapide.
  • Les fichiers d’exemple, livrés avec le paquet. Copiez-les dans un nouveau répertoire pour que le magasin de l’exécution y soit créé :
oloproof init --example conversation ~/oloproof-conversation
cd ~/oloproof-conversation
FichierCe que c’est
assistant.pyl’application évaluée : un assistant sur les offres, avec un état de session
systems.pyl’adaptateur : rejoue un script, enregistre conversation/v1
judge_offline.pyle remplaçant scripté du modèle juge
evaluate.pyexécute l’évaluation, la comparaison et l’alternative par tours
release.yamlla politique pour une exécution
comparison.yamlla politique pour le candidat face à la référence
turns_release.yamlla politique pour l’alternative par tours
data/conversations.jsonl40 conversations scriptées
data/turns.jsonlles mêmes conversations, un cas par tour

Aucune clé, aucun réseau et aucun coût de fournisseur, jusqu’à l’étape facultative en direct à la fin.

L’application

assistant.py répond à des questions sur trois offres tarifaires. Elle garde un seul élément d’état, l’offre dont parle la conversation, si bien qu’une relance comme « Does that include SSO? » peut résoudre « that ». La référence a un défaut délibéré : elle ne retient pas l’offre, si bien qu’une relance reçoit une réponse sur l’offre par défaut. Un utilisateur qui demande une personne met fin à la conversation avec 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): ...

C’est la partie que vous remplacez par votre propre application : un client de chatbot, une session d’agent, une session HTTP vers votre service. Quelle qu’elle soit, elle possède son état et sa réinitialisation ; Oloproof ne voit que ce qu’enregistre l’adaptateur.

Le jeu de données : le script est l’entrée

Une ligne de data/conversations.jsonl est une conversation :

{"expected": {"plan": "enterprise"}, "id": "conv_00", "input": {"turns": ["What does the enterprise plan cost?", "Does that include SSO?"]}, "metadata": {"pattern": "pronoun_followup"}}

Les tours de l’utilisateur sont du contenu du jeu de données, couverts par l’empreinte de la suite et identiques pour chaque système mesuré sur eux ; c’est ce qui rend deux systèmes comparables. expected est la référence que l’on montre au juge. Les 40 conversations comptent 24 conversations avec une relance qui ne nomme aucune offre, 12 qui nomment l’offre à chaque tour, et 4 qui demandent une personne au deuxième tour sur trois.

L’adaptateur

systems.py démarre une session fraîche par cas, pose chaque tour scripté dans l’ordre, et enregistre ce qui est revenu :

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)

Une session fraîche par cas compte : Oloproof exécute les cas en parallèle et sans ordre fixe, et une session partagée entre cas laisserait l’état d’une conversation fuir dans une autre. records= déclare que le système enregistre l’artefact ; sans lui, les évaluateurs de conversation sont refusés avant toute exécution, plutôt que de compter chaque cas comme manquant.

L’artefact conversation/v1

Ce qu’a enregistré la référence pour conv_00, d’après oloproof export RUN_ID (le cases.jsonl du paquet) :

{"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, ...}]}

Et pour une conversation qui a demandé une personne :

{"declared_turns": 3, "truncated": true, "turns": [{"answer": "The team plan costs $20 a month.", "asked": "What does the team plan cost?", "index": 1, ...}]}
ChampSignification
turns[].indexle tour scripté auquel celui-ci répond ; contigus à partir de 1
turns[].answerce que l’assistant a renvoyé, n’importe quel JSON
turns[].askedfacultatif, pour la lecture seulement ; le moteur s’appuie sur index
turns[].retrievalfacultatif, ce que ce tour a récupéré, sous la forme retrieval/v1
declared_turnscombien de tours le script a déclarés
truncatedl’enregistrement s’arrête avant la fin du script, quelle qu’en soit la cause

L’artefact est vérifié lors de son enregistrement : un enregistrement avec moins de tours que déclaré doit indiquer truncated: true, les index doivent être contigus, et un enregistrement ne peut pas répondre à plus de tours qu’on ne lui en a posé. Un artefact mal formé arrête l’exécution avec une SystemContractError.

Les deux évaluateurs, et pourquoi les deux

  • ConversationCompleted est déterministe : l’assistant a-t-il répondu à chaque tour scripté ? Il passe en premier parce que toute autre affirmation sur une conversation arrêtée au premier tour sur trois porte sur une autre conversation. Une conversation tronquée y échoue ; c’est un résultat, pas un cas manquant.
  • ConversationJudge est un juge modèle sur la transcription entière, chaque tour de l’utilisateur et de l’assistant, parce que les défaillances qu’on reproche à un produit conversationnel sont relationnelles : une réponse qui contredit celle du tour précédent n’est fausse qu’à côté d’elle. Une conversation tronquée est jugée sur ce qui a été enregistré, et la transcription indique au juge où elle s’est arrêtée.
evaluators = [
    ConversationCompleted(),
    ConversationJudge(criterion="plan_coherent", provider=..., model=..., rubric_text=RUBRIC),
]

Juger hors ligne

Un juge a besoin d’un modèle. Pour s’exécuter sans réseau, judge_offline.py passe un fournisseur scripté, le même utilitaire qu’utilisent les propres tests d’Oloproof (FakeProvider des rouages internes du moteur, pas une API publique). Il répond à chaque consigne de juge par une seule règle fixe : réussite quand chaque tour de l’assistant nomme l’offre que nomme la référence. Cela rend les jugements déterministes et le tutoriel reproductible. Il ne mesure rien du comportement d’un vrai modèle juge.

La politique

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}

Exécutez-le

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'}

Comment le lire :

  • La complétude est de 36 sur 40, les quatre transferts vers une personne. Sa borne inférieure dépasse 0.75, si bien que cette règle est PASS.
  • La cohérence est de 40%, et sa règle lit INSUFFICIENT_EVIDENCE avec evaluator_not_validated, pas FAIL. Les règles d’un juge ne décident pas tant que le juge n’a pas été mesuré contre des étiquettes humaines (require_validated_evaluators est actif par défaut ; Juges l’explique). L’estimation est quand même affichée, et c’est quand même une preuve : simplement, elle ne peut ni publier ni bloquer à elle seule.
  • gate BLOCK (exit 3) : la politique bloque sur INSUFFICIENT_EVIDENCE. Le code de sortie 3 est cet état, pas un échec.

Valider le remplaçant hors ligne n’aurait aucun sens, puisque c’est une règle écrite pour cet exemple. Avec un vrai juge, étiquetez un échantillon de l’exécution avec oloproof review RUN_ID --criterion plan_coherent --by YOU --sample 20 puis lancez oloproof evaluators validate EVALUATOR_ID --by YOU.

Examiner une conversation en échec

Le SDK écrit dans le même magasin que lit l’outil en ligne de commande, .oloproof/ dans le répertoire depuis lequel vous avez lancé :

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

L’utilisateur a demandé l’offre enterprise et la relance a reçu une réponse sur l’offre starter. oloproof inspect RUN_ID --failures liste chaque conversation en échec ; les 24 relances sans nom d’offre échouent toutes de la même façon. L’action suivante est dans l’application : garder l’offre dans l’état de session.

Un changement candidat, et la comparaison

candidate dans systems.py fixe remembers_plan=True. evaluate.py exécute les deux systèmes sur les mêmes scripts et les compare cas par cas selon 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)

La différence de cohérence est grande et son intervalle exclut zéro, mais le juge n’est pas validé, si bien que sa règle ne décide toujours pas. La complétude est inchangée, et 40 paires ne peuvent pas montrer qu’elle reste à moins de cinq points : l’intervalle atteint 12.7 points dans chaque sens. Les deux indiquent la même étape suivante : valider le juge et ajouter des conversations.

L’alternative : les tours comme cas, analysés en grappes

Un verdict au niveau de la conversation dit à quelle fréquence une conversation s’est bien passée, pas quel tour a mal tourné. Comme Oloproof n’a pas de métrique par tour au sein d’une conversation enregistrée, l’autre voie consiste à faire de chaque tour son propre cas et à relier les tours d’une conversation avec 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?"}}

Le système rejoue l’historique scripté dans une session fraîche, puis répond au tour :

@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"]))

Les tours d’une conversation ne sont pas indépendants, si bien que dès qu’un cas a un group_id, la suite est analysée par grappe avec une méthode approchée qu’une politique doit accepter (turns_release.yaml fixe allow_approximate_methods: true ; Cas groupés l’explique) :

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)

Le compromis :

Un cas par conversationUn cas par tour, en grappes
Unité du tauxles conversations qui se sont bien passéesles tours ayant reçu une réponse juste
Taille d’échantillon effectivele nombre de conversationstoujours le nombre de conversations, pas de tours
Quel tour a échouélire la transcriptionchaque tour a son propre verdict
Historique que voit chaque tourles réponses antérieures de l’assistant lui-mêmeles tours antérieurs de l’utilisateur du script, rejoués
Détecte la dérive causée par ses propres réponses antérieuresouinon, chaque tour part d’un historique scripté
ÉvaluateursConversationCompleted, ConversationJudge (SDK seulement)n’importe quel évaluateur, en YAML ou dans le SDK

L’alternative par tours exclut les quatre conversations transférées à une personne, si bien que ses 72 tours viennent de 36 conversations. Ici, elle peut décider là où le juge ne le pouvait pas, parce que ExactMatch est déterministe et ne demande aucune validation.

Facultatif : un modèle juge en direct

Cette étape demande un modèle servi sur votre machine. Elle n’est exécutée ni par le tutoriel hors ligne ni par son test. Avec Ollama en marche et llama3.1 téléchargé :

python evaluate.py --live

Le juge appelle alors http://localhost:11434/v1 avec provider="openai_compatible". Un serveur en boucle locale ne demande aucune clé et n’envoie rien hors de la machine. Un fournisseur cloud a besoin de sa clé dans l’environnement, envoie chaque transcription à ce fournisseur et coûte de l’argent par jugement. Les verdicts d’un vrai modèle diffèrent de ceux du remplaçant, si bien que les nombres ci-dessus changeront, et ses règles lisent toujours evaluator_not_validated tant que vous ne l’avez pas validé.

Dépannage

SymptômeCause et correction
this evaluator needs exactly one conversation/v1 artifact; the case recorded 0L’adaptateur n’a pas appelé current_case().artifact(CONVERSATION, ...), ou a levé une exception avant. Enregistrez même quand la conversation s’arrête tôt.
malformed conversation/v1 artifact: ... 0 of 2 turns recorded and truncated is falseL’exécution s’arrête avec une SystemContractError. Un enregistrement avec moins de tours que declared_turns doit fixer truncated: true.
conversation turn indexes must be contiguous starting at 1Numérotez les tours 1, 2, 3 selon le tour scripté auquel ils répondent.
evaluator 'conversation_completed' needs conversation/v1 artifacts, but system ... does not declare that it records themAjoutez records=(CONVERSATION,) au décorateur @system.
Input tag 'conversation_completed' found using 'type' does not match any of the expected tags depuis oloproof runLes évaluateurs de conversation n’existent que dans le SDK. Utilisez un script comme ici.
Des réponses fuient d’une conversation à l’autreUne session est partagée entre les cas. Créez-en une par cas.
Les règles de cohérence ne décident jamaisLe juge n’est pas validé. Validez-le, ou fixez require_validated_evaluators: false en connaissance de cause.

Limites

  • Pas de simulateur d’utilisateur : chaque tour de l’utilisateur vient du script du jeu de données, si bien que la conversation ne peut pas bifurquer selon ce qu’a dit l’assistant.
  • Pas de métrique par tour au sein d’une conversation enregistrée ; utilisez plutôt les tours comme cas groupés, avec le compromis ci-dessus.
  • Pas de rejeu de conversation : une conversation enregistrée ne peut pas être réexécutée contre un autre système. Comparer deux systèmes signifie que chacun rejoue le même script.
  • ConversationCompleted et ConversationJudge n’existent que dans le SDK Python.
  • Texte seulement : un juge voit du texte JSON, jamais d’images ni d’audio.
  • Le juge hors ligne est une règle scriptée. Ses verdicts montrent la mécanique, pas l’exactitude d’un vrai juge.

Où aller ensuite

  • Juges couvre les fournisseurs, la validation et la recalibration.
  • Cas groupés couvre group_id et l’acceptation explicite des méthodes approchées.
  • L’API Python couvre evaluate et evaluate_comparison.
  • Agents et outils couvre la même frontière pour la boucle d’outils d’un agent.