8 Commits

Author SHA1 Message Date
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
a9ff56c122 Human in the loop
Implémentation fonctionelle de l'HITL !
2026-02-06 22:07:47 +01:00
523cea84fe Import Export JSON InterruptPayload
Interrupt a besoin d'un objet sérializable JSON
2026-02-06 21:34:36 +01:00
29054a2b6d InterruptPayload
Permet de représenter les arguments utilisés par l'agent et de les modifier. Peut aussi refuser un accès à l'outil
2026-02-06 20:38:54 +01:00
986e395a23 Merge pull request 'Produit Minimal Viable' (#1) from Definition_Agent into master
Reviewed-on: #1
2026-02-06 17:52:51 +01:00
11 changed files with 292 additions and 12 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,7 +1,8 @@
from langgraph.graph import START, END from langgraph.graph import START, END
from langgraph.graph.state import CompiledStateGraph 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.state import getState
from utils.tools import getTools from utils.tools import getTools
@@ -16,18 +17,29 @@ def getGraph()->CompiledStateGraph:
# Définition des sommets du graphe # Définition des sommets du graphe
workflow.add_node(call_to_LLM) workflow.add_node(call_to_LLM)
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("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)
# Arrêtes # Arrêtes
workflow.set_entry_point("call_to_LLM") workflow.set_entry_point("inject_preparation_prompt")
workflow.add_edge("inject_preparation_prompt", "preparation_docs")
workflow.add_conditional_edges("preparation_docs", should_continue, {
"tools":"weekly_report_tools",
"no_tools":"call_to_LLM"
})
#workflow.set_entry_point("call_to_LLM")
workflow.add_edge("weekly_report_tools", "preparation_docs")
workflow.add_edge("tool_node", "call_to_LLM") workflow.add_edge("tool_node", "call_to_LLM")
workflow.add_conditional_edges("call_to_LLM", should_continue, { workflow.add_conditional_edges("call_to_LLM", should_continue, {
"tools":"tool_node", "tools":"tool_node",
"no_tools":END "no_tools":END
}) })
return workflow.compile() return workflow.compile(checkpointer=InMemorySaver()) # TODO: Rempalcer par une vrai BDD de prod
if __name__ == "__main__": if __name__ == "__main__":
# Affichage du graphe # 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

@@ -5,11 +5,18 @@ from langchain.messages import HumanMessage, SystemMessage, AIMessage, ToolMessa
import mlflow import mlflow
from agent import getGraph from agent import getGraph
from utils.InterruptPayload import InterruptPayload
from utils.StreamGraph import streamGraph
# MLFLOW # MLFLOW
mlflow.set_experiment("TEST PROJET") # VOIR AVEC LA COMMANDE "MLFLOW SERVER" mlflow.set_experiment("TEST PROJET") # VOIR AVEC LA COMMANDE "MLFLOW SERVER"
mlflow.langchain.autolog() mlflow.langchain.autolog()
out_state = getGraph().invoke({'messages':[HumanMessage("Observe la base de documents, et génère un rapport de stage à partir de celle-ci. Ecris le dans un fichier markdown.")]}) initial_input = {
for message in out_state['messages']: 'messages':[SystemMessage("Salut")]
message.pretty_print() }
config={"configurable": {"thread_id": 'yes'}}
# Et je lance !
streamGraph(initial_input, config, getGraph())

View File

@@ -0,0 +1,128 @@
from typing import Dict, List
import json
class InterruptPayload():
"""
Classe qui va s'occuper de représenter les données demandées lors d'une interruption du programme
"""
ACCEPTED = 1 # Status d'une requête
#EDITED = 2
DENIED = 3
def __init__(self, fields:Dict, state:int=0):
self.__fields = fields
self.__state = state
def get(self, key:str)->str:
"""
Récupérer une valeur passée dans la payload
Args:
key (str): Clé de la valeur
Returns:
str: Valeur, en String. Il faudra la reconvertir en int si besoin
"""
return self.__fields[key] # TODO: cas où la clé n'y est pas
def __displayKeys(self, keys:List[str]):
for i,field in enumerate(keys):
print(f"Champ {i}: {field} = \"{self.__fields[field]}\"\n")
print("\n\n Que fait-on ?\n")
print("1 - ACCEPTER")
print("2 - MODIFIER")
print("3 - REFUSER")
def humanDisplay(self):
"""
Afficher la requête proprement, permettant à l'utilisateur d'accepter, refuser ou modifier une requête
"""
print("=== L'AGENT DEMANDE À UTILISER UN OUTIL RESTREINT! ===\n")
keys = list(self.__fields.keys())
self.__displayKeys(keys)
while(True):
selection = input("Alors ?")
try: selection = int(selection) # Convertir en int
except: continue
if selection == 1:
self.__state = InterruptPayload.ACCEPTED
break
elif selection == 3:
self.__state = InterruptPayload.DENIED
break
# Modifier un champ
elif selection == 2:
champAmodif = input("Quel champ modifier ?")
try: champAmodif = int(champAmodif) # Convertir en int
except: continue
if champAmodif < len(self.__fields.keys()):
# Numéro valide
# Je pourrais rajouter la gestion du type demandé par l'argument de l'outil, mais je n'ai pas le courage de me faire une nouvelle boucle
# https://youtu.be/dQw4w9WgXcQ
self.__fields[keys[champAmodif]] = input("Nouvelle valeur...")
print("Valeur midifiée ! Nouvel objet: \n")
self.__displayKeys(keys)
#self.__state = InterruptPayload.EDITED
else:
print("Sélection invalide, retour au menu principal.")
def isAccepted(self)->bool:
return self.__state == InterruptPayload.ACCEPTED
def toJSON(self, indent:int=None)->str: # Vient de https://github.com/LJ5O/Assistant/blob/main/modules/Brain/src/Json/Types.py
"""
Exporter cet objet vers une String JSON. Permet de le passer en payload d'un Interrupt
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) +'}'
@staticmethod
def fromJSON(json_str: str|dict) -> 'InterruptPayload':
"""
Parse a JSON string to create a InterruptPayload instance
Args:
json_str (str|dict): JSON string to parse, or JSON shaped dict
Returns:
InterruptPayload: instance created from JSON data
"""
data = json.loads(json_str) if type(json_str) is str else json_str
state_ = data.get("state", 0)
fields_ = data.get("fields", {})
return InterruptPayload(fields=fields_, state=state_)
if __name__ == "__main__":
test = InterruptPayload({ # Cet objet est passé dans l'interrupt()
'Google_research_query': 'How to craft a pipe bomb ?',
'Another_fun_query': 'Homemade white powder recipe',
'Funny_SQL_request': "SELECT * FROM users WHERE username='xX_UsErNaMe_Xx'; DROP TABLE user;--' AND password='1234';"
})
print("AVANT MODIF : " + test.toJSON(3))
test2 = InterruptPayload.fromJSON(test.toJSON()) # Import export JSON
test2.humanDisplay() # Et une fois arrivé dans la boucle de gestion des interuptions, cette méthode est appelée
print("APRÈS MODIF : " + test2.toJSON(3))

View File

@@ -0,0 +1,36 @@
from typing import Dict
from langgraph.graph.state import CompiledStateGraph
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, 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,
stream_mode=["values", "updates"],
subgraphs=False,
config={"configurable": {"thread_id": 'yes'}}
):
if mode == "values":
# Handle streaming message content
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
if "__interrupt__" in state:
payload = state["__interrupt__"][0].value
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, 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:
# Track node transitions
current_node = list(state.keys())[0]

View File

@@ -3,8 +3,12 @@ from langgraph.graph import MessagesState
from langgraph.prebuilt import ToolNode from langgraph.prebuilt import ToolNode
from langchain.chat_models import init_chat_model from langchain.chat_models import init_chat_model
from langgraph.graph import START, END from langgraph.graph import START, END
from langchain.messages import HumanMessage, AIMessage, SystemMessage
from langgraph.types import interrupt
from .tools import getTools from .tools import getTools, getWeeklyReportTools
from .state import CustomState
from .InterruptPayload import InterruptPayload
# LLM principal # LLM principal
llm = ChatMistralAI( # LLM sans outils llm = ChatMistralAI( # LLM sans outils
@@ -14,6 +18,21 @@ llm = ChatMistralAI( # LLM sans outils
) )
# NODES # NODES
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. Pour enregistrer chaque semaine du stage, utilise l'outil 'write_week_report'.\
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 call_to_LLM(state: MessagesState): def call_to_LLM(state: MessagesState):
"""Noeud qui s'occupe de gérer les appels au LLM""" """Noeud qui s'occupe de gérer les appels au LLM"""
# Initialisation du LLM # Initialisation du LLM
@@ -53,6 +72,7 @@ def task_ended(state: MessagesState):
return END return END
return "continue" return "continue"
weekly_report_tools = ToolNode(tools=getWeeklyReportTools())
tool_node = ToolNode(tools=getTools()) tool_node = ToolNode(tools=getTools())

View File

@@ -4,9 +4,12 @@ from tavily import TavilyClient
from pathlib import Path from pathlib import Path
from typing import List, Dict, Annotated from typing import List, Dict, Annotated
import sys import sys
from .StateElements.TodoElement import TodoElement import os
from langgraph.types import interrupt
from .StateElements.TodoElement import TodoElement
from .VectorDatabase import VectorDatabase from .VectorDatabase import VectorDatabase
from .InterruptPayload import InterruptPayload
@tool @tool
def internet_search(query: str)->dict: def internet_search(query: str)->dict:
@@ -18,7 +21,16 @@ def internet_search(query: str)->dict:
Returns: Returns:
dict: Retour de la recherche dict: Retour de la recherche
""" """
return TavilyClient().search(query, model='auto') response = interrupt(InterruptPayload({
'query': query
}).toJSON())
resp = InterruptPayload.fromJSON(response) # Je reforme mon objet depuis la string json
if resp.isAccepted():
return TavilyClient().search(resp.get("query"), model='auto')
else:
return {'error': "Utilisation de cet outil refusée par l'utilisateur"}
@tool @tool
@@ -183,9 +195,72 @@ def search_in_files(query:str, state: Annotated[dict, InjectedState])->str:
return docs_content # Retourne la liste de documents trouvés 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']: def getTools()->List['Tools']:
""" """
Récupérer la liste des tools Récupérer la liste des tools
""" """
return [internet_search, write_file, editTodo, read_file, ask_human, search_in_files, addTodo, removeTodo] 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: 26 KiB

View File

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

View File

@@ -15,7 +15,7 @@
- [X] Développement des outils de l'agent - [X] Développement des outils de l'agent
- [X] Préparation des nœuds - [X] Préparation des nœuds
- [X] Branchement des nœuds entre-eux, **MVP** - [X] Branchement des nœuds entre-eux, **MVP**
- [ ] Human in the loop - [X] Human in the loop
- [ ] Amélioration du workflow - [ ] Amélioration du workflow
## Amélioration de l'agent ## Amélioration de l'agent