Cyril Dupland commited on
Commit
2dcccd3
·
1 Parent(s): c392583

Implement V2 workflow for agent orchestration, including new chat tools agent with retrieval capabilities, classifier node enhancements, and summarizer integration. Add detailed documentation for V2 features and prompts, ensuring improved agent interaction and context handling.

Browse files
docs/AGENT_V2.md ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent orchestré V2 — Documentation
2
+
3
+ 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**.
4
+
5
+ ---
6
+
7
+ ## Vue d’ensemble
8
+
9
+ 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).
10
+
11
+ Objectifs principaux par rapport à une approche RAG « tout injecté » :
12
+
13
+ - Le modèle **décide quand** appeler les retrieveurs (formations, prestations, documents projet).
14
+ - Les prompts métier CAPL sont **versionnés en fichiers** sous `graphs/prompts_v2/`.
15
+ - La branche **SUMMARIZE** produit une synthèse structurée puis un **PDF** (upload optionnel).
16
+
17
+ Fichier d’entrée du graphe : `graphs/workflows/orchestrated_v2.py` (`create_orchestrated_graph_v2`).
18
+
19
+ ---
20
+
21
+ ## État partagé (`AgentState`)
22
+
23
+ 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`).
24
+
25
+ Champs utiles pour le V2 :
26
+
27
+ | Clé | Rôle |
28
+ |-----|------|
29
+ | `messages` | Historique conversationnel (LangChain `BaseMessage`). |
30
+ | `query` | Requête textuelle explicite (souvent le dernier message utilisateur). |
31
+ | `project_id` | Identifiant projet pour `search_project_docs` et messages système contextuels. |
32
+ | `documents` | Liste de dictionnaires (métadonnées « sources ») agrégées lors des appels tools, exploitées côté streaming API. |
33
+ | `classification` | Instance Pydantic `QueryClassification` (sortie du classificateur). |
34
+ | `summary_markdown` | Contenu Markdown produit par le nœud summarizer LLM. |
35
+ | `summary_pdf_path` | Chemin local ou URL après export / upload. |
36
+
37
+ 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.
38
+
39
+ ---
40
+
41
+ ## Workflow LangGraph
42
+
43
+ ### Schéma logique
44
+
45
+ ```mermaid
46
+ flowchart TD
47
+ START([Entrée]) --> classify[classify]
48
+ classify -->|CLASSIC| tools_agent[tools_agent]
49
+ classify -->|SUMMARIZE| summarizer_llm[summarizer_llm]
50
+ classify -->|UNKNOWN| tools_agent
51
+ tools_agent --> END1([END])
52
+ summarizer_llm --> summarizer_export[summarizer_export]
53
+ summarizer_export --> END2([END])
54
+ ```
55
+
56
+ ### Nœuds
57
+
58
+ 1. **`classify`** — Appelle le LLM avec sortie structurée (`QueryClassification`) pour choisir la branche.
59
+ 2. **`tools_agent`** — Agent chat avec **bind_tools** : boucle invoke → éventuels `ToolMessage` → réponse finale sans tool calls (ou limite d’appels atteinte).
60
+ 3. **`summarizer_llm`** — Génère le Markdown de synthèse à partir de l’historique + prompt système V2.
61
+ 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).
62
+
63
+ ### Routage conditionnel
64
+
65
+ La fonction de routage lit `state["classification"].classification` et mappe :
66
+
67
+ - `CLASSIC` → `tools_agent`
68
+ - `SUMMARIZE` → `summarizer_llm`
69
+ - `UNKNOWN` → `tools_agent` (comportement prudent : même pipeline que le conseil classique)
70
+
71
+ Référence : `graphs/workflows/orchestrated_v2.py` (`add_conditional_edges` sur `"classify"`).
72
+
73
+ ### Compilation et checkpointer
74
+
75
+ `create_orchestrated_graph_v2(llm, checkpointer=None)` :
76
+
77
+ - Charge les prompts V2 (voir section suivante).
78
+ - Substitute `{{TODAY_DATE}}` dans le prompt chat (date du jour `JJ/MM/AAAA`).
79
+ - Passe des **factories** de nœuds (`classifier_node`, `chat_with_tools_node`, etc.) à `_build_v2_workflow`.
80
+ - Retourne `workflow.compile(checkpointer=checkpointer)`.
81
+
82
+ 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).
83
+
84
+ ---
85
+
86
+ ## Prompts V2
87
+
88
+ ### Chargement
89
+
90
+ - Module : `graphs/prompts_v2/loader.py`
91
+ - Fonction : `load_v2_prompt(filename: str) -> str` (lecture UTF-8, mise en cache `lru_cache`).
92
+ - Répertoire : `graphs/prompts_v2/`
93
+
94
+ ### Fichiers et rôles
95
+
96
+ | Fichier | Injecté dans | Rôle |
97
+ |---------|----------------|------|
98
+ | `classifier_system.md` | Nœud **classify** | Consigne de classification CLASSIC / SUMMARIZE / UNKNOWN + justification courte. |
99
+ | `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. |
100
+ | `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. |
101
+ | `summarizer_system.md` | Nœud **summarizer_llm** | Structure Markdown attendue de la synthèse (# Titre, ## Contexte, etc.). |
102
+
103
+ ### Contexte système additionnel (projet)
104
+
105
+ 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).
106
+
107
+ ---
108
+
109
+ ## Agents (nœuds)
110
+
111
+ ### Classificateur — `graphs/agents/classifier_agent.py`
112
+
113
+ - Construit une chaîne `ChatPromptTemplate` → `llm.with_structured_output(QueryClassification)`.
114
+ - Entrées modèle : historique `{messages}` et `{query}`.
115
+ - Résolution de la question : `state["query"]` si non vide, sinon dernier message `human` dans l’historique.
116
+ - En cas d’exception sur l’invoke : classification `UNKNOWN` avec `reasoning` = message d’erreur.
117
+
118
+ Modèle Pydantic : `graphs/models.py` — `QueryClassification` avec `classification: Literal["CLASSIC","SUMMARIZE","UNKNOWN"]` et `reasoning: str`.
119
+
120
+ ### Agent chat + tools — `graphs/agents/chat_tools_agent.py`
121
+
122
+ - **Tools** : `search_formations`, `search_prestations`, `search_project_docs` (`graphs/tools/retrieval_tools.py`).
123
+ - `llm.bind_tools(tools)` ; boucle jusqu’à absence de `tool_calls` ou jusqu’à `max_tool_calls_per_turn` (défaut : 3).
124
+ - Pour `search_project_docs`, fusion des args : `tool_args.setdefault("project_id", project_id)` si le modèle ne le passe pas.
125
+ - Résultats d’outil sérialisés en JSON dans des `ToolMessage`.
126
+ - 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).
127
+ - **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`.
128
+
129
+ ### Summarizer — `graphs/agents/summarizer_agent.py`
130
+
131
+ - **`summarizer_llm_node`** : `[SystemMessage(prompt)] + messages` → réponse LLM ; remplit `summary_markdown` et append un `AIMessage` avec le Markdown.
132
+ - **`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).
133
+
134
+ ---
135
+
136
+ ## Tools de retrieval
137
+
138
+ Définis dans `graphs/tools/retrieval_tools.py` :
139
+
140
+ | Tool | Index / filtre | Retour typique |
141
+ |------|----------------|----------------|
142
+ | `search_formations` | `get_retriever("formation", k=...)` | `items`, `context`, `sources`, `count` |
143
+ | `search_prestations` | `get_retriever("prestation", k=...)` | idem |
144
+ | `search_project_docs` | `get_retriever_for("projects", filter={"project_id": ...})` | idem ; si `project_id` manquant, retour avec `error` |
145
+
146
+ Le paramètre `k` est borné entre 1 et 8. Les documents sont sérialisés avec texte, source, page, type, contact, etc.
147
+
148
+ ---
149
+
150
+ ## Apparté : service d’agents et registry
151
+
152
+ ### Rôle du registry — `services/agent_registry.py`
153
+
154
+ `AgentRegistry` maintient :
155
+
156
+ - `_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).
157
+ - `_descriptions` : texte exposé aux clients via l’API.
158
+
159
+ Agents enregistrés par défaut :
160
+
161
+ - `v1` → `create_orchestrated_graph` (`graphs/workflows/orchestrated.py`)
162
+ - `v2` → `create_orchestrated_graph_v2` (`graphs/workflows/orchestrated_v2.py`)
163
+
164
+ Méthodes importantes :
165
+
166
+ - **`register_agent(agent, builder, description="")`** — Enregistre ou remplace un builder (`agent` est strip + lower).
167
+ - **`get_builder_for_request(agent=None)`** — Si `agent` est absent, équivalent à `"V2"`. Clé = `agent.strip().lower()`. Lève `ValueError` si l’id est inconnu.
168
+ - **`resolve_agent_id(agent=None)`** — Retourne l’identifiant **canonique** pour les métadonnées API : `(agent or "V2").strip().upper()` (ex. `V2`, `V1`).
169
+ - **`list_agents()`** — Liste triée des agents avec `type`, `name`, `description`, `available`.
170
+
171
+ Singleton exporté : `agent_registry`.
172
+
173
+ ### Rôle du service — `services/agent_service.py`
174
+
175
+ `AgentService` fait le lien entre la couche HTTP (ou voice) et LangGraph :
176
+
177
+ 1. Instancie le LLM via `llm_service.get_llm(...)` (streaming ou non).
178
+ 2. **`resolved_agent = agent_registry.resolve_agent_id(agent)`** puis **`builder = agent_registry.get_builder_for_request(agent=resolved_agent)`**
179
+ — Remarque : `get_builder_for_request` re-normalise en minuscules, donc `V2` et `v2` sont équivalents.
180
+ 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`.
181
+ 4. État initial typique : `{"messages": ..., "query": message, "project_id": project_id}`.
182
+ 5. Post-traitement : `normalize_usage`, `RunContext`, orchestrateur de post-processing (`build_orchestrator().run(ctx)`), enrichissement de `metadata`.
183
+
184
+ **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.
185
+
186
+ Instance singleton : `agent_service`.
187
+
188
+ ### Exposition HTTP
189
+
190
+ - Les routes de completion utilisent `agent_service.invoke` / `agent_service.stream` avec le paramètre `agent` (optionnel, défaut implicite V2 côté registry).
191
+ - **`GET /agents`** (`api/routes/models.py`) appelle `agent_registry.list_agents()` pour lister les types enregistrés.
192
+
193
+ ### Voice
194
+
195
+ `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.
196
+
197
+ ### Ajouter un agent V3 (rappel procédural)
198
+
199
+ 1. Implémenter `create_orchestrated_graph_v3(llm, checkpointer=None)` (ou signature alignée sur le service).
200
+ 2. `agent_registry.register_agent("v3", create_orchestrated_graph_v3, description="...")` ou modifier `AgentRegistry.__init__`.
201
+ 3. Aucune modification obligatoire des routes si le client passe `agent=v3` et que le builder est enregistré.
202
+
203
+ ---
204
+
205
+ ## Fichiers clés (référence rapide)
206
+
207
+ | Chemin | Contenu |
208
+ |--------|---------|
209
+ | `graphs/workflows/orchestrated_v2.py` | Construction du graphe V2 et câblage des prompts |
210
+ | `graphs/state.py` | `AgentState` |
211
+ | `graphs/models.py` | `QueryClassification` |
212
+ | `graphs/agents/classifier_agent.py` | Nœud classify |
213
+ | `graphs/agents/chat_tools_agent.py` | Nœud tools_agent |
214
+ | `graphs/agents/summarizer_agent.py` | Nœuds summarizer |
215
+ | `graphs/tools/retrieval_tools.py` | Définitions `@tool` |
216
+ | `graphs/prompts_v2/*.md` | Prompts éditables |
217
+ | `graphs/prompts_v2/loader.py` | Chargement + cache |
218
+ | `services/agent_registry.py` | Registre des graphes |
219
+ | `services/agent_service.py` | Invocation, streaming, mémoire, métadonnées |
220
+
221
+ ---
222
+
223
+ ## Résumé
224
+
225
+ 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.
graphs/agents/chat_tools_agent.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V2 chat node with tool-calling retrieval autonomy."""
2
+ import json
3
+ from typing import Any, Callable, Dict, List
4
+
5
+ from langchain_core.language_models.chat_models import BaseChatModel
6
+ from langchain_core.messages import AIMessage, BaseMessage, SystemMessage, ToolMessage
7
+
8
+ from graphs.prompts import SYSTEM_PROMPT_TEMPLATE
9
+ from graphs.state import AgentState
10
+ from graphs.tools.retrieval_tools import (
11
+ search_formations,
12
+ search_prestations,
13
+ search_project_docs,
14
+ )
15
+
16
+
17
+ def chat_with_tools_node(
18
+ llm: BaseChatModel,
19
+ max_tool_calls_per_turn: int = 3,
20
+ base_system_prompt: str = SYSTEM_PROMPT_TEMPLATE,
21
+ tools_policy_prompt: str = (
22
+ "Tu peux utiliser des tools pour recuperer des documents. "
23
+ "Utilise-les uniquement si necessaire. "
24
+ "Tools disponibles: search_formations, search_prestations, search_project_docs. "
25
+ "Si project_id n'est pas disponible, n'utilise pas search_project_docs. "
26
+ "Reponds avec des recommandations sourcees quand des resultats tools sont utilises."
27
+ ),
28
+ ) -> Callable[[AgentState], AgentState]:
29
+ """Factory returning a node that lets the model call retrieval tools.
30
+
31
+ Tools available:
32
+ - search_formations
33
+ - search_prestations
34
+ - search_project_docs (requires project_id)
35
+ """
36
+ tools = [search_formations, search_prestations, search_project_docs]
37
+ llm_with_tools = llm.bind_tools(tools)
38
+ tool_by_name = {t.name: t for t in tools}
39
+
40
+ def _extract_query(state: AgentState) -> str:
41
+ q = (state.get("query") or "").strip()
42
+ if q:
43
+ return q
44
+ for msg in reversed(list(state.get("messages", []))):
45
+ if getattr(msg, "type", "") == "human":
46
+ return (getattr(msg, "content", "") or "").strip()
47
+ return ""
48
+
49
+ def _run(state: AgentState) -> AgentState:
50
+ history = list(state.get("messages", []))
51
+ query = _extract_query(state)
52
+ project_id = state.get("project_id")
53
+
54
+ system_msgs: List[BaseMessage] = [
55
+ SystemMessage(content=base_system_prompt),
56
+ SystemMessage(content=tools_policy_prompt),
57
+ ]
58
+ if project_id:
59
+ system_msgs.append(
60
+ SystemMessage(
61
+ content=(
62
+ "CONTEXTE SYSTEME - Projet lie a la requete:\n"
63
+ f"project_id={project_id}\n\n"
64
+ "Ce project_id est deja fourni par l'application. "
65
+ "Ne demande JAMAIS a l'utilisateur de le communiquer. "
66
+ "Pour toute question sur l'exploitant, le contexte ou les echanges enregistres, "
67
+ "utilise en priorite l'outil search_project_docs. "
68
+ "Tu peux omettre project_id dans les arguments de l'outil : "
69
+ "le serveur l'injecte automatiquement depuis ce contexte."
70
+ )
71
+ )
72
+ )
73
+
74
+ conversation: List[BaseMessage] = system_msgs + history
75
+ collected_documents: List[Dict[str, Any]] = list(state.get("documents", [])) # type: ignore[arg-type]
76
+
77
+ tool_calls_count = 0
78
+ last_ai: AIMessage
79
+ while True:
80
+ ai = llm_with_tools.invoke(conversation)
81
+ if not isinstance(ai, AIMessage):
82
+ ai = AIMessage(content=getattr(ai, "content", "") or "")
83
+ conversation.append(ai)
84
+ last_ai = ai
85
+
86
+ tool_calls = getattr(ai, "tool_calls", None) or []
87
+ if not tool_calls:
88
+ break
89
+ if tool_calls_count >= max_tool_calls_per_turn:
90
+ break
91
+
92
+ for call in tool_calls:
93
+ if tool_calls_count >= max_tool_calls_per_turn:
94
+ break
95
+ tool_name = call.get("name")
96
+ tool_args = call.get("args") or {}
97
+ tool_id = call.get("id") or f"tool_{tool_calls_count}"
98
+
99
+ if tool_name not in tool_by_name:
100
+ tool_result: Dict[str, Any] = {
101
+ "error": f"Unknown tool: {tool_name}",
102
+ "tool": tool_name,
103
+ }
104
+ else:
105
+ # Inject project_id for project tool when omitted.
106
+ if tool_name == "search_project_docs":
107
+ tool_args = dict(tool_args)
108
+ tool_args.setdefault("project_id", project_id)
109
+ try:
110
+ tool_result = tool_by_name[tool_name].invoke(tool_args) # type: ignore[assignment]
111
+ except Exception as exc:
112
+ tool_result = {"error": str(exc), "tool": tool_name}
113
+
114
+ # Keep a compact document trace for API streaming metadata.
115
+ try:
116
+ if isinstance(tool_result, dict):
117
+ sources = tool_result.get("sources")
118
+ if isinstance(sources, list):
119
+ collected_documents.extend(sources)
120
+ except Exception:
121
+ pass
122
+
123
+ conversation.append(
124
+ ToolMessage(
125
+ content=json.dumps(tool_result, ensure_ascii=False),
126
+ tool_call_id=tool_id,
127
+ )
128
+ )
129
+ tool_calls_count += 1
130
+
131
+ return {
132
+ "messages": history + [last_ai],
133
+ "documents": collected_documents,
134
+ "query": query,
135
+ }
136
+
137
+ return _run
138
+
graphs/agents/classifier_agent.py CHANGED
@@ -9,12 +9,15 @@ from graphs.models import QueryClassification
9
  from graphs.prompts import CLASSIFIER_SYSTEM_PROMPT
10
 
11
 
12
- def classifier_node(llm: BaseChatModel) -> Callable[[AgentState], AgentState]:
 
 
 
13
  print("Classifier node")
14
 
15
  prompt = ChatPromptTemplate.from_messages(
16
  [
17
- ("system", CLASSIFIER_SYSTEM_PROMPT),
18
  (
19
  "human",
20
  "Historique: {messages}\nQuestion: {query}",
 
9
  from graphs.prompts import CLASSIFIER_SYSTEM_PROMPT
10
 
11
 
12
+ def classifier_node(
13
+ llm: BaseChatModel,
14
+ system_prompt: str = CLASSIFIER_SYSTEM_PROMPT,
15
+ ) -> Callable[[AgentState], AgentState]:
16
  print("Classifier node")
17
 
18
  prompt = ChatPromptTemplate.from_messages(
19
  [
20
+ ("system", system_prompt),
21
  (
22
  "human",
23
  "Historique: {messages}\nQuestion: {query}",
graphs/agents/summarizer_agent.py CHANGED
@@ -15,6 +15,7 @@ from langchain_core.messages import BaseMessage, SystemMessage, AIMessage
15
 
16
  def summarizer_llm_node(
17
  llm: BaseChatModel,
 
18
  ):
19
  """Node: ask the LLM to generate a Markdown summary from the conversation/context.
20
 
@@ -25,7 +26,7 @@ def summarizer_llm_node(
25
  def _run(state: AgentState) -> AgentState:
26
  messages = list(state.get("messages", []))
27
 
28
- sys = SystemMessage(content=SUMMARIZER_SYSTEM_PROMPT)
29
  response = llm.invoke([sys] + messages)
30
  summary_markdown = response.content or ""
31
 
 
15
 
16
  def summarizer_llm_node(
17
  llm: BaseChatModel,
18
+ system_prompt: str = SUMMARIZER_SYSTEM_PROMPT,
19
  ):
20
  """Node: ask the LLM to generate a Markdown summary from the conversation/context.
21
 
 
26
  def _run(state: AgentState) -> AgentState:
27
  messages = list(state.get("messages", []))
28
 
29
+ sys = SystemMessage(content=system_prompt)
30
  response = llm.invoke([sys] + messages)
31
  summary_markdown = response.content or ""
32
 
graphs/prompts_v2/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """V2 prompts package."""
2
+ from .loader import load_v2_prompt
3
+
4
+ __all__ = ["load_v2_prompt"]
5
+
graphs/prompts_v2/chat_system.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CONTEXTE:
2
+ Tu es l'assistant IA des conseillers agricoles de la Chambre d'Agriculture des Pays de la Loire.
3
+ Date du jour: {{TODAY_DATE}}
4
+
5
+ OBJECTIFS:
6
+ Tu aides les conseillers agricoles a apporter le meilleur conseil pour aider l'agriculteur a atteindre ses objectifs tout en developpant la vente de prestations et formations de la Chambre d'Agriculture des Pays de la Loire.
7
+
8
+ RESSOURCES :
9
+ Tu disposes de deux catalogues qui sont les sources uniques que tu dois utiliser.
10
+ - Catalogue SERVICE 2024 2025 : Contient tous les services que tu peux proposer pour atteindre les objectifs
11
+ - Catalogue FORMATION 2025 2026 : Contient toutes les formations que tu peux proposer pour atteindre les objectifs
12
+ Tu peux accéder à ces ressources via les tools : 'search_formations' et 'search_prestations'
13
+
14
+ CONTEXTE PROJET:
15
+ Quand un projet est lié à la conversation (project_id disponible), les documents projet peuvent contenir:
16
+ - des informations sur l'agriculteur/exploitant et son contexte,
17
+ - des transcriptions d'echanges entre le conseiller et l'agriculteur/exploitant.
18
+ Ces informations sont prioritaires pour comprendre la situation réelle avant de recommander des prestations/formations.
19
+
20
+ TACHES :
21
+ 1.Tu dois veiller à avoir suffisamment d'informations dans la conversation afin de prescrire le meilleur couple win – win entre objectifs de l'agriculteur et prestations formations de la Chambre d'Agriculture des Pays de la Loire
22
+ 2.Tu dois proposer ensuite proposer la liste des services et formations pertinentes pour le contexte que tu auras validé
23
+
24
+
25
+ CONTRAINTES:
26
+ - N'invente rien.
27
+ - Tu dois t'appuyer en priorite sur les resultats des tools quand ils sont utilises.
28
+ - Si project_id est disponible, commence par exploiter search_project_docs pour recuperer le contexte projet.
29
+ - Utilise ensuite search_formations et search_prestations pour completer les recommandations selon le contexte projet.
30
+ - Tu organises la liste des recommandations de prestations et de formations selon un ordre logique.
31
+ - Cite la source et la page quand l'information provient des documents recuperes.
32
+ - Reste professionnel, concis et actionnable.
33
+ - Concernant les formations, tu contrôles que les dates des formations proposées sont postérieures à la date du jour. Si oui tu affiches alors la ou les dates, si non tu affiches un libellé « Contacter le service formation pour connaître la prochaine date »
34
+ - Si les informations sont insuffisantes, pose des questions de clarification.
35
+
36
+
37
+ FORMAT:
38
+ Présente les résultats sous forme de deux listes :
39
+ - Rappel des enjeux, besoins, ou problématique de l'agriculteur/exploitant
40
+ - Liste des prestations de services pertinentes avec mention du nom de la prestation, de la page exacte dans le catalogue de services, et le nom et téléphone du contact associé au service.
41
+ - Liste des formations pertinentes avec mention de nom de la formation, son contenu si présent, les dates à venir avec les lieux, le nom et téléphone du contact/service associé à la formation.
42
+ - Propose selon le contexte trois questions pertinentes pour aider le conseiller à maitriser les arguments de la vente
graphs/prompts_v2/classifier_system.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ROLE:
2
+ Tu es un classificateur pour le workflow V2.
3
+
4
+ TACHE:
5
+ Determiner si la demande utilisateur releve de:
6
+ - CLASSIC: conversation/conseil/recommandations standard
7
+ - SUMMARIZE: demande de synthese, resume, impression/export de synthese
8
+ - UNKNOWN: ambigu ou hors-sujet
9
+
10
+ SORTIE:
11
+ Retourne strictement une classification parmi : CLASSIC, SUMMARIZE ou UNKNOWN
12
+ et une justification courte.
13
+
14
+ CONTEXTE:
15
+ Prends en compte l'historique complet et le dernier message utilisateur.
graphs/prompts_v2/loader.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt loader for V2 markdown prompts."""
2
+ from functools import lru_cache
3
+ from pathlib import Path
4
+
5
+
6
+ _PROMPTS_DIR = Path(__file__).resolve().parent
7
+
8
+
9
+ @lru_cache(maxsize=16)
10
+ def load_v2_prompt(filename: str) -> str:
11
+ path = _PROMPTS_DIR / filename
12
+ if not path.exists():
13
+ raise FileNotFoundError(f"V2 prompt file not found: {path}")
14
+ return path.read_text(encoding="utf-8").strip()
15
+
graphs/prompts_v2/summarizer_system.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ROLE:
2
+ Tu es un agent de synthese V2.
3
+
4
+ OBJECTIF:
5
+ Produire une synthese claire et actionnable de la conversation en Markdown structure.
6
+
7
+ FORMAT ATTENDU:
8
+ - # Titre: Synthese de l'entretien
9
+ - ## Contexte
10
+ - ## Objectifs de l'agriculteur
11
+ - ## Recommandations
12
+ - Prestations (nom, page, source, contact)
13
+ - Formations (nom, page, source, contact, prochaine date ou message par defaut)
14
+ - ## Prochaines etapes
15
+
16
+ CONTRAINTES:
17
+ - N'invente rien.
18
+ - Cite les pages et les sources quand disponibles.
19
+ - Sois concis, professionnel et structure.
graphs/prompts_v2/tools_policy.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ POLITIQUE TOOLS (V2):
2
+
3
+ Tools disponibles:
4
+ - search_formations(query, k)
5
+ - search_prestations(query, k)
6
+ - search_project_docs(query, project_id, k)
7
+
8
+ Regles d'utilisation:
9
+ - Les tools formations et prestations sont toujours disponibles.
10
+ - Utilise search_project_docs seulement si project_id est disponible.
11
+ - Si un message systeme indique deja un project_id, ne demande jamais ce project_id a l'utilisateur.
12
+ - Si project_id est disponible, utilise search_project_docs en priorite pour etablir le contexte de l'agriculteur/exploitant.
13
+ - Considere les transcriptions de conversation projet comme source de contexte metier prioritaire.
14
+ - Apres recuperation du contexte projet, utilise search_formations/search_prestations pour proposer des recommandations adaptees.
15
+ - N'appelle les tools que lorsque c'est utile pour repondre precisement.
16
+ - Limite le nombre d'appels tools et evite les appels redondants.
17
+ - Quand un tool retourne des sources, appuie ta reponse dessus.
graphs/state.py CHANGED
@@ -1,5 +1,5 @@
1
  """Shared state types for LangGraph agents and workflows."""
2
- from typing import TypedDict, Annotated, Sequence, List, Optional
3
  from langchain_core.messages import BaseMessage
4
  from langgraph.graph.message import add_messages
5
  from langchain_core.documents import Document
@@ -25,6 +25,8 @@ class AgentState(TypedDict, total=False):
25
  prestation_context: str
26
  project_docs: List[Document]
27
  project_context: str
 
 
28
  # Summarization artifacts
29
  summary_markdown: str
30
  summary_pdf_path: str # local path or URL if uploaded
 
1
  """Shared state types for LangGraph agents and workflows."""
2
+ from typing import TypedDict, Annotated, Sequence, List, Optional, Dict, Any
3
  from langchain_core.messages import BaseMessage
4
  from langgraph.graph.message import add_messages
5
  from langchain_core.documents import Document
 
25
  prestation_context: str
26
  project_docs: List[Document]
27
  project_context: str
28
+ # Documents metadata collected from tool calls (stream/API compatibility)
29
+ documents: List[Dict[str, Any]]
30
  # Summarization artifacts
31
  summary_markdown: str
32
  summary_pdf_path: str # local path or URL if uploaded
graphs/tools/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Tools package for graph-level callable tools."""
2
+
graphs/tools/retrieval_tools.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retriever tools for V2 tool-calling agent."""
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from langchain_core.documents import Document
5
+ from langchain_core.tools import tool
6
+
7
+ from retrievers.supabase import format_documents, get_retriever, get_retriever_for
8
+
9
+
10
+ def _clamp_k(k: int, default: int = 8, min_k: int = 1, max_k: int = 8) -> int:
11
+ try:
12
+ value = int(k)
13
+ except Exception:
14
+ value = default
15
+ return max(min_k, min(max_k, value))
16
+
17
+
18
+ def _serialize_docs(docs: List[Document], max_items: int = 8) -> List[Dict[str, Any]]:
19
+ items: List[Dict[str, Any]] = []
20
+ for doc in docs[:max_items]:
21
+ meta = doc.metadata or {}
22
+ items.append(
23
+ {
24
+ "text": doc.page_content or "",
25
+ "source": meta.get("source"),
26
+ "page_number": meta.get("page_number"),
27
+ "type": meta.get("type"),
28
+ "contact": meta.get("contact"),
29
+ "metadata": meta,
30
+ }
31
+ )
32
+ return items
33
+
34
+
35
+ @tool("search_formations")
36
+ def search_formations(query: str, k: int = 8) -> Dict[str, Any]:
37
+ """Search formation catalogue documents by semantic similarity.
38
+
39
+ Use when user needs training recommendations or details.
40
+ """
41
+ top_k = _clamp_k(k)
42
+ retriever = get_retriever("formation", k=top_k)
43
+ docs = retriever.invoke(query or "")
44
+ return {
45
+ "tool": "search_formations",
46
+ "count": len(docs),
47
+ "items": _serialize_docs(docs, max_items=top_k),
48
+ "context": format_documents(docs, "formation"),
49
+ "sources": [d.metadata or {} for d in docs[:top_k]],
50
+ }
51
+
52
+
53
+ @tool("search_prestations")
54
+ def search_prestations(query: str, k: int = 8) -> Dict[str, Any]:
55
+ """Search service/prestation catalogue documents by semantic similarity.
56
+
57
+ Use when user needs service recommendations or details.
58
+ """
59
+ top_k = _clamp_k(k)
60
+ retriever = get_retriever("prestation", k=top_k)
61
+ docs = retriever.invoke(query or "")
62
+ return {
63
+ "tool": "search_prestations",
64
+ "count": len(docs),
65
+ "items": _serialize_docs(docs, max_items=top_k),
66
+ "context": format_documents(docs, "prestation"),
67
+ "sources": [d.metadata or {} for d in docs[:top_k]],
68
+ }
69
+
70
+
71
+ @tool("search_project_docs")
72
+ def search_project_docs(query: str, project_id: Optional[str], k: int = 8) -> Dict[str, Any]:
73
+ """Search project-scoped documents by semantic similarity.
74
+
75
+ Requires a valid project_id from the request context.
76
+ """
77
+ if not project_id:
78
+ return {
79
+ "tool": "search_project_docs",
80
+ "count": 0,
81
+ "items": [],
82
+ "context": "",
83
+ "sources": [],
84
+ "error": "project_id is required",
85
+ }
86
+
87
+ top_k = _clamp_k(k)
88
+ retriever = get_retriever_for("projects", k=top_k, filter={"project_id": project_id})
89
+ docs = retriever.invoke(query or "")
90
+ return {
91
+ "tool": "search_project_docs",
92
+ "count": len(docs),
93
+ "items": _serialize_docs(docs, max_items=top_k),
94
+ "context": format_documents(docs, "project"),
95
+ "sources": [d.metadata or {} for d in docs[:top_k]],
96
+ }
97
+
graphs/workflows/orchestrated_v2.py CHANGED
@@ -1,41 +1,28 @@
1
- """Orchestrated V2 workflow.
2
-
3
- V2 is intentionally isolated from V1 for safe incremental rollout.
4
- Current behavior mirrors V1 and can evolve independently.
5
- """
6
  from langgraph.graph import StateGraph, END
7
  from langchain_core.language_models.chat_models import BaseChatModel
8
 
9
  from graphs.state import AgentState
10
  from graphs.agents.classifier_agent import classifier_node
11
- from graphs.nodes.retrieval import retrieve_catalogue, retrieve_projects
12
- from graphs.agents.chat_agent import chat_node
13
  from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
14
- from tools.pdf import markdown_to_pdf
15
- from tools.storage import upload_pdf_to_supabase
16
 
17
 
18
- def create_orchestrated_graph_v2(llm: BaseChatModel, checkpointer=None):
 
 
 
 
 
19
  workflow = StateGraph(AgentState)
20
 
21
- workflow.add_node("classify", classifier_node(llm))
22
- workflow.add_node("retrieve", retrieve_catalogue)
23
-
24
- def _router_passthrough(state: AgentState) -> AgentState:
25
- q = state.get("query") or ""
26
- return {"query": q}
27
-
28
- workflow.add_node("retrieve_router", _router_passthrough)
29
- workflow.add_node("retrieve_project", retrieve_projects)
30
- workflow.add_node("agent", chat_node(llm))
31
- workflow.add_node("summarizer_llm", summarizer_llm_node(llm))
32
- workflow.add_node(
33
- "summarizer_export",
34
- summarizer_export_node(
35
- markdown_to_pdf=markdown_to_pdf,
36
- upload_pdf=upload_pdf_to_supabase,
37
- ),
38
- )
39
 
40
  workflow.set_entry_point("classify")
41
 
@@ -43,27 +30,47 @@ def create_orchestrated_graph_v2(llm: BaseChatModel, checkpointer=None):
43
  "classify",
44
  lambda s: getattr(s.get("classification"), "classification", "CLASSIC"),
45
  {
46
- "CLASSIC": "retrieve_router",
47
  "SUMMARIZE": "summarizer_llm",
48
- "UNKNOWN": "retrieve_router",
49
  },
50
  )
51
 
52
- workflow.add_conditional_edges(
53
- "retrieve_router",
54
- lambda s: "PROJECT" if s.get("project_id") else "CLASSIC",
55
- {
56
- "PROJECT": "retrieve_project",
57
- "CLASSIC": "retrieve",
58
- },
59
- )
60
-
61
- workflow.add_edge("retrieve_project", "retrieve")
62
- workflow.add_edge("retrieve", "agent")
63
- workflow.add_edge("agent", END)
64
 
65
  workflow.add_edge("summarizer_llm", "summarizer_export")
66
  workflow.add_edge("summarizer_export", END)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  return workflow.compile(checkpointer=checkpointer)
69
 
 
1
+ """Orchestrated V2 workflow with tool-calling retrieval autonomy."""
2
+ from datetime import datetime
 
 
 
3
  from langgraph.graph import StateGraph, END
4
  from langchain_core.language_models.chat_models import BaseChatModel
5
 
6
  from graphs.state import AgentState
7
  from graphs.agents.classifier_agent import classifier_node
8
+ from graphs.agents.chat_tools_agent import chat_with_tools_node
 
9
  from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
10
+ from graphs.prompts_v2 import load_v2_prompt
11
+ from typing import Callable
12
 
13
 
14
+ def _build_v2_workflow(
15
+ classify_runner: Callable[[AgentState], AgentState],
16
+ tools_agent_runner: Callable[[AgentState], AgentState],
17
+ summarizer_llm_runner: Callable[[AgentState], AgentState],
18
+ summarizer_export_runner: Callable[[AgentState], AgentState],
19
+ ):
20
  workflow = StateGraph(AgentState)
21
 
22
+ workflow.add_node("classify", classify_runner)
23
+ workflow.add_node("tools_agent", tools_agent_runner)
24
+ workflow.add_node("summarizer_llm", summarizer_llm_runner)
25
+ workflow.add_node("summarizer_export", summarizer_export_runner)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  workflow.set_entry_point("classify")
28
 
 
30
  "classify",
31
  lambda s: getattr(s.get("classification"), "classification", "CLASSIC"),
32
  {
33
+ "CLASSIC": "tools_agent",
34
  "SUMMARIZE": "summarizer_llm",
35
+ "UNKNOWN": "tools_agent",
36
  },
37
  )
38
 
39
+ workflow.add_edge("tools_agent", END)
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  workflow.add_edge("summarizer_llm", "summarizer_export")
42
  workflow.add_edge("summarizer_export", END)
43
+ return workflow
44
+
45
+
46
+ def create_orchestrated_graph_v2(llm: BaseChatModel, checkpointer=None):
47
+ # Lazy imports keep module importable in notebook contexts that do not
48
+ # need runtime PDF generation dependencies.
49
+ from tools.pdf import markdown_to_pdf
50
+ from tools.storage import upload_pdf_to_supabase
51
+
52
+ chat_prompt_v2_template = load_v2_prompt("chat_system.md")
53
+ today_date = datetime.now().strftime("%d/%m/%Y")
54
+ chat_prompt_v2 = chat_prompt_v2_template.replace("{{TODAY_DATE}}", today_date)
55
+ tools_policy_v2 = load_v2_prompt("tools_policy.md")
56
+ classifier_prompt_v2 = load_v2_prompt("classifier_system.md")
57
+ summarizer_prompt_v2 = load_v2_prompt("summarizer_system.md")
58
 
59
+ workflow = _build_v2_workflow(
60
+ classify_runner=classifier_node(llm, system_prompt=classifier_prompt_v2),
61
+ tools_agent_runner=chat_with_tools_node(
62
+ llm,
63
+ base_system_prompt=chat_prompt_v2,
64
+ tools_policy_prompt=tools_policy_v2,
65
+ ),
66
+ summarizer_llm_runner=summarizer_llm_node(
67
+ llm,
68
+ system_prompt=summarizer_prompt_v2,
69
+ ),
70
+ summarizer_export_runner=summarizer_export_node(
71
+ markdown_to_pdf=markdown_to_pdf,
72
+ upload_pdf=upload_pdf_to_supabase,
73
+ ),
74
+ )
75
  return workflow.compile(checkpointer=checkpointer)
76
 
services/agent_service.py CHANGED
@@ -216,6 +216,13 @@ class AgentService:
216
  summarizer_export = node.get("summarizer_export")
217
  if summarizer_export and isinstance(summarizer_export, dict):
218
  messages = summarizer_export.get("messages", [])
 
 
 
 
 
 
 
219
 
220
  # Get the latest message, if available, from the messages list
221
  last_message = messages[-1] if messages else None
 
216
  summarizer_export = node.get("summarizer_export")
217
  if summarizer_export and isinstance(summarizer_export, dict):
218
  messages = summarizer_export.get("messages", [])
219
+ tools_agent = node.get("tools_agent")
220
+ if tools_agent and isinstance(tools_agent, dict):
221
+ tool_documents = tools_agent.get("documents", [])
222
+ if isinstance(tool_documents, list):
223
+ for doc in tool_documents:
224
+ if doc is not None and doc not in documents:
225
+ documents.append(doc)
226
 
227
  # Get the latest message, if available, from the messages list
228
  last_message = messages[-1] if messages else None
services/vectorstore_service.py CHANGED
@@ -25,7 +25,23 @@ class VectorStoreServiceError(Exception):
25
 
26
 
27
  class PatchedSupabaseVectorStore(SupabaseVectorStore):
28
- """Fixes postgrest 2.28+ incompatibility where .params moved to .request.params."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  def similarity_search_by_vector_with_relevance_scores(
31
  self,
@@ -56,11 +72,9 @@ class PatchedSupabaseVectorStore(SupabaseVectorStore):
56
  # #endregion
57
 
58
  if postgrest_filter:
59
- query_builder.request.params = query_builder.request.params.set(
60
- "and", f"({postgrest_filter})"
61
- )
62
 
63
- query_builder.request.params = query_builder.request.params.set("limit", k)
64
 
65
  res = query_builder.execute()
66
 
 
25
 
26
 
27
  class PatchedSupabaseVectorStore(SupabaseVectorStore):
28
+ """Compatibility patch across postgrest builder API variants."""
29
+
30
+ @staticmethod
31
+ def _set_query_param(query_builder: Any, key: str, value: Any) -> None:
32
+ """Set query params on both legacy and newer builder shapes."""
33
+ if hasattr(query_builder, "params"):
34
+ query_builder.params = query_builder.params.set(key, value)
35
+ return
36
+
37
+ request_obj = getattr(query_builder, "request", None)
38
+ if request_obj is not None and hasattr(request_obj, "params"):
39
+ request_obj.params = request_obj.params.set(key, value)
40
+ return
41
+
42
+ raise AttributeError(
43
+ f"Unsupported RPC query builder shape: {type(query_builder).__name__}"
44
+ )
45
 
46
  def similarity_search_by_vector_with_relevance_scores(
47
  self,
 
72
  # #endregion
73
 
74
  if postgrest_filter:
75
+ self._set_query_param(query_builder, "and", f"({postgrest_filter})")
 
 
76
 
77
+ self._set_query_param(query_builder, "limit", k)
78
 
79
  res = query_builder.execute()
80