Construire un outil de recherche de voitures d'occasion avec le SDK Les agents

Construire un pipeline qui trouve les cinq voitures d'occasion les moins coûteuses sur la durée de détention prévue, en respectant un budget et une distance de déplacement. Un agent lit chaque page d'annonce avant qu'elle ne vous soit présentée. Chaque exécution produit une sélection en Markdown, lisible en une minute : lien, prix, kilométrage, distance, coût de possession, résumé, avertissements et questions à poser au vendeur.

Cet exemple illustre : un appel par annonce (sans outils : le code récupère la page), une sortie structurée, un délai d'expiration qui arrête l'attente, pas le tour de l'agent, et des tests avec un modèle aux réponses prédéfinies.

Exécutez cette commande depuis le dossier de l'exemple, après avoir défini MISTRAL_API_KEY : uv run python -m car_finder.main --no-record

criteria.json → AutoScout24 search → filters → cost of ownership → ranked
   → for each car, cheapest first: open the page → listing-reviewer agent → shortlist
Répartir le travail entre le code et l'agent

Répartir le travail entre le code et l'agent

Confiez à l'agent uniquement ce que le code ne peut pas faire : lire une page d'annonce et l'évaluer. Gardez dans le code tout ce qui peut être vérifié :

  • Les chiffres proviennent du code. La recherche, les filtres sur les motorisations, l'ajustement de la décote et le coût de possession sont calculés en Python. Le rapport reprend le prix, le kilométrage et l'année de l'annonce, jamais ceux du modèle.
  • Le code ouvre la page. Python récupère chaque annonce et insère son texte dans le prompt. Un agent disposant d'un outil inspect_listing peut répondre sans l'appeler et déclarer inaccessible une page qui fonctionne.
  • Un rejet doit être justifié par une citation. Un verdict FAIL doit recopier la phrase qui le justifie, et le code vérifie que cette phrase figure sur la page. Sans cette vérification, une voiture peut être rejetée parce que sa liste d'équipements mentionne « airbag en cas d'accident ». Une inquiétude non étayée devient plutôt une question à poser au vendeur.
  • Le code signale les frais. Une page mentionnant des frais d'agence ou de courtage déclenche un avertissement : un prix bas assorti de 700 € de frais obligatoires n'est plus aussi avantageux.

L'agent chargé de l'évaluation est un agents.Agent sans outils. Le format de sa réponse est un modèle Pydantic : le modèle reçoit donc une demande de Review, et l'exécution renvoie un objet de ce type :

class Review(BaseModel):
    verdict: str = Field(description="PASS or FAIL")
    why: str = Field(description="One line")
    evidence: str = Field(
        description="For FAIL: an exact sentence copied from page_text that proves it. Empty for PASS"
    )
    abstract: str = Field(description="Two or three short sentences: what it is, its history and condition, the catch")
    maintenance_history: str
    controle_technique: str
    owners: str
    questions_for_seller: list[str] = Field(description="2 to 4 questions to ask before driving there")

REVIEWER = agents.Agent(
    model="mistral-medium-latest",
    name="listing-reviewer",
    instructions=f"""You vet one used car listing for a buyer who keeps it {BUYER["years"]} years and wants no big
repair. You get the listing data, what this model should be (target), and the text of the listing page, already
fetched. Report only what the page says; write 'not stated' otherwise. Never invent facts.
The engine and gearbox were already checked; do not judge them.
FAIL only if the page itself says: a price or km different from the listing data; the car was damaged or in an
accident, does not run, has no papers, is for export or for parts; or it looks like a scam (payment by transfer,
seller abroad, mandatory extra fees). Equipment lists ("airbag en cas d'accident", "kit de réparation",
"pare-chocs") are not evidence.
For a FAIL, copy the exact sentence that proves it into evidence. Otherwise PASS.
Write the abstract and questions in English. The engine has a timing chain unless the target says otherwise.""",
    output_type=Review,  # the model answers with a Review, and Completed.output is one
    max_iterations=2,
)

Le code appelle l'agent d'évaluation une fois par annonce, avec le texte de la page dans le prompt, puis vérifie la citation :

async def review(car: dict, model: dict, text: str) -> Review | None:
    listing = {k: v for k, v in car.items() if k != "tco"}
    prompt = json.dumps({"listing": listing, "target": model["target"], "page_text": text}, ensure_ascii=False)
    for attempt in (1, 2):
        match await REVIEWER.run(prompt, timeout=300):
            case agents.Completed(output=Review() as rev):
                pass
            case agents.Running(session_id=session_id, turn_id=turn_id):
                log(f"review attempt {attempt}: no answer after 300 s")
                await REVIEWER.session(session_id).turn(turn_id).interrupt()  # the timeout only left the wait
                continue
            case other:  # Completed(output=InvalidOutput(...)), Failed, ...
                log(f"review attempt {attempt} failed: {other!r}"[:300])
                continue
        # A FAIL must quote the page. An unquoted worry is kept as a question rather than dropping the car.
        if rev.verdict.upper().startswith("FAIL") and _normalise(rev.evidence or "∅") not in _normalise(text):
            rev.verdict = "PASS*"
            rev.questions_for_seller.insert(0, f"Reviewer worried, unproven: {rev.why}")
        return rev
    return None

Chaque résultat est une valeur. Une exécution qui ne répond pas dans les 300 s renvoie Running : le délai d'expiration arrête l'attente, pas le tour de l'agent. Le code interrompt donc ce tour avant de réessayer. Une réponse qui n'est pas un objet Review valide est renvoyée sous la forme Completed(output=InvalidOutput(...)).

Le code de l'outil de recherche de voitures d'occasion

Le code de l'outil de recherche de voitures d'occasion

L'exemple est un projet doté de son propre fichier pyproject.toml :

FichierCe qu'il illustre
criteria.jsonLes paramètres de l'acheteur, les modèles, les motorisations et les réparations à prendre en compte
car_finder/autoscout.pyLa lecture des résultats de recherche à partir des données intégrées par AutoScout24 dans ses pages
car_finder/tco.pyFiltres, ajustement de la décote, coût de possession
car_finder/main.pyLe pipeline, l'agent d'évaluation des annonces et ses vérifications, le rapport
tests/Le coût de possession et les vérifications de l'agent d'évaluation avec un modèle aux réponses prédéfinies
Exécuter l'exemple

Exécuter l'exemple

Depuis le dossier de l'exemple :

uv sync
export MISTRAL_API_KEY=...
uv run python -m car_finder.main

Une exécution prend une à deux minutes. La sélection est affichée et enregistrée dans reports/. Les voitures déjà présentées sont enregistrées dans state/shown.json et ignorées à l'exécution suivante ; utilisez --no-record pendant vos essais.

L'exécution consigne chaque annonce ouverte et le verdict de l'agent d'évaluation. Chaque ligne indique le verdict, le coût de possession de la voiture sur la période prévue (et non son prix), ainsi que le titre de l'annonce :

Output
[...]
[22:19:03] 45 candidates, 0 skipped as already shown
[22:19:06] PASS 1,451 € Toyota Yaris Collection Yaris Hybride 100h No disqualifying statements found in the page text.
[22:19:08] PASS 1,551 € Citroën C1 C1 VTi 68 Airscape Feel No disqualifying statements found in the page text.
[22:19:11] PASS 1,715 € Hyundai i10 INITIA No disqualifying statements found in the page text.
[22:19:13] PASS 1,725 € Peugeot 108 Style No red flags (damage, accident, no papers, export/parts, scam) found in the page text. Pri
[22:19:19] FAIL 1,733 € Hyundai i10 EDITION MONDIAL 2019 - APPLE CARPLAY A Mandatory extra fees (frais obligatoires) are explicitly mentioned in the listing.
[22:19:21] PASS 1,739 € Kia Picanto 1.2 essence MPi 84 ch BVM5 GT Line No red flags (damage, accident, no papers, export/parts, scam) or discrepancies in price/k
[...]

La citation de l'agent justifiant le verdict FAIL a été trouvée sur la page. Le code a donc conservé ce verdict ; la voiture n'apparaît pas dans la sélection.

Tester l'exemple

Tester l'exemple

Les tests ne nécessitent ni clé API ni connexion réseau. Un modèle aux réponses prédéfinies remplace celui de l'agent d'évaluation. Le test vérifie ainsi le code qui l'entoure : un verdict FAIL sans citation devient une question à poser au vendeur, tandis qu'un verdict accompagné d'une citation est conservé :

"""The listing reviewer with a scripted model: no API key, no network."""

import json

import pytest
from car_finder import main

from mistralai import agents

CAR = {"title": "Peugeot 108 1.0 VTi 72 Active", "price_eur": 6_900, "km": 61_000, "tco": None}
MODEL = {"target": "Peugeot 108 1.0 VTi, 68 to 73 ch, timing chain"}
PAGE = "Peugeot 108 1.0 VTi 72 Active. Carnet d'entretien complet. Contrôle technique OK."

def verdict(verdict: str, evidence: str) -> str:
    return json.dumps(
        {
            "verdict": verdict,
            "why": "Accident damage",
            "evidence": evidence,
            "abstract": "A 108 with a full service book.",
            "maintenance_history": "full service book",
            "controle_technique": "OK",
            "owners": "not stated",
            "questions_for_seller": ["When was the clutch changed?"],
        }
    )

async def test_a_fail_needs_a_quote_from_the_page(monkeypatch: pytest.MonkeyPatch) -> None:
    model = agents.testing.ScriptedModel([agents.testing.reply(verdict("FAIL", "Véhicule accidenté."))])
    monkeypatch.setattr(main, "REVIEWER", main.REVIEWER.override(model=model))
    rev = await main.review(CAR, MODEL, PAGE)
    model.assert_consumed()
    assert rev is not None
    assert rev.verdict == "PASS*"  # the quote is not on the page: kept, with a question
    assert rev.questions_for_seller[0] == "Reviewer worried, unproven: Accident damage"

async def test_a_quoted_fail_stays_a_fail(monkeypatch: pytest.MonkeyPatch) -> None:
    model = agents.testing.ScriptedModel([agents.testing.reply(verdict("FAIL", "Contrôle technique OK."))])
    monkeypatch.setattr(main, "REVIEWER", main.REVIEWER.override(model=model))
    rev = await main.review(CAR, MODEL, PAGE)
    assert rev is not None
    assert rev.verdict == "FAIL"
uv run pytest
Classer par coût de possession

Classer par coût de possession

Ne comptez que ce qui diffère entre deux voitures. Les coûts communs à toutes les voitures, comme l'immatriculation et l'assurance, ne changent rien au classement :

PosteSource
Prix d'achatL'annonce
− Valeur de reventeLa décote de chaque modèle, ajustée à partir des prix réellement demandés dans toute la France, par année d'âge et par tranche de 10 000 km, puis appliquée au prix payé. Un achat en dessous du prix du marché n'est pas compté comme un bénéfice, et le supplément payé chez un professionnel est perdu lors d'une revente entre particuliers.
+ Entretienservicing_per_year_eur dans les critères
+ Grosses réparations probablesLes réparations connues pour chaque modèle (embrayage, pompe à eau), pondérées selon la proximité du kilométrage de la voiture avec celui auquel elles deviennent généralement nécessaires
+ Entretien lié à l'âgeUn montant fixe par an au-delà de 10 ans

Les filtres déterminent les motorisations et les boîtes de vitesses admissibles avant tous ces calculs. La Peugeot 108 illustre pourquoi : son moteur 1.0 VTi est un moteur Toyota à chaîne de distribution, tandis que son 1.2 PureTech utilise une courroie baignant dans l'huile. Les deux portent le nom « 108 » dans le titre de l'annonce ; la puissance en chevaux permet de les distinguer.

Adapter l'exemple

Adapter l'exemple

Modifiez criteria.json :

  • buyer : votre code postal, la distance que vous acceptez de parcourir, le budget, la durée de détention en années et le kilométrage annuel.
  • models : les modèles que vous jugez fiables, la plage de puissance qui identifie la bonne motorisation, les mots qui excluent une annonce, l'année minimale, le kilométrage maximal et les réparations à prévoir dans le budget. Les modèles correspondant à la même voiture, comme l'Aygo, la C1 et la 108, partagent un même market_group : un seul ajustement de la décote et un seul modèle dans la sélection.

Les paramètres par défaut ciblent les citadines essence à moins de 10 000 € autour d'Aix-en-Provence. Ils constituent un point de départ, pas une étude étayée par des sources : vérifiez-les avant de vous fier au classement.

Limites

Limites

  • AutoScout24 uniquement. Leboncoin et La Centrale bloquent les requêtes automatisées, et ParuVendu affiche un captcha après quelques dizaines de requêtes.
  • Consultez les conditions d'utilisation d'AutoScout24 avant de planifier des exécutions régulières de l'outil.
  • autoscout.py lit les données intégrées aux pages du site. Lorsque le site modifie leur structure, le lecteur cesse de fonctionner, et les tests ne le détectent pas : ils couvrent l'évaluation et le modèle de coût, pas le lecteur.
  • Les modèles ayant peu d'annonces, comme la Jazz et la Mazda 2 dans le budget prévu, utilisent une courbe de décote générique.
  • La décote est ajustée à partir des prix demandés, pas des prix de vente.