# Agent orchestré V2 — Documentation Ce document décrit le fonctionnement du workflow **V2** : état partagé, prompts Markdown, nœuds d’agents, outils de retrieval, graphe LangGraph, ainsi que l’intégration via le **service d’agents** et le **registry**. --- ## Vue d’ensemble Le V2 est un graphe LangGraph à **quatre nœuds**, avec un **classificateur** en entrée qui route soit vers un **agent conversationnel avec tools** (recherche sémantique), soit vers une **chaîne de synthèse** (Markdown puis export PDF). Objectifs principaux par rapport à une approche RAG « tout injecté » : - Le modèle **décide quand** appeler les retrieveurs (formations, prestations, documents projet). - Les prompts métier CAPL sont **versionnés en fichiers** sous `graphs/prompts_v2/`. - La branche **SUMMARIZE** produit une synthèse structurée puis un **PDF** (upload optionnel). Fichier d’entrée du graphe : `graphs/workflows/orchestrated_v2.py` (`create_orchestrated_graph_v2`). --- ## État partagé (`AgentState`) Le type `AgentState` (`graphs/state.py`) est un `TypedDict` partiellement optionnel : chaque nœud n’écrit que les clés dont il a besoin ; LangGraph fusionne les mises à jour (notamment `messages` via `add_messages`). Champs utiles pour le V2 : | Clé | Rôle | |-----|------| | `messages` | Historique conversationnel (LangChain `BaseMessage`). | | `query` | Requête textuelle explicite (souvent le dernier message utilisateur). | | `project_id` | Identifiant projet pour `search_project_docs` et messages système contextuels. | | `sources` | Liste optionnelle d'UUID de documents projet (inclusion stricte) appliquée côté serveur à `search_project_docs`. | | `documents` | Liste de dictionnaires (métadonnées « sources ») agrégées lors des appels tools, exploitées côté streaming API. | | `classification` | Instance Pydantic `QueryClassification` (sortie du classificateur). | | `summary_markdown` | Contenu Markdown produit par le nœud summarizer LLM. | | `summary_pdf_path` | Chemin local ou URL après export / upload. | Les champs historiques RAG (`formation_docs`, `prestation_context`, etc.) restent dans le schéma pour compatibilité avec d’autres workflows ; le V2 s’appuie surtout sur les **tools** et sur `documents` pour la traçabilité côté client. --- ## Workflow LangGraph ### Schéma logique ```mermaid flowchart TD START([Entrée]) --> classify[classify] classify -->|CLASSIC| tools_agent[tools_agent] classify -->|SUMMARIZE| summarizer_llm[summarizer_llm] classify -->|UNKNOWN| tools_agent tools_agent --> END1([END]) summarizer_llm --> summarizer_export[summarizer_export] summarizer_export --> END2([END]) ``` ### Nœuds 1. **`classify`** — Appelle le LLM avec sortie structurée (`QueryClassification`) pour choisir la branche. 2. **`tools_agent`** — Agent chat avec **bind_tools** : boucle invoke → éventuels `ToolMessage` → réponse finale sans tool calls (ou limite d’appels atteinte). 3. **`summarizer_llm`** — Génère le Markdown de synthèse à partir de l’historique + prompt système V2. 4. **`summarizer_export`** — Convertit le Markdown en PDF, écrit un fichier local, tente un upload ; ajoute un `AIMessage` avec métadonnées document (lien, type, horodatage). ### Routage conditionnel La fonction de routage lit `state["classification"].classification` et mappe : - `CLASSIC` → `tools_agent` - `SUMMARIZE` → `summarizer_llm` - `UNKNOWN` → `tools_agent` (comportement prudent : même pipeline que le conseil classique) Référence : `graphs/workflows/orchestrated_v2.py` (`add_conditional_edges` sur `"classify"`). ### Compilation et checkpointer `create_orchestrated_graph_v2(llm, checkpointer=None)` : - Charge les prompts V2 (voir section suivante). - Substitute `{{TODAY_DATE}}` dans le prompt chat (date du jour `JJ/MM/AAAA`). - Passe des **factories** de nœuds (`classifier_node`, `chat_with_tools_node`, etc.) à `_build_v2_workflow`. - Retourne `workflow.compile(checkpointer=checkpointer)`. Les imports `markdown_to_pdf` et `upload_pdf_to_supabase` sont **lazy** dans la factory pour éviter de charger des dépendances PDF quand ce n’est pas nécessaire (ex. notebooks). --- ## Prompts V2 ### Chargement - Module : `graphs/prompts_v2/loader.py` - Fonction : `load_v2_prompt(filename: str) -> str` (lecture UTF-8, mise en cache `lru_cache`). - Répertoire : `graphs/prompts_v2/` ### Fichiers et rôles | Fichier | Injecté dans | Rôle | |---------|----------------|------| | `classifier_system.md` | Nœud **classify** | Consigne de classification CLASSIC / SUMMARIZE / UNKNOWN + justification courte. | | `chat_system.md` | Nœud **tools_agent** (1er `SystemMessage`) | Identité CAPL Pays de la Loire, objectifs win-win, usage catalogues, contraintes (pas d’invention, citations, dates formations, questions de clarification). Placeholder `{{TODAY_DATE}}` remplacé à la compilation du graphe. | | `tools_policy.md` | Nœud **tools_agent** (2e `SystemMessage`) | Règles d’usage des tools, priorité `search_project_docs` si `project_id`, limitation des appels redondants. | | `summarizer_system.md` | Nœud **summarizer_llm** | Structure Markdown attendue de la synthèse (# Titre, ## Contexte, etc.). | ### Contexte système additionnel (projet) Si `project_id` est présent dans l’état, `chat_with_tools_agent` ajoute un **troisième** `SystemMessage` : rappel que l’ID est fourni par l’application, interdiction de le demander à l’utilisateur, priorité à `search_project_docs`, et possibilité d’omettre `project_id` dans les arguments de l’outil (injection serveur). --- ## Agents (nœuds) ### Classificateur — `graphs/agents/classifier_agent.py` - Construit une chaîne `ChatPromptTemplate` → `llm.with_structured_output(QueryClassification)`. - Entrées modèle : historique `{messages}` et `{query}`. - Résolution de la question : `state["query"]` si non vide, sinon dernier message `human` dans l’historique. - En cas d’exception sur l’invoke : classification `UNKNOWN` avec `reasoning` = message d’erreur. Modèle Pydantic : `graphs/models.py` — `QueryClassification` avec `classification: Literal["CLASSIC","SUMMARIZE","UNKNOWN"]` et `reasoning: str`. ### Agent chat + tools — `graphs/agents/chat_tools_agent.py` - **Tools** : `search_formations`, `search_prestations`, `search_project_docs` (`graphs/tools/retrieval_tools.py`). - `llm.bind_tools(tools)` ; boucle jusqu’à absence de `tool_calls` ou jusqu’à `max_tool_calls_per_turn` (défaut : 10). - Pour `search_project_docs`, fusion des args : `tool_args.setdefault("project_id", project_id)` si le modèle ne le passe pas. - Résultats d’outil sérialisés en JSON dans des `ToolMessage`. - Extraction des **sources** : si le dict retourné par un tool contient une clé `sources` (liste), les éléments sont ajoutés à `state["documents"]` pour l’API (streaming). - **Sortie** : `messages` = historique initial **+** dernier `AIMessage` uniquement (pas tout le détail des tours tools dans l’historique persisté du graphe pour ce nœud), plus `documents` et `query`. ### Summarizer — `graphs/agents/summarizer_agent.py` - **`summarizer_llm_node`** : `[SystemMessage(prompt)] + messages` → réponse LLM ; remplit `summary_markdown` et append un `AIMessage` avec le Markdown. - **`summarizer_export_node`** : lit `summary_markdown`, appelle `markdown_to_pdf`, écrit sous `tmp_summaries/`, tente `upload_pdf` ; met à jour `summary_pdf_path` et append un `AIMessage` final avec `metadata["document"]` (lien, nom de fichier, type, date). --- ## Tools de retrieval Définis dans `graphs/tools/retrieval_tools.py` : | Tool | Index / filtre | Retour typique | |------|----------------|----------------| | `search_formations` | `get_retriever("formation", k=...)` | `items`, `context`, `sources`, `count` | | `search_prestations` | V2 : `PrestationsV2Retriever` → Supabase `documents_v2` + RPC `match_documents_v2_full`, embeddings `mistral-embed` | `documents` (`page_content` + `metadata` dont `similarity`), `context`, `count`, `version`, `applied` | | `search_project_docs` | `list_project_chunks_paginated` sur la table `projects` (Supabase REST) | `items`, `context`, `sources`, `count`, pagination `has_more` / `next_offset` ; filtre optionnel `document_ids` (UUID) ; si `project_id` manquant, retour avec `error` | `search_project_docs` **ne fait pas de recherche sémantique** : il énumère les chunks du projet par **pagination stable** (`order by id`), avec **au plus 20 chunks par appel** (défaut et plafond). L’agent doit enchaîner les appels avec `next_offset` tant que `has_more` est vrai. Quand `sources` est fourni dans la requête API, un filtre d'inclusion strict est appliqué côté serveur via `document_id`. Le nœud `tools_agent` autorise plusieurs appels tools par tour (plafond configurable) pour permettre cette pagination. Pour `search_formations`, le top-k reste clampé côté serveur entre **10 et 24** (défaut 10). Pour `search_prestations` (V2), `k` est clampé de la même façon ; en plus : `score_threshold` (0.0–1.0, défaut 0.5) et `offset` (pagination, défaut 0) sont passés à la RPC. --- ## Apparté : service d’agents et registry ### Rôle du registry — `services/agent_registry.py` `AgentRegistry` maintient : - `_agent_builders` : `dict[str, Callable]` — identifiant **normalisé en minuscules** → fonction qui construit le graphe compilé à partir d’un `BaseChatModel` (et optionnellement d’un checkpointer pour V2). - `_descriptions` : texte exposé aux clients via l’API. Agents enregistrés par défaut : - `v1` → `create_orchestrated_graph` (`graphs/workflows/orchestrated.py`) - `v2` → `create_orchestrated_graph_v2` (`graphs/workflows/orchestrated_v2.py`) Méthodes importantes : - **`register_agent(agent, builder, description="")`** — Enregistre ou remplace un builder (`agent` est strip + lower). - **`get_builder_for_request(agent=None)`** — Si `agent` est absent, équivalent à `"V2"`. Clé = `agent.strip().lower()`. Lève `ValueError` si l’id est inconnu. - **`resolve_agent_id(agent=None)`** — Retourne l’identifiant **canonique** pour les métadonnées API : `(agent or "V2").strip().upper()` (ex. `V2`, `V1`). - **`list_agents()`** — Liste triée des agents avec `type`, `name`, `description`, `available`. Singleton exporté : `agent_registry`. ### Rôle du service — `services/agent_service.py` `AgentService` fait le lien entre la couche HTTP (ou voice) et LangGraph : 1. Instancie le LLM via `llm_service.get_llm(...)` (streaming ou non). 2. **`resolved_agent = agent_registry.resolve_agent_id(agent)`** puis **`builder = agent_registry.get_builder_for_request(agent=resolved_agent)`** — Remarque : `get_builder_for_request` re-normalise en minuscules, donc `V2` et `v2` sont équivalents. 3. **Mémoire serveur** : si `conversation_id` est fourni, le graphe est compilé avec `MemorySaver` partagé (`_text_checkpointer`) et `config = {"configurable": {"thread_id": conversation_id}}` ; le message courant est seulement un `HumanMessage` (l’historique est repris du checkpointer). Sinon, historique client reconstruit via `_prepare_messages`. 4. État initial typique : `{"messages": ..., "query": message, "project_id": project_id}`. 5. Post-traitement : `normalize_usage`, `RunContext`, orchestrateur de post-processing (`build_orchestrator().run(ctx)`), enrichissement de `metadata`. **Streaming** (`stream`) : modes `["messages", "updates"]` ; agrégation des documents depuis les updates `tools_agent` / métadonnées du dernier message ; chunks texte issus des `AIMessageChunk` uniquement ; chunk final avec usage, latence et métadonnées. Instance singleton : `agent_service`. ### Exposition HTTP - Les routes de completion utilisent `agent_service.invoke` / `agent_service.stream` avec le paramètre `agent` (optionnel, défaut implicite V2 côté registry). - **`GET /agents`** (`api/routes/models.py`) appelle `agent_registry.list_agents()` pour lister les types enregistrés. ### Voice `services/voice/voice_agent_service.py` peut cibler explicitement un graphe (ex. `get_builder_for_request("V1")`) selon le pipeline vocal — à distinguer du chemin texte qui suit le paramètre `agent` / défaut V2. ### Ajouter un agent V3 (rappel procédural) 1. Implémenter `create_orchestrated_graph_v3(llm, checkpointer=None)` (ou signature alignée sur le service). 2. `agent_registry.register_agent("v3", create_orchestrated_graph_v3, description="...")` ou modifier `AgentRegistry.__init__`. 3. Aucune modification obligatoire des routes si le client passe `agent=v3` et que le builder est enregistré. --- ## Fichiers clés (référence rapide) | Chemin | Contenu | |--------|---------| | `graphs/workflows/orchestrated_v2.py` | Construction du graphe V2 et câblage des prompts | | `graphs/state.py` | `AgentState` | | `graphs/models.py` | `QueryClassification` | | `graphs/agents/classifier_agent.py` | Nœud classify | | `graphs/agents/chat_tools_agent.py` | Nœud tools_agent | | `graphs/agents/summarizer_agent.py` | Nœuds summarizer | | `graphs/tools/retrieval_tools.py` | Définitions `@tool` | | `services/vectorstore_service.py` | `list_project_chunks_paginated` (liste paginée projet) | | `graphs/prompts_v2/*.md` | Prompts éditables | | `graphs/prompts_v2/loader.py` | Chargement + cache | | `services/agent_registry.py` | Registre des graphes | | `services/agent_service.py` | Invocation, streaming, mémoire, métadonnées | --- ## Résumé Le **V2** combine un **router LLM structuré**, un **agent autonome sur les outils de retrieval** (avec politique projet et plafond d’appels), et une **branche synthèse PDF**. Les instructions métier sont externalisées dans **`graphs/prompts_v2/`**. L’**`AgentRegistry`** sélectionne le builder ; l’**`AgentService`** fournit LLM, checkpointer optionnel, état initial et post-traitement uniformes pour l’API. --- ## Outil debug RAG (HTML) Une page de debug ouverte est disponible sur `GET /rag-explorer` (fichier statique `static/rag_explorer.html`), avec un endpoint JSON `GET /rag-explorer/chunks`. Usage: - Saisir une question (embedding requête). - Saisir un `project_id` si l’index est `projects`. - Lister les chunks avec `content`, `metadata` et `similarity` renvoyé par la RPC d’index (`match_project_documents`). - Cliquer sur un extrait pour afficher le contenu complet dans une modale. Prérequis: - La fonction RPC configurée pour l’index (ex. `match_project_documents`) doit être accessible et retourner le champ `similarity`. Garde-fous: - Pagination `limit/offset`. - `limit` borné à 500.