Merge pull request 'Produit Minimal Viable' (#1) from Definition_Agent into master

Reviewed-on: #1
This commit is contained in:
2026-02-06 17:52:51 +01:00
10 changed files with 306 additions and 33 deletions

View File

@@ -1,8 +1,9 @@
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 utils.nodes import reponse_question, tool_node, should_continue from utils.nodes import call_to_LLM, should_continue, task_ended, BasicToolNode, tool_node
from utils.state import getState from utils.state import getState
from utils.tools import getTools
def getGraph()->CompiledStateGraph: def getGraph()->CompiledStateGraph:
""" """
@@ -14,15 +15,15 @@ def getGraph()->CompiledStateGraph:
workflow = getState() # State prêt à utiliser workflow = getState() # State prêt à utiliser
# Définition des sommets du graphe # Définition des sommets du graphe
workflow.add_node(reponse_question) workflow.add_node(call_to_LLM)
workflow.add_node("tool_node", tool_node) # 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
# Arrêtes # Arrêtes
workflow.set_entry_point("reponse_question") workflow.set_entry_point("call_to_LLM")
workflow.add_edge("tool_node", "reponse_question") workflow.add_edge("tool_node", "call_to_LLM")
workflow.add_conditional_edges("reponse_question", should_continue, { workflow.add_conditional_edges("call_to_LLM", should_continue, {
"tools":"tool_node", "tools":"tool_node",
END:END "no_tools":END
}) })
return workflow.compile() return workflow.compile()

View File

@@ -10,6 +10,6 @@ from agent import getGraph
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("What's the price for bitcoin ?")]}) 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.")]})
for message in out_state['messages']: for message in out_state['messages']:
message.pretty_print() message.pretty_print()

View File

@@ -0,0 +1,40 @@
# Classes utilisées pour représenter des données
class TodoElement():
STATE_NOT_STARTED = 0 # Sorte d'enum qui représente l'état d'une tâche
STATE_STARTED = 1
STATE_COMPLETED = 2
name: str
state: int
def __init__(self, name:str, description:str=None):
self.name = name
self.description = description
self.state = TodoElement.STATE_NOT_STARTED
def __str__(self)->str:
"""
Affiche la tâche, son nom et son statut
Affichera aussi la description de la tâche si elle a été définie ET est en cours
Returns:
str: Représentation écrite de la tâche
"""
return f"Tâche \"{self.name}\": {self.__getStateName()}." + \
(f" Description: {self.description}" if self.description and self.state == TodoElement.STATE_STARTED else '')
def __getStateName(self)->str:
if self.state == TodoElement.STATE_NOT_STARTED:
return "Non commencée"
elif self.state == TodoElement.STATE_STARTED:
return "En cours"
elif self.state == TodoElement.STATE_COMPLETED:
return "Terminée"
else:
return "Inconnu"
if __name__ == "__main__":
test = TodoElement("TEST tâche", "OUI")
test.state = TodoElement.STATE_STARTED
print(test)
print([str(test)])

View File

@@ -0,0 +1,23 @@
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma # TODO plus tard, ramplacer par PG Vector
import sys
from pathlib import Path
base_dir:str = Path(sys.argv[0]).resolve().parent.as_posix() # Récupérer le chemin vers le point d'entrée du programme
bdd_path:str = base_dir + "/../chroma_db/"
EMBEDDINGS = HuggingFaceEmbeddings(model_name="intfloat/multilingual-e5-large", model_kwargs={"trust_remote_code": True})
CHROMA = Chroma(
persist_directory=bdd_path,
embedding_function=EMBEDDINGS
)
class VectorDatabase: # Classe pour récupérer la BDD
@staticmethod
def getChroma()->Chroma:
return CHROMA
@staticmethod
def getEmbeddings()->'Embeddings Hugging Face':
return EMBEDDINGS

View File

@@ -14,21 +14,18 @@ llm = ChatMistralAI( # LLM sans outils
) )
# NODES # NODES
def reponse_question(state: MessagesState): def call_to_LLM(state: MessagesState):
"""Noeud qui réponds à la question, en s'aidant si besoin des outils à disposition""" """Noeud qui s'occupe de gérer les appels au LLM"""
# Initialisation du LLM # Initialisation du LLM
model = llm.bind_tools(getTools()) model = llm.bind_tools(getTools())
# Appel du LLM # Appel du LLM
return {"messages": [model.invoke(state["messages"])]} return {"messages": [model.invoke(state["messages"])]}
tool_node = ToolNode(tools=getTools()) # Node gérant les outils # fonction de routage : Après reponse_question, si le LLM veut appeler un outil, on va au tool_node
# fonction de routage : Après reponse_question, si le LLM veut appeler un outil, on va au tool_node, sinon on termine
def should_continue(state: MessagesState): def should_continue(state: MessagesState):
""" """
Use in the conditional_edge to route to the ToolNode if the last message Vérifier s'il y a un appel aux outils dans le dernier message
has tool calls. Otherwise, route to the end.
""" """
if isinstance(state, list): if isinstance(state, list):
ai_message = state[-1] ai_message = state[-1]
@@ -39,4 +36,48 @@ def should_continue(state: MessagesState):
if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0: if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
return "tools" 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 END
return "continue"
tool_node = ToolNode(tools=getTools())
class BasicToolNode: # De mon ancien projet, https://github.com/LJ5O/Assistant/blob/main/modules/Brain/src/LLM/graph/nodes/BasicToolNode.py
"""A node that runs the tools requested in the last AIMessage."""
def __init__(self, tools: list) -> None:
self.tools_by_name = {tool.name: tool for tool in tools}
def __call__(self, inputs: dict):
if messages := inputs.get("messages", []):
message = messages[-1]
else:
raise ValueError("No message found in input")
outputs = []
for tool_call in message.tool_calls:
#print(tool_call["args"])
tool_result = self.tools_by_name[tool_call["name"]].invoke(
tool_call["args"]
)
outputs.append(
ToolMessage(
content=json.dumps(tool_result),
name=tool_call["name"],
tool_call_id=tool_call["id"],
)
)
return {"messages": outputs}

View File

@@ -1,7 +1,17 @@
from langgraph.graph import StateGraph, MessagesState from langgraph.graph import StateGraph, MessagesState
from typing import List
from .StateElements.TodoElement import TodoElement
class CustomState(MessagesState):
todo: List[TodoElement] # Les tâches en cours
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
# TODO: Ajouter la source des documents sélectionnés pour la fin du rapport ?
class hjgzefvuiyguhzfvihuozdef(MessagesState): # J'ai du mal à nommer mes classes ._.
pass
def getState()->StateGraph: def getState()->StateGraph:
""" """
@@ -10,4 +20,4 @@ def getState()->StateGraph:
Returns: Returns:
StateGraph: prêt à utiliser StateGraph: prêt à utiliser
""" """
return StateGraph(hjgzefvuiyguhzfvihuozdef) return StateGraph(CustomState)

View File

@@ -1,6 +1,12 @@
from langchain.tools import tool from langchain.tools import tool
from langgraph.prebuilt import InjectedState
from tavily import TavilyClient from tavily import TavilyClient
from typing import List from pathlib import Path
from typing import List, Dict, Annotated
import sys
from .StateElements.TodoElement import TodoElement
from .VectorDatabase import VectorDatabase
@tool @tool
def internet_search(query: str)->dict: def internet_search(query: str)->dict:
@@ -16,22 +22,170 @@ def internet_search(query: str)->dict:
@tool @tool
def write_file(content: str) -> str: def write_file(file_path:str, content: str, append:bool=True) -> str:
""" """
Écrire les données dans un fichier Ecrire et ajouter du texte dans un fichier.
Args: Args:
content (str): Contenu du fichier à écrire file_path (str): Chemin d'accès relatif vers le fichier à écrire.
content (str): Contenu à écrire dans le fichier.
append (bool, optional): Faut-il AJOUTER(True) au fichier, ou REMPLACER son contenu(False) ? True par défaut.
Returns: Returns:
str: Résultat de l'écriture str: Le chemin d'accès relatif vers le fichier en cas de réussite, ou une erreur en cas d'echec
""" """
print("==ECRITURE FICHIER==") try:
print(content) base_dir:str = Path(sys.argv[0]).resolve().parent.as_posix() # Récupérer le chemin vers le point d'entrée du programme
return "Fichier écrit" full_path:str = base_dir + (file_path if file_path.startswith('/') else f'/{file_path}') # Puis générer le chemin vers le fichier
mode = "a" if append else "w" # Mode d'écriture
with open(full_path, mode, encoding="utf-8") as f: # Puis j'écris
f.write(content)
return str(full_path)
except Exception as e:
return f"Erreur lors de l'écriture: {str(e)}"
@tool
def editTodo(index:int, todoState:int, state: Annotated[dict, InjectedState])->bool: # https://stackoverflow.com/a/79525434
"""
Modifier l'état d'une tâche (TODO)
Args:
index (int): Index de la tâche à modifier, en commançant à 0 pour la première tâche.
todoState (int): Nouvel état. 0 pour "non commencé, 1 pour "en cours", 2 pour "complété"
Returns:
bool: Réussite de l'opération, ou non.
"""
if len(state["todo"]) <= index:
# Erreur, l'index est trop grand
return False
state["todo"][index].state = todoState # Modification de l'état de cette tâche
# Toutes les tâches complétées ?
found = False
for task in state["todo"]: # Pour chaque tâche
if task.state != 2: # Si elle n'est pas terminée
found = True
break
if not found: state["todo"] = [] # Toutes les tâches terminées, je peux clear la TODO list du state
return True
@tool
def addTodo(name:str, description:str, state: Annotated[dict, InjectedState])->bool:
"""
Ajouter une nouvelle tâche/TODO
Args:
name (str): Nom de cette tâche
description (str): Une ou deux phrases pour décrire le travail à effectuer dans ce TODO
Returns:
bool: Réussite de l'opération, ou non
"""
if state["todo"] is None: state["todo"] = []
state["todo"].append(TodoElement(name, description))
return True
@tool
def removeTodo(index:int, state: Annotated[dict, InjectedState])->bool:
"""
Retirer une tâche/TODO de la liste des tâches
Args:
index (int): Position de la tâche dans la liste, commence à 0 pour le premier TODO
Returns:
bool: Réussite de l'opération, ou non
"""
if len(state["todo"]) <= index:
# Erreur, l'index est trop grand
return False
state['todo'].pop(index)
return True
@tool
def read_file(file_path: str) -> str:
"""
Lire le contenu d'un fichier texte.
Args:
file_path (str): Chemin d'accès relatif vers le fichier à lire.
Returns:
str: Le contenu du fichier, ou un message d'erreur.
"""
try:
base_dir:str = Path(sys.argv[0]).resolve().parent.as_posix() # Récupérer le chemin vers le point d'entrée du programme
full_path:str = base_dir + (file_path if file_path.startswith('/') else f'/{file_path}') # Puis générer le chemin vers le fichier
with open(full_path, "r", encoding="utf-8") as f:
content = f.read()
return content
except Exception as e:
return f"Erreur lors de la lecture : {str(e)}"
@tool
def ask_human(request:str)->str:
"""
Demander quelque chose à un assistant humain. Permet d'obtenir des informations supplémentaires,
ou qu'une action soit réalisée.
Args:
request (str): Ce qui est demandé à l'humain
Returns:
str: Réponse de l'humain
"""
print("--- L'IA A BESOIN D'UN HUMAIN ! ---")
print(f"L'IA demande : {request}")
user_response = input("Réponse humaine: ") # Input bloque le système en attendant l'humain
# J'aurais possiblement utiliser d'autres approches comme https://docs.langchain.com/oss/javascript/langchain/human-in-the-loop
# Mais Human in the loop se place AVANT l'outil. Ici, l'outil consiste justement à demander quelque chose à un humain.
print("-------")
return user_response
@tool
def search_in_files(query:str, state: Annotated[dict, InjectedState])->str:
"""
Rechercher quelque chose dans les documents enregistrés localement.
Dans le cas actuel, ces documents sont des rapports hebdomadaires de stage.
Args:
query (str): La requête recherchée.
Returns:
str: Échantillons de documents correspondants, concaténés en une seule chaîne de caractères.
"""
bdd = VectorDatabase.getChroma() # Récupère l'unique instance de cette BDD, c'est un SIngleton
retrieved_docs = bdd.similarity_search(query, k=5) # 5 documents
# Conversion des documents en texte
docs_content = "\n".join(
[f"Document {i+1}:\n{doc.page_content}" for i,doc in enumerate(retrieved_docs)]
)
# Sauvegarde des données dans le State
state["ragQuery"] = query
state["ragDocuments"] = retrieved_docs
return docs_content # Retourne la liste de documents trouvés
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] return [internet_search, write_file, editTodo, read_file, ask_human, search_in_files, addTodo, removeTodo]

View File

@@ -43,7 +43,7 @@ print("===")
# Création du modèle d'embeddings # Création du modèle d'embeddings
# https://docs.langchain.com/oss/python/integrations/text_embedding/huggingfacehub # https://docs.langchain.com/oss/python/integrations/text_embedding/huggingfacehub
# https://huggingface.co/jinaai/jina-clip-v2 # https://huggingface.co/jinaai/jina-clip-v2
embeddings = HuggingFaceEmbeddings(model_name="jinaai/jina-embeddings-v3", model_kwargs={"trust_remote_code": True}) embeddings = HuggingFaceEmbeddings(model_name="intfloat/multilingual-e5-large", model_kwargs={"trust_remote_code": True})
# Stockage des embeddings dans ChromaDB dans un dossier local "chroma_db" # Stockage des embeddings dans ChromaDB dans un dossier local "chroma_db"
vectorstore = Chroma.from_documents(documents=chunks,embedding=embeddings, persist_directory=base_dir.as_posix()+"/chroma_db/",) # https://docs.langchain.com/oss/python/integrations/vectorstores/chroma vectorstore = Chroma.from_documents(documents=chunks,embedding=embeddings, persist_directory=base_dir.as_posix()+"/chroma_db/",) # https://docs.langchain.com/oss/python/integrations/vectorstores/chroma

BIN
agent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -11,14 +11,18 @@
- [X] Lecture des documents et mise en base de données vectorielle - [X] Lecture des documents et mise en base de données vectorielle
## Mise en place de l'agent ## Mise en place de l'agent
- [ ] Préparation du `State` - [X] Préparation du `State`
- [ ] Développement des outils de l'agent - [X] Développement des outils de l'agent
- [ ] Préparation des nœuds - [X] Préparation des nœuds
- [ ] Branchement des nœuds entre-eux - [X] Branchement des nœuds entre-eux, **MVP**
- [ ] Human in the loop
- [ ] Amélioration du workflow
## Amélioration de l'agent ## Amélioration de l'agent
- [ ] Cross-encoding sur la sortie du **RAG**
- [ ] Sauvegarde de l'état de l'agent - [ ] Sauvegarde de l'état de l'agent
- [ ] Système de redémarrage après un arrêt - [ ] 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* - [ ] Détection de *prompt injection*
- [ ] Génération d'un PDF en sortie du système - [ ] Génération d'un PDF en sortie du système