Aller au contenu

Guides

Tutoriel : une application derrière un point de terminaison HTTP

Évaluez un service que vous atteignez en HTTP, sans importer son code : pointez Oloproof vers l’URL, exécutez la suite, trouvez ce qu’elle rate, déployez un changement, et comparez. Un petit service local remplace le vôtre, si bien que tout s’exécute hors ligne.

Ce que vous allez construire

Un service de prise de commandes qui lit un message client et en extrait deux champs : une intent (where_is_order, cancel, return ou other) et un order_id (quatre chiffres, ou null). Vous vérifierez le format de chaque réponse, ce qui ne demande aucune référence, et mesurerez si chaque champ est juste, ce qui en demande une. Les termes comme cas, exécution, métrique et porte sont définis dans les Concepts.

Prérequis

  • Python 3.11 ou ultérieur, et Oloproof dans un environnement virtuel :
python3 -m venv .venv
. .venv/bin/activate
pip install oloproof
  • Le projet d’exemple, livré avec le paquet. Copiez-le dans un nouveau répertoire et travaillez-y (httpx, qu’importe client.py, est installé avec Oloproof) :
oloproof init --example http order-intake
cd order-intake
  • Le port 8765 libre sur cette machine. S’il est pris, choisissez-en un autre et changez-le à la fois dans la commande du serveur et dans oloproof.yaml.

Rien ici n’utilise de fournisseur, de clé d’API ni Internet : le service écoute sur 127.0.0.1.

Les fichiers

order-intake/
  server.py             the stand-in service (Python standard library only)
  client.py             a callable that calls the service with a token (used near the end)
  oloproof.yaml         the suite: dataset, HTTP system, evaluators
  release.yaml          rules for a run
  compare.yaml          a rule for a comparison
  data/messages.jsonl   20 cases

Lancez les commandes depuis order-intake/. Démarrez le service dans un second terminal et laissez-le tourner :

python server.py --port 8765
intake service (v1) on http://127.0.0.1:8765/extract

Le contrat HTTP

Pour chaque cas, Oloproof envoie une requête : l’input du cas comme corps JSON, avec la méthode que vous déclarez (POST par défaut). Il lit la réponse comme du JSON. Un statut de 400 ou plus, un délai dépassé ou une connexion refusée est enregistré comme une erreur d’exécution pour ce cas, jamais comme une mauvaise réponse.

Requête et réponse pour un cas :

POST /extract
{"message": "Where is order 1042? It has not arrived."}

200 OK
{"result": {"intent": "where_is_order", "order_id": "1042"}, "service": {"rules": "v1"}}

output_path: result indique à Oloproof de ne garder que result comme sortie du cas ; sans lui, le corps entier est la sortie. Un chemin pointé comme data.answer va plus en profondeur.

version: 1
project: order-intake
dataset: data/messages.jsonl
system:
  name: order-intake
  http:
    url: http://127.0.0.1:8765/extract
    method: POST
    version: rules-v1
    output_path: result
    timeout_s: 30
evaluators:
  - type: json_schema
    criterion: format_valid
    field: null
    schema:
      type: object
      required: [intent, order_id]
      properties:
        intent: {enum: [where_is_order, cancel, return, other]}
        order_id: {type: [string, "null"], pattern: '^\d{4}$'}
      additionalProperties: false
  - type: exact_match
    criterion: intent_correct
    field: intent
  - type: exact_match
    criterion: order_id_correct
    field: order_id

Un système HTTP doit déclarer une version. Oloproof ne peut pas voir un déploiement : il met en cache la sortie de chaque cas sous l’URL, la méthode, le chemin de sortie et cette version, si bien que la version est la façon de lui dire que le service a changé. Oubliez de la changer et un nouveau déploiement n’est jamais appelé.

Les en-têtes et l’authentification ne peuvent pas encore être configurés sur system.http. La section sur les jetons ci-dessous montre le contournement.

Le jeu de données

{"id":"m04","input":{"message":"Has order #5120 shipped yet?"},"expected":{"intent":"where_is_order","order_id":"5120"}}
{"id":"m07","input":{"message":"Do you ship to Canada?"},"expected":{"intent":"other","order_id":null}}
{"id":"m16","input":{"message":"Please refund and take back the lamp from order #1560."},"expected":{"intent":"return","order_id":"1560"}}

