9 Commits

Author SHA1 Message Date
33be2a7ac8 Gestion du contexte/mémoire courte
Gestion de la mémoire courte
2026-02-09 19:11:10 +01:00
8655359add Architecture de la réduction contexte
Architecture uniquement, pas implémenté
2026-02-09 00:11:52 +01:00
8b32c0ac64 Start conditionnel
Permet de ne pas regénérer tous les résumés, et de gagner du temps et des tokens
2026-02-08 22:50:09 +01:00
bdf5b7dd98 Renamme node central 2026-02-08 22:21:51 +01:00
e0bd50a15b User prompt node
Permet de demander un prompt à l'utilisateur
2026-02-08 18:58:27 +01:00
82a5491188 Nouveau node inject_preparation_prompt
Ne change rien, mais simplifie le système
2026-02-08 15:33:48 +01:00
ea314e5c5c Moved agent.png 2026-02-08 15:21:09 +01:00
fc7f692ba3 Noeud de préparation des documents
Pas encore totalement fonctionnel, mais déjà de bons résultats là dessus
2026-02-08 14:41:22 +01:00
52bf2d5a82 Outils de préparation des résumés
Permet de générer les rapports de chaque semaine du stage
2026-02-08 13:28:54 +01:00
12 changed files with 278 additions and 35 deletions

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ mlflow.db
# Par sécurité
documents_projet/
chroma_db/
AgentReact/rapports_resumes/
AgentReact/outils_resumes/
# Python
__pycache__/

View File

@@ -2,7 +2,7 @@ from langgraph.graph import START, END
from langgraph.graph.state import CompiledStateGraph
from langgraph.checkpoint.memory import InMemorySaver
from utils.nodes import call_to_LLM, should_continue, task_ended, BasicToolNode, tool_node
from utils.nodes import *
from utils.state import getState
from utils.tools import getTools
@@ -16,13 +16,35 @@ def getGraph()->CompiledStateGraph:
workflow = getState() # State prêt à utiliser
# Définition des sommets du graphe
workflow.add_node(call_to_LLM)
workflow.add_node(user_prompt)
workflow.add_node(LLM_central)
workflow.add_node(preparation_docs)
workflow.add_node(inject_preparation_prompt)
workflow.add_node("tool_node", tool_node)# BasicToolNode(tools=getTools())) # N'est pas une fonction, mais une classe instanciée, je dois précisier le nom du node
workflow.add_node("weekly_report_tools", weekly_report_tools)
workflow.add_node(context_shortener) # Réduit la taille du contexte
workflow.add_node("context_shortener_2", context_shortener) # Le même, sous un autre nom pour le différencier dans le graphe
# Arrêtes
workflow.set_entry_point("call_to_LLM")
workflow.add_edge("tool_node", "call_to_LLM")
workflow.add_conditional_edges("call_to_LLM", should_continue, {
workflow.set_conditional_entry_point(is_resumes_reports_already_initialised, {
"résumés non disponibles": "inject_preparation_prompt", # Résumés non générés
"résumés déjà générés": "user_prompt" # Résumés déjà prêts, je peux aller direct à la partie principale
})
workflow.add_edge("inject_preparation_prompt", "preparation_docs")
workflow.add_conditional_edges("preparation_docs", should_continue, {
"tools":"weekly_report_tools",
"no_tools":"context_shortener" # FIN de la préparation, on réduit le contexte avant de passer à la suite
})
workflow.add_edge("context_shortener", "user_prompt") # Et ici, je rejoins la partie principale qui rédigera le rapport
workflow.add_edge("user_prompt", "LLM_central")
workflow.add_edge("weekly_report_tools", "preparation_docs")
workflow.add_conditional_edges("tool_node", should_shorten, {
'sous la limite': "LLM_central",
'réduire contexte': "context_shortener_2"
})
workflow.add_edge("context_shortener_2", "LLM_central")
workflow.add_conditional_edges("LLM_central", should_continue, {
"tools":"tool_node",
"no_tools":END
})
@@ -31,4 +53,4 @@ def getGraph()->CompiledStateGraph:
if __name__ == "__main__":
# Affichage du graphe
getGraph().get_graph().draw_mermaid_png(output_file_path="agent.png")
getGraph().get_graph().draw_mermaid_png(output_file_path="imgs/agent.png")

View File

@@ -13,7 +13,7 @@ mlflow.set_experiment("TEST PROJET") # VOIR AVEC LA COMMANDE "MLFLOW SERVER"
mlflow.langchain.autolog()
initial_input = {
'messages':[HumanMessage("Recherche 'Recette de Monster' sur internet")]
'messages':[SystemMessage("Salut")]
}
config={"configurable": {"thread_id": 'yes'}}

View File

@@ -10,9 +10,21 @@ class InterruptPayload():
#EDITED = 2
DENIED = 3
def __init__(self, fields:Dict, state:int=0):
TOOL_CALL = 999
USER_PROMPT = 998
def __init__(self, fields:Dict, state:int=0, payload_type:int=TOOL_CALL):
"""
Créer unne nouvelle instance de payload pour interrupt()
Args:
fields (Dict): Un dictionnaire d'arguments pour un call d'outil, ou {'prompt':str} pour une requête de prompt
state (int, optional): État de la requête. Defaults to 0. Définit en variables statiques de l'objet.
payload_type (int, optional): Type d'interuption, appel d'outil ou requête humaine. Defaults to TOOL_CALL. Définit en variables statiques de l'objet.
"""
self.__fields = fields
self.__state = state
self.__type = payload_type
def get(self, key:str)->str:
"""
@@ -39,7 +51,22 @@ class InterruptPayload():
"""
Afficher la requête proprement, permettant à l'utilisateur d'accepter, refuser ou modifier une requête
"""
if self.__type == InterruptPayload.USER_PROMPT: # C'est une demande de prompt humain
self.__human_prompt_display()
else: # C'est un appel d'outil
self.__tool_query_display()
def __human_prompt_display(self):
print("=== L'AGENT DEMANDE DES CONSIGNES! ===\n")
print("Veuillez saisir un prompt pour l'agent...\n")
prompt = input("Prompt...")
self.__fields = {'prompt': prompt}
print("\nMerci, l'exécution va reprendre.\n")
print("======")
def __tool_query_display(self):
print("=== L'AGENT DEMANDE À UTILISER UN OUTIL RESTREINT! ===\n")
keys = list(self.__fields.keys())
@@ -86,7 +113,7 @@ class InterruptPayload():
Returns:
str: String sérialisable via la méthode statique InterruptPayload.strImport(string)
"""
return '{"state":'+ str(self.__state) +', "fields": ' + json.dumps(self.__fields, ensure_ascii=False, indent=indent) +'}'
return '{"state":'+ str(self.__state) +', "type": '+str(self.__type)+', "fields": ' + json.dumps(self.__fields, ensure_ascii=False, indent=indent) +'}'
@staticmethod
@@ -104,8 +131,9 @@ class InterruptPayload():
state_ = data.get("state", 0)
fields_ = data.get("fields", {})
type_ = data.get("type", InterruptPayload.TOOL_CALL)
return InterruptPayload(fields=fields_, state=state_)
return InterruptPayload(fields=fields_, state=state_, payload_type=type_)

View File

@@ -5,7 +5,7 @@ from langgraph.types import Command
from .InterruptPayload import InterruptPayload
# Une fonction pour stream et gérer proprement le graphe
def streamGraph(initial_input:Dict, config:Dict, graphe:CompiledStateGraph):
def streamGraph(initial_input:Dict, config:Dict, graphe:CompiledStateGraph, lastMsgIndex=0):
# https://docs.langchain.com/oss/python/langgraph/interrupts#stream-with-human-in-the-loop-hitl-interrupts
for mode, state in graphe.stream(
initial_input,
@@ -15,8 +15,11 @@ def streamGraph(initial_input:Dict, config:Dict, graphe:CompiledStateGraph):
):
if mode == "values":
# Handle streaming message content
msg = state['messages'][-1]
i=0
for msg in state['messages'][lastMsgIndex:]: # Permet de gérer plusieurs nouveaux messages d'un coup
msg.pretty_print()
i+=1
lastMsgIndex+=i
elif mode == "updates":
# Check for interrupts
@@ -25,7 +28,7 @@ def streamGraph(initial_input:Dict, config:Dict, graphe:CompiledStateGraph):
payload = InterruptPayload.fromJSON(payload) # Chargement de la requête depuis sa version JSON
payload.humanDisplay() # L'utilisateur peut accepter/modifier/refuser ici
streamGraph(Command(resume=payload.toJSON()), config, graphe) # Je renvois la chaîne JSON, qui sera reconvertie en objet dans l'outil, et je relance le stream récursivement
streamGraph(Command(resume=payload.toJSON()), config, graphe, lastMsgIndex) # Je renvois la chaîne JSON, qui sera reconvertie en objet dans l'outil, et je relance le stream récursivement
return # Fin de cette fonction récursive
else:

View File

@@ -3,8 +3,23 @@ from langgraph.graph import MessagesState
from langgraph.prebuilt import ToolNode
from langchain.chat_models import init_chat_model
from langgraph.graph import START, END
from langchain.messages import HumanMessage, AIMessage, SystemMessage, ToolMessage
from langgraph.types import interrupt
from shutil import rmtree
import os
import sys
from pathlib import Path
import json
from .tools import getTools
from .tools import getTools, getWeeklyReportTools
from .state import CustomState
from .InterruptPayload import InterruptPayload
# Variables principales
TAILLE_CONTEXTE_MAX = 20000 #charactères
PROMPT_SUMMARY = """Tu dois résumer le message qui te sera envoyé, de façon à préserver le plus d'informations, et en deux ou trois phrases.
En écrivant ta réponse, n'inclus QUE le message qui a été résumé, seulement ton résumé et rien d'autre.
Voici le message sur lequel tu dois travailler, fais le résumé :\n"""
# LLM principal
llm = ChatMistralAI( # LLM sans outils
@@ -14,7 +29,44 @@ llm = ChatMistralAI( # LLM sans outils
)
# NODES
def call_to_LLM(state: MessagesState):
def inject_preparation_prompt(state: CustomState):
""" Noeud qui vise juste à insérer le message indiquant au LLM comment travailler sur les résumés de comptes-rendus """
return {'messages': HumanMessage(
"Ton but est de lire les fichiers présents dans la base de données en utilisant l'outil 'search_in_files',\
afin de générer des rapports sur chaque semaine du stage qui y est décrit. Il y a 25 semaines, tu dois toutes les résumer,\
avec des détails et des informations complémentaires.\
Pour enregistrer chaque semaine du stage, utilise l'outil 'write_week_report'. Tu DOIS les enregistrer avec cet outil.\
Une fois terminé, fais une liste de tous les outils, logiciels, méthodes, entreprises, techniques, ect.. utilisés,\
et fais en une liste avec quelques descriptions que tu devras enregistrer avec l'outil 'write_library_tools_details_on_internship'."
)}
def preparation_docs(state: CustomState):
"""Noeud en charge de préparer les résumés pour chaque semaine des rapports, et la liste des outils et méthodes utilisées"""
model = llm.bind_tools(getWeeklyReportTools()) # LLM en charge de générer des rapports hebdomadaires sur le stage
return {'messages': model.invoke(state['messages'])}
def user_prompt(state: CustomState):
""" Dans ce nœud, l'utilisateur peut écrire un HumanMessage pour l'IA """
messages = [msg for msg in state['messages']] # Je récupère la liste des messages
sys_message = SystemMessage("Salut") # TODO: Anti-injections
user_message = HumanMessage(
InterruptPayload.fromJSON(
interrupt(
InterruptPayload({'prompt':''}, payload_type=InterruptPayload.USER_PROMPT).toJSON()
)
).get("prompt")
) # Récupérer un prompt
messages.append(sys_message) # Rajout des nouveaux messages dans le système
messages.append(user_message)
return {'messages': messages}# Je passe unen liste, devrait écraser tous les messages précédent au lieu d'ajouter à la liste du State
def LLM_central(state: MessagesState):
"""Noeud qui s'occupe de gérer les appels au LLM"""
# Initialisation du LLM
model = llm.bind_tools(getTools())
@@ -22,6 +74,73 @@ def call_to_LLM(state: MessagesState):
# Appel du LLM
return {"messages": [model.invoke(state["messages"])]}
def context_shortener(state: CustomState):
""" Noeud visant à réduire la taille du contexte pour éviter une explosion de la taille de la mémoire court-terme/contexte """
# Récupérer le chemin vers le point d'entrée
base_dir: Path = Path(sys.argv[0]).resolve().parent
reports_dir = base_dir / "outils_resumes" # Chemin du dossier des rapports
lastSummarizedMessage = 0 # 0, je ne résume pas le premier message système
if 'lastSummarizedMessage' in state.keys():
lastSummarizedMessage = state['lastSummarizedMessage'] # Récupérer l'index du dernier message qui a été résumé
else:
# Premier passage, je supprime les anciens outils si besoin
rmtree(reports_dir.as_posix()) # Supprimer le dossier
reports_dir.mkdir(parents=True, exist_ok=False) # Créer le dossier
messages = [msg for msg in state['messages'][lastSummarizedMessage+1:]] # Récupérer tous les messages après lastSummarizedMessage sans l'inclure
newMessages = [msg for msg in state['messages'][:lastSummarizedMessage]] # Pré-remplir les anciens messages déjà revus
for msg in messages: # Boucle principale
if isinstance(msg, HumanMessage) or isinstance(msg, AIMessage):
# Message pouvant être directement résumé
if len(msg.content) > 0: # s'il y a un contenu dans ce message
msg.content = llm.invoke(PROMPT_SUMMARY + msg.content).content # Je le résume
newMessages.append(msg)
elif isinstance(msg, ToolMessage):
# Outil, sera placé dans un fichier
file_name = f"resume_{msg.tool_call_id}.txt" # Nom unique
full_path = reports_dir / file_name
with open(full_path, "w", encoding="utf-8") as f:
# Ecriture
f.write(f"""
Tu as utilisé un outil, qui a retourné ceci:
{msg.content}
""") # TODO: Trouver un moyen d'ajouter le nom de l'outil depuis les ToolCall vers ce write
msg.content = f"Pour voir le compte-rendu complet de cet outil, utilise ton outil 'read_file(file_path=\"outils_resumes/{file_name}\")'.\n Résumé:\n" + \
llm.invoke(PROMPT_SUMMARY + msg.content).content
newMessages.append(msg)
else:
# SystemMessage. Je ne les modifie pas
newMessages.append(msg)
lastSummarizedMessage+=1
return {'messages': newMessages, 'lastSummarizedMessage': lastSummarizedMessage} # Je retourne une liste entière, ce qui devrait remplacer toute la liste au lieu d'ajouter un simple message
# fonction de routage
def should_shorten(state: CustomState)->str:
"""
Fonction de routage, permet de savoir s'il est temps de résumer la contexte de la conversation
Args:
state (CustomState): Le State actuel
Returns:
str: Faut-il réduire le contexte ?
"""
count = 0
for msg in state['messages']: count += len(msg.content) # Compter le nombre total de caractères dans le contexte
if count < TAILLE_CONTEXTE_MAX:
# OK
return 'sous la limite'
return 'réduire contexte'
# fonction de routage : Après reponse_question, si le LLM veut appeler un outil, on va au tool_node
def should_continue(state: MessagesState):
"""
@@ -38,21 +157,7 @@ def should_continue(state: MessagesState):
return "tools"
return "no_tools"
def task_ended(state: MessagesState):
"""
Vérifier si l'agent a terminé son cycle, ou s'il faut le relancer
"""
if isinstance(state, list):
ai_message = state[-1]
elif messages := state.get("messages", []):
ai_message = messages[-1]
else:
raise ValueError(f"No messages found in input state to tool_edge: {state}")
if "terminé" in ai_message.content.lower():
return END
return "continue"
weekly_report_tools = ToolNode(tools=getWeeklyReportTools())
tool_node = ToolNode(tools=getTools())
@@ -81,3 +186,18 @@ class BasicToolNode: # De mon ancien projet, https://github.com/LJ5O/Assistant/b
)
)
return {"messages": outputs}
# fonction de routage
def is_resumes_reports_already_initialised(state: CustomState)->str:
"""Permet de savoirr si les résumés de comptes-rendu ont déjà été générés.
S'ils le sont, inutile de recréer ce dossier.
Returns:
str: Faut-il générer les résumés ?
"""
# Récupérer le chemin vers le point d'entrée
base_dir: Path = Path(sys.argv[0]).resolve().parent
reports_dir = base_dir / "rapports_resumes" # Chemin du dossier des rapports
if os.path.isdir(reports_dir):
return "résumés déjà générés"
else: return "résumés non disponibles"

View File

@@ -10,6 +10,8 @@ class CustomState(MessagesState):
ragQuery: str # Requête envoyée au RAG, pour le cross-encodeur
ragDocuments: List[str] # Documents retrouvés par le RAG, pour le cross-encodeur
lastSummarizedMessage: int # Index du message où l'on s'était arrêté de résumer
# TODO: Ajouter la source des documents sélectionnés pour la fin du rapport ?

View File

@@ -4,6 +4,7 @@ from tavily import TavilyClient
from pathlib import Path
from typing import List, Dict, Annotated
import sys
import os
from langgraph.types import interrupt
from .StateElements.TodoElement import TodoElement
@@ -194,9 +195,72 @@ def search_in_files(query:str, state: Annotated[dict, InjectedState])->str:
return docs_content # Retourne la liste de documents trouvés
@tool
def write_week_report(numero_semaine:int, contenu:str)->str:
"""
Écrire un rapport sur une semaine du stage. Sauvegardera ce rapport dans un fichier en mémoire pour un usage futur.
Args:
numero_semaine (int): Semaine du stage. Commence à 1 pour la première semaine
contenu (str): Ce qu'il faut écrire dans ce rapport
Returns:
str: CHemin vers le fichier, ou une erreur en cas de problème
"""
try:
# Récupérer le chemin vers le point d'entrée
base_dir: Path = Path(sys.argv[0]).resolve().parent
reports_dir = base_dir / "rapports_resumes" # Chemin du dossier des rapports
reports_dir.mkdir(parents=True, exist_ok=True) # Créer le dossier
file_name = f"rapport_semaine_{numero_semaine}.txt"
full_path = reports_dir / file_name
with open(full_path, "w", encoding="utf-8") as f: # Écrire le contenu
f.write(contenu)
return str(full_path)
except Exception as e:
return f"Erreur lors de l'écriture: {str(e)}"
@tool
def write_library_tools_details_on_internship(contenu:str)->str:
"""
Enregistrer les détails sur le stage.
Utilise cet outil pour enregistrer tous les outils, logiciels, programmes, entreprises, ect.. utilisés pendant le stage.
Args:
contenu (str): Une liste de tous les éléments intéréssants, avec quelques détails sur chacun.
Returns:
str: CHemin vers le fichier, ou une erreur en cas de problème
"""
try:
# Récupérer le chemin vers le point d'entrée
base_dir: Path = Path(sys.argv[0]).resolve().parent
reports_dir = base_dir / "rapports_resumes" # Chemin du dossier des rapports
reports_dir.mkdir(parents=True, exist_ok=True) # Créer le dossier
file_name = f"rapport_outils.txt"
full_path = reports_dir / file_name
with open(full_path, "w", encoding="utf-8") as f: # Écrire le contenu
f.write(contenu)
return str(full_path)
except Exception as e:
return f"Erreur lors de l'écriture: {str(e)}"
def getTools()->List['Tools']:
"""
Récupérer la liste des tools
"""
return [internet_search, write_file, editTodo, read_file, ask_human, search_in_files, addTodo, removeTodo]
def getWeeklyReportTools()->List['Tools']:
"""
Récupérer la liste des tools, POUR LE LLM EN CHARGE DE FAIRE LES RAPPORTS DE CHAQUE SEMAINE
"""
return [write_week_report, write_library_tools_details_on_internship, internet_search, search_in_files]

BIN
agent.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
imgs/agent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -4,6 +4,7 @@
## Workflow
![image](imgs/workflow.png)
![workflow](imgs/agent.png)
## Mise en place

View File

@@ -16,13 +16,14 @@
- [X] Préparation des nœuds
- [X] Branchement des nœuds entre-eux, **MVP**
- [X] Human in the loop
- [ ] Amélioration du workflow
- [X] Amélioration du workflow
- [X] Gestion de la taille du contexte - Résumé de l'historique des messages
## Amélioration de l'agent
- [ ] Cross-encoding sur la sortie du **RAG**
- [ ] Sauvegarde de l'état de l'agent
- [ ] Lecture d'un `skills.md`
- [ ] Système de redémarrage après un arrêt
- [ ] Gestion de la taille du contexte - Résumé de l'historique des messages
- [ ] Détection de *prompt injection*
- [ ] Génération d'un PDF en sortie du système