Leçon 3 — Construire l'agent pas à pas

Sources citées et rapport structuré

5 min de lecture

Sources citées et rapport structuré

Le problème des citations

Le texte renvoyé par un modèle ancré ne contient pas de liens : les sources sont dans grounding_metadata.grounding_chunks. Si on laisse le modèle « citer de mémoire », il peut se tromper de numéro ou inventer une URL. On fait donc circuler les numéros dans le texte et les URL dans l'état.

flowchart LR
  G["Réponse Gemini + grounding_chunks"] --> CB["memoriser_sources (after_model_callback)"]
  CB -->|"dédoublonne, numérote"| ST[("state['sources']")]
  CB -->|"ajoute 'Sources : [1] a.org ; [2] b.org'"| TX["Texte renvoyé à l'agent principal"]
  TX --> P["analyste_recherche cite [1][2]"]
  ST --> B["bibliographie() dans run.py"]
  P --> R["rapport.md"]
  B --> R

Le callback memoriser_sources

Un after_model_callback reçoit la réponse du modèle avant qu'ADK ne la transforme en événement. Il peut la lire, la modifier et la renvoyer.

files/agent.py
def memoriser_sources(callback_context, llm_response):
    gm = llm_response.grounding_metadata
    if not gm or not gm.grounding_chunks or not llm_response.content:
        return None
    sources = list(callback_context.state.get("sources", []))
    lignes = []
    for chunk in gm.grounding_chunks:
        if not chunk.web or not chunk.web.uri:
            continue
        source = {"titre": chunk.web.title or chunk.web.domain or "source", "url": chunk.web.uri}
        if source not in sources:
            sources.append(source)
        lignes.append(f"[{sources.index(source) + 1}] {source['titre']}")
    callback_context.state["sources"] = sources
    llm_response.content.parts.append(types.Part(text="\n\nSources : " + " ; ".join(dict.fromkeys(lignes))))
    return llm_response

Trois détails importants :

DétailPourquoi
if source not in sourcesune même page trouvée par deux recherches garde le même numéro
dict.fromkeys(lignes)dédoublonne en gardant l'ordre (une page peut apparaître plusieurs fois dans les chunks)
callback_context.state["sources"] = ...l'écriture passe par le state_delta du sous-agent, qu'AgentTool recopie dans la session principale

Deux recherches successives trouvent b.org. Quel numéro b.org porte-t-il dans la deuxième recherche ?

Le rapport structuré

L'instruction de l'agent principal impose les étapes et le format :

files/agent.py
INSTRUCTION = """Tu es un analyste de veille. Tu réponds en français.
1. Appelle date_du_jour.
2. Découpe la question de l'utilisateur en 2 ou 3 sous-questions précises.
3. Appelle chercheur_web une fois par sous-question.
4. Rédige un rapport Markdown avec exactement ces sections :
   ## Synthèse (3 phrases)
   ## Points clés (puces ; chaque puce finit par le ou les numéros de source, ex. [1][3])
   ## Ce qui reste incertain
N'utilise QUE les faits renvoyés par chercheur_web et ne cite QUE les numéros [n] qu'il donne.
Ne recopie pas la liste des sources : elle est ajoutée automatiquement."""

Puis run.py assemble le fichier final : titre, texte de l'agent, et la bibliographie construite par le code depuis l'état.

files/run.py
s = await runner.session_service.get_session(app_name="veille", user_id="moi", session_id=s.id)
sources = s.state.get("sources", [])
texte = f"# {question}\n\n{rapport.strip()}\n\n## Sources\n{bibliographie(sources)}\n"
open("rapport.md", "w", encoding="utf-8").write(texte)

Qui écrit les URL de la section « Sources » du rapport ?

Exercice : signaler les citations orphelines

Écrivez une fonction citations_invalides(rapport, sources) qui renvoie les numéros cités dans le rapport ([7]) qui n'existent pas dans sources. Où l'appelez-vous ?

Voir la solution
import re

def citations_invalides(rapport: str, sources: list) -> list[int]:
    cites = {int(n) for n in re.findall(r"\[(\d+)\]", rapport)}
    return sorted(n for n in cites if not 1 <= n <= len(sources))

Appelez-la dans run.py juste avant d'écrire rapport.md ; si la liste n'est pas vide, affichez un avertissement ou relancez la rédaction. Ajoutez un test : citations_invalides("A [1] B [4]", [s1, s2]) == [4].

Résultat obtenu

Rapport réel produit par python run.py (gemini-3.5-flash, 2026-10-15), extrait. Les faits viennent des pages trouvées ce jour-là ; relisez-les avant tout usage.

## Points clés
* **Moteur d'exécution par graphes (Workflow Runtime)** : La publication d'ADK Python v2.0.0 en
  mai 2026, suivie de Go v2.0.0 en juin et TypeScript v2.0.0 en août 2026, marque la transition
  d'une exécution hiérarchique vers le *Workflow Runtime* [...] [3].
* **Écosystème multi-langage et Java v1.0.0** : ADK prend en charge Python, Java, Go, TypeScript
  et Kotlin [...] [1][2][3].

## Sources
1. [googleblog.com](https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQF28L...)
2. [googleblog.com](https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQGLLs...)
3. [google.com](https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQE4qD...)
[...] 8 sources au total

Je sais faire

  • Lire grounding_metadata dans un after_model_callback
  • Numéroter des sources de façon stable entre plusieurs recherches
  • Faire remonter l'état d'un sous-agent appelé par AgentTool
  • Imposer un format de rapport et séparer texte du modèle et bibliographie du code
Suivant