input est exactement le corps de la requête. expected contient la référence de chaque champ ; null est une vraie valeur de référence, qui signifie « il n’y a pas d’identifiant de commande dans ce message ».

Choisir les évaluateurs

CritèreÉvaluateurExige expectedMesure
format_validjson_schema sur la sortienonle format : une intention connue et un identifiant bien formé
intent_correctexact_match sur intentouila réussite de la tâche pour le premier champ
order_id_correctexact_match sur order_idouila réussite de la tâche pour le second champ

Le schéma accepterait {"intent": "other", "order_id": null} pour chaque message : bien formé et inutile. Seules les vérifications par référence disent si le service a fait son travail. Noter les champs séparément montre lequel échoue, ce qu’une vérification combinée masquerait.

release.yaml n’admet aucun échec de format et demande que chaque champ soit juste au moins 70% du temps, jugé sur l’intervalle à 95% :

version: 1
confidence_level: 0.95
block_on: [FAIL, INSUFFICIENT_EVIDENCE, MANUAL_REVIEW]
rules:
  - id: valid-format
    metric: format_valid
    kind: observed_count
    max_failures: 0
  - id: intent-floor
    metric: intent_correct
    min: 0.70
  - id: order-id-floor
    metric: order_id_correct
    min: 0.70

Exécutez-la

oloproof run
Run run_01M4... [DECIDED/COMPLETE]
Gate: BLOCK (exit 3)
│ valid-format   │ format_valid     │ PASS                  │ observed_failures_within_limit │
│ intent-floor   │ intent_correct   │ INSUFFICIENT_EVIDENCE │ interval_overlaps_threshold    │
│ order-id-floor │ order_id_correct │ INSUFFICIENT_EVIDENCE │ interval_overlaps_threshold    │
│ format_valid     │ 100.0%   │ [83.1%, 100.0%] │ 20 / 20 observed · 0 missing · 0 excluded │
│ intent_correct   │ 75.0%    │ [50.8%, 91.4%]  │ 15 / 20 observed · 0 missing · 0 excluded │
│ order_id_correct │ 75.0%    │ [50.8%, 91.4%]  │ 15 / 20 observed · 0 missing · 0 excluded │
Cache: execution 0 hit/20 miss; judgment 0 hit/60 miss

Chaque réponse est bien formée. Chaque champ est juste 15 fois sur 20, mais 20 cas laissent un intervalle qui descend à 50.8%, si bien qu’aucun des deux planchers de 70% n’est démontré : INSUFFICIENT_EVIDENCE, et la porte bloque avec le code de sortie 3.

Examiner les échecs

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

m04
  output: {"intent": "where_is_order", "order_id": null}
  order_id_correct: failed

m06
  output: {"intent": "other", "order_id": "7011"}
  intent_correct: failed
...
m18
  output: {"intent": "other", "order_id": null}
  intent_correct: failed
  order_id_correct: failed

Lisez les entrées à côté (oloproof inspect RUN_ID --case m04) et deux défauts apparaissent : le motif de l’identifiant ne reconnaît que « order 1234 », pas « order #5120 », « order no. 8123 » ni un simple « #1673 » ; et des formulations comme « send back », « stop order » et « where’s my parcel » ne correspondent à aucune intention. C’est l’action suivante : élargir les deux règles.

Déployer un changement

Arrêtez le service et démarrez le candidat, qui porte ces corrections :

python server.py --port 8765 --rules v2

Changez version: rules-v1 en version: rules-v2 dans oloproof.yaml, parce que l’URL n’a pas changé et qu’Oloproof réutiliserait sinon les anciennes sorties. Puis :

oloproof run
Gate: ALLOW (exit 0)
│ intent_correct   │ 100.0%   │ [83.1%, 100.0%] │ 20 / 20 observed · 0 missing · 0 excluded │
│ order_id_correct │ 100.0%   │ [83.1%, 100.0%] │ 20 / 20 observed · 0 missing · 0 excluded │

Les deux planchers passent et la commande sort avec 0.

Comparer les deux déploiements

compare.yaml demande si les intentions du candidat sont meilleures que celles de la référence :

version: 1
confidence_level: 0.95
block_on: [FAIL, INSUFFICIENT_EVIDENCE, MANUAL_REVIEW]
rules:
  - id: intent-better
    kind: superiority
    metric: intent_correct
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
intent_correct: +25.0 points [-8.5, +58.3] · 20 paired · 0 missing · 0 excluded
order_id_correct: +25.0 points [-8.5, +58.3] · 20 paired · 0 missing · 0 excluded
Decisions
  intent-better  intent_correct  superiority  INSUFFICIENT_EVIDENCE  interval_overlaps_zero
Gate: BLOCK (exit 3)

Le candidat satisfait ses propres planchers, et pourtant la comparaison ne peut pas montrer qu’il est meilleur : cinq cas ont changé, et sur 20 cas appariés l’intervalle du gain inclut encore zéro. Les deux affirmations sont vraies en même temps. « Satisfait l’exigence » et « bat la référence » sont des questions distinctes, et une suite aussi petite ne répond à la seconde que pour de grands effets. Davantage de vrais messages est le remède.

Un service qui exige un jeton

Démarrez le service de façon qu’il exige un jeton bearer :

INTAKE_TOKEN=s3cret python server.py --port 8765 --rules v2 --require-token

system.http n’envoie aucun en-tête personnalisé, si bien qu’avec une nouvelle version (disons rules-v2-auth) chaque appel est refusé :

Gate: BLOCK (exit 3)
│ valid-format   │ format_valid     │ INSUFFICIENT_EVIDENCE │ no_observations │
│ intent_correct   │          │ [0.0%, 100.0%] │ 0 / 0 observed · 20 missing · 0 excluded │

et oloproof inspect RUN_ID --failures affiche execution ERROR: TransientError: system returned HTTP 401 sur chaque cas. Les erreurs d’exécution sont des preuves manquantes, pas des échecs : rien n’a été observé, si bien que chaque règle est INSUFFICIENT_EVIDENCE.

Le contournement est un appelable Python qui fait lui-même la requête. client.py ajoute l’en-tête à partir d’une variable d’environnement, si bien que le jeton n’entre jamais dans oloproof.yaml ni dans aucun enregistrement stocké :

URL = os.environ.get("INTAKE_URL", "http://127.0.0.1:8765/extract")


def extract(case: dict[str, Any]) -> dict[str, Any]:
    headers = {"Authorization": f"Bearer {os.environ['INTAKE_TOKEN']}"}
    response = httpx.post(URL, json=case, headers=headers, timeout=30)
    response.raise_for_status()
    return response.json()["result"]

Remplacez le bloc http: de oloproof.yaml par :

system:
  name: order-intake
  version: rules-v2
  callable: client:extract

et exécutez avec le jeton dans l’environnement :

INTAKE_TOKEN=s3cret oloproof run

Les 20 cas sont de nouveau observés et la porte autorise. Le cache de l’appelable suit son propre source et sa version, pas le service qui est derrière, si bien que la même règle s’applique : changez version quand vous déployez.

Dépannage

SymptômeCause et correction
system connection failed (ConnectError) sur chaque casLe service ne tourne pas, ou écoute sur un autre port.
system connection failed (RemoteProtocolError)Autre chose répond sur ce port. Choisissez-en un libre.
KeyError: "missing output path 'results'"output_path nomme un champ que la réponse n’a pas.
system returned HTTP 401 ou 403Le point de terminaison exige des identifiants : utilisez le contournement par appelable.
Vous avez déployé un changement et la ligne du cache n’indique que des succèsLa version n’a pas changé, si bien que les sorties stockées ont été réutilisées.
an HTTP system needs a declared versionAjoutez version sous system ou system.http.

Limites

  • Pas d’en-têtes personnalisés, d’authentification, de paramètres de requête ni de gabarit de requête sur system.http : l’input du cas est le corps JSON tel quel. Utilisez un appelable pour tout le reste.
  • Les réponses doivent être du JSON. Les réponses en flux ne sont pas lues comme un flux.
  • Oloproof ne peut pas détecter un déploiement ; la version déclarée est toute l’identité de ce qui a répondu.
  • Votre service possède son état : Oloproof envoie des requêtes et enregistre les réponses ; il ne réinitialise, n’isole ni n’annule rien de ce qu’une requête modifie.