Agents.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import os
  2. from typing import Annotated , Sequence , TypedDict , Optional , List , Any
  3. from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage , AIMessage
  4. from langgraph.graph.message import add_messages
  5. from dotenv import load_dotenv
  6. from langchain_openai import ChatOpenAI
  7. from langchain_groq import ChatGroq
  8. import pandas as pd
  9. from langfuse import get_client
  10. import json
  11. from langchain_google_genai import ChatGoogleGenerativeAI
  12. import re
  13. import base64
  14. import cv2
  15. from pathlib import Path
  16. from tools import excel_code_interpreter
  17. from paddleocr import PaddleOCR
  18. from function import (
  19. preparer_image_zoom_hd,
  20. extraire_donnees_ocr,
  21. nettoyage_sortie_ocr,
  22. formater_donnees_section,
  23. sauvegarder_fichier_unique,
  24. to_points ,
  25. build_lines,
  26. cluster_lines ,
  27. merge_close_lines
  28. )
  29. from clean_DBSCAN import (
  30. transform_to_clean_markdown
  31. )
  32. load_dotenv()
  33. tools = [excel_code_interpreter]
  34. class AgentState(TypedDict) :
  35. messages : Annotated[Sequence[BaseMessage],add_messages]
  36. pdf_path : str
  37. pages : List[int]
  38. page : List[int]
  39. entreprise_name : str
  40. section_name : str
  41. lignes : str
  42. markdown : str
  43. use_vision : bool
  44. image_path: str
  45. model_gemini = ChatGoogleGenerativeAI(model="gemini-2.5-flash")
  46. model_llama = ChatGroq(model="llama-3.3-70b-versatile")
  47. model_openai = ChatOpenAI(model="gpt-4o" , temperature=0.2)
  48. model_ai = model_llama.bind_tools(tools)
  49. ocr = PaddleOCR(use_angle_cls=True, lang='fr', det_limit_side_len=10000, show_log=False)
  50. def has_rc_codes(points):
  51. has_r = False
  52. has_c = False
  53. for p in points:
  54. text = str(p[2]).strip()
  55. if text.startswith("R"):
  56. has_r = True
  57. if text.startswith("C"):
  58. has_c = True
  59. return has_r and has_c
  60. def agent_ocr(state: AgentState):
  61. pdf_path = state.get("pdf_path")
  62. page_val = state.get("page")
  63. if page_val is None:
  64. raise ValueError("page est None dans state")
  65. try:
  66. page_index = int(page_val) - 1
  67. except TypeError:
  68. page_index = int(page_val[0]) - 1
  69. all_points = []
  70. texte_accumule = []
  71. try:
  72. img_finale = preparer_image_zoom_hd(pdf_path, page_index)
  73. raw_data = extraire_donnees_ocr(img_finale, ocr)
  74. data_propre = nettoyage_sortie_ocr(raw_data)
  75. points = to_points(data_propre)
  76. all_points.extend(points)
  77. # 🎯 CAS ROUGE : Pas de codes R/C détectés -> Mode Vision
  78. if not has_rc_codes(points):
  79. print(f" Pas de codes R/C détectés — activation du mode LLM vision")
  80. # 🛠️ SAUVEGARDE ET COMPRESSION TEMPORAIRE
  81. # On utilise le format .jpg avec une qualité de 85% pour réduire drastiquement la taille
  82. chemin_image_temp = f"temp_page_{page_index + 1}.jpg"
  83. import cv2
  84. cv2.imwrite(chemin_image_temp, img_finale, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
  85. return {
  86. "use_vision": True,
  87. "image_path": chemin_image_temp, # 🟢 On ne passe qu'un bête texte de 20 caractères !
  88. "messages": [HumanMessage(content="Mode LLM Vision activé.")],
  89. }
  90. # Texte pour le LLM (Cas normal)
  91. lignes_page = formater_donnees_section(data_propre, page_index)
  92. texte_accumule.extend(lignes_page)
  93. print(f" Page {page_index + 1} traitée par l'OCR.")
  94. except Exception as e:
  95. print(f"Erreur lors de l'OCR Page {page_index + 1}: {e}")
  96. raise e
  97. # Traitement classique si des codes R/C sont présents
  98. clusters = cluster_lines(all_points, eps=0.5)
  99. lignes = build_lines(clusters)
  100. lignes = merge_close_lines(lignes)
  101. markdown = transform_to_clean_markdown(lignes)
  102. contenu_final = "\n".join(texte_accumule)
  103. return {
  104. "points": points,
  105. "messages": [HumanMessage(content=f"Voici les données OCR brutes :\n{contenu_final}")],
  106. "clusters": clusters,
  107. "lignes": lignes,
  108. "markdown": markdown,
  109. "use_vision": False # <-- Cas normal, on continue vers agent_extracteur
  110. }
  111. def agent_extracteur(state: AgentState):
  112. prompt = """
  113. Tu es un extracteur de tableaux QRT (Solvabilité II).
  114. Objectif : convertir le tableau markdown en JSON sans AUCUNE modification.
  115. Règles STRICTES :
  116. - La position de chaque valeur dans le tableau est ABSOLUE et NE DOIT PAS être modifiée.
  117. - Si une colonne contient 0, garde 0. Ne déplace jamais une valeur vers une autre colonne.
  118. - Utilise Rxxxx (lignes) et Cxxxx (colonnes) comme clés directement.
  119. - Copie STRICTEMENT les valeurs dans leur colonne exacte.
  120. - Supprime uniquement les espaces dans les nombres : "3 297 388" → 3297388.
  121. - Une colonne à 0 reste à 0, même si une valeur non-nulle existe dans une colonne adjacente.
  122. - Structure attendue : {"Cxxxx": {"Rxxxx": valeur, ...}, ...}
  123. ⚠️ CONTRAINTE DE SORTIE :
  124. - Réponds UNIQUEMENT avec l'objet JSON.
  125. - PAS de markdown (```json), PAS de texte, PAS d'explications.
  126. - NE PAS réorganiser, NE PAS interpréter, NE PAS corriger les données.
  127. Tableau à convertir :
  128. {markdown}
  129. """
  130. markdown_content = state.get("markdown", "")
  131. lignes = state.get("lignes", [])
  132. # Garde-fou : markdown vide ou trop pauvre (moins de 2 lignes de données)
  133. data_rows = [l for l in str(markdown_content).splitlines() if l.strip().startswith("|") and "R0" in l]
  134. if not markdown_content or not str(markdown_content).strip() or len(data_rows) == 0:
  135. backup_content = json.dumps(lignes, ensure_ascii=False)
  136. input_content = f"Note : Le markdown était vide ou invalide. Voici les lignes brutes OCR :\n{backup_content}"
  137. else:
  138. input_content = markdown_content
  139. msg = [
  140. SystemMessage(content=prompt),
  141. HumanMessage(content=input_content) # ← plus de json.dumps() sur du markdown déjà str
  142. ]
  143. response = model_openai.invoke(msg)
  144. return {"messages": [response]}
  145. def encoder_image_en_base64(chemin_image: str) -> str:
  146. """Convertit une image locale en chaîne base64 pour l'API OpenAI."""
  147. with open(chemin_image, "rb") as image_file:
  148. return base64.b64encode(image_file.read()).decode("utf-8")
  149. def agent_llm_vision(state: AgentState):
  150. print("[LLM Vision] Début de l'analyse visuelle du tableau QRT...")
  151. # 1. On récupère le CHEMIN du fichier image
  152. image_path = state.get("image_path")
  153. section_name = state.get("section_name", "Non spécifiée")
  154. if not image_path or not os.path.exists(image_path):
  155. raise ValueError(f"Le fichier image est introuvable : {image_path}")
  156. section_name_raw = state.get("section_name") # Ex: "S.23_page_64" ou "S.02.01_table_1"
  157. if not section_name_raw:
  158. raise ValueError("L'état de l'agent doit contenir un 'section_name' valide.")
  159. # Extraction de la racine de la section (ex: "S.23" ou "S.02.01")
  160. # Cette regex capture tout ce qui commence par S. suivi de chiffres et de points
  161. match = re.match(r"^(S\.\d+(?:\.\d+)*)", section_name_raw)
  162. if not match:
  163. raise ValueError(f"Impossible de déterminer la racine réglementaire depuis : {section_name_raw}")
  164. section_racine = match.group(1)
  165. mapping_path = Path(__file__).resolve().parent / "mapping.json"
  166. """ print("Mapping path réel =", mapping_path)
  167. print("Existe ?", mapping_path.exists())"""
  168. try:
  169. with open(mapping_path, "r", encoding="utf-8") as f:
  170. full_mapping = json.load(f)
  171. # On cherche d'abord la racine exacte, sinon on tente une correspondance partielle
  172. section_mapping = full_mapping.get(section_racine)
  173. if not section_mapping:
  174. # Fallback au cas où le JSON contient "S.23.01" mais votre racine est "S.23"
  175. alternative_key = next((k for k in full_mapping.keys() if k.startswith(section_racine)), None)
  176. if alternative_key:
  177. section_mapping = full_mapping[alternative_key]
  178. else:
  179. raise KeyError(f"Aucun mapping trouvé pour '{section_racine}' (déduit de '{section_name_raw}') dans mapping.json.")
  180. mapping_json_reduit = json.dumps(section_mapping, ensure_ascii=False, indent=2)
  181. except Exception as e:
  182. raise RuntimeError(f"Erreur mapping pour {section_name_raw} : {str(e)}")
  183. # 2. Prompt (Identique)
  184. PROMPT_VISION_SANS_CODES = f"""
  185. Tu es un expert Solvabilité II. Ce tableau est un QRT SFCR sans codes R/C visibles.
  186. Ta tâche :
  187. 1. Identifie les lignes (descriptions) et colonnes (headers) du tableau.
  188. 2. Associe chaque description de ligne au bon code Rxxxx selon la nomenclature Solvabilité II.
  189. 3. Associe chaque header de colonne au bon code Cxxxx.
  190. Utilise le mapping suivant :
  191. {mapping_json_reduit}
  192. Règles STRICTES :
  193. - Utilise directement les codes Rxxxx (lignes) et Cxxxx (colonnes) comme clés.
  194. - Supprime uniquement les espaces dans les nombres (ex: "3 297 388" → 3297388).
  195. - OPTIMISATION DE TOKENS : Exclura COMPLÈTEMENT du JSON de sortie les lignes/colonnes dont la valeur est égale à 0, vide, "-", "–" ou "N/A". Ne les écris pas.
  196. - Structure attendue :
  197. {{
  198. "Cxxxx": {{
  199. "Rxxxx": valeur,
  200. ...
  201. }},
  202. ...
  203. }}
  204. - Section détectée : {section_name}
  205. ⚠️ CONTRAINTE DE SORTIE :
  206. - Réponds UNIQUEMENT avec l'objet JSON.
  207. - PAS de markdown.
  208. - PAS de balises ```json.
  209. - PAS de texte explicatif.
  210. - PAS de commentaires.
  211. - Le JSON doit être valide et directement parsable par json.loads().
  212. """
  213. try:
  214. # 3. Encodage à la volée du fichier disque en Base64
  215. with open(image_path, "rb") as image_file:
  216. base64_image = base64.b64encode(image_file.read()).decode("utf-8")
  217. # 4. Préparation du message multimodal
  218. msg_vision = HumanMessage(
  219. content=[
  220. {"type": "text", "text": PROMPT_VISION_SANS_CODES},
  221. {
  222. "type": "image_url",
  223. "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
  224. },
  225. ]
  226. )
  227. # 5. Appel OpenAI avec format JSON
  228. model_json = model_openai.bind(response_format={"type": "json_object"})
  229. response = model_json.invoke([msg_vision])
  230. # Nettoyage des balises si présentes
  231. contenu_propre = response.content.strip()
  232. if contenu_propre.startswith("```json"):
  233. contenu_propre = contenu_propre.replace("```json", "").replace("```", "").strip()
  234. print(" [LLM Vision] Extraction réussie.")
  235. # 6. NETTOYAGE DU DISQUE (Optionnel mais propre)
  236. # Supprime le fichier temporaire pour ne pas encombrer votre dossier de travail
  237. if os.path.exists(image_path):
  238. os.remove(image_path)
  239. # 7. Retour de la réponse (UNIQUEMENT le texte JSON)
  240. # L'image géante n'est PAS stockée dans l'historique du graphe, elle disparaît ici !
  241. return {
  242. "messages": [AIMessage(content=contenu_propre)],
  243. "image_path": None # On réinitialise la clé à None pour vider le State
  244. }
  245. except Exception as e:
  246. # En cas d'erreur, on essaie quand même de nettoyer le fichier
  247. if os.path.exists(image_path):
  248. os.remove(image_path)
  249. print(f" [LLM Vision] Erreur : {e}")
  250. raise e
  251. def agent_builder(state: AgentState):
  252. print(f" Construction du fichier Excel pour : {state['entreprise_name']}...")
  253. try:
  254. import json
  255. import pandas as pd
  256. import os
  257. import re
  258. # 1. Extraction du contenu JSON pur
  259. content = state["messages"][-1].content
  260. # Nettoyage au cas où le modèle aurait ajouté des balises markdown malgré les consignes
  261. content = re.sub(r'```json|```', '', content).strip()
  262. data_json = json.loads(content)
  263. # 2. Gestion du chemin de sauvegarde (identique)
  264. base_outputs = os.path.join("..", "04 - Outputs")
  265. match = re.search(r'(\d{4})', state['entreprise_name'])
  266. annee = match.group(1) if match else "2025"
  267. nom_entreprise = state['entreprise_name'].split('_')[0].replace(" ", "_")
  268. target_folder = os.path.join(base_outputs, annee, nom_entreprise)
  269. if not os.path.exists(target_folder):
  270. os.makedirs(target_folder)
  271. output_file = os.path.join(target_folder, f"Rapport_{state['section_name']}.xlsx")
  272. # 3. Construction du DataFrame à partir de 'data_json' uniquement
  273. # On récupère l'ensemble unique de tous les Rxxxx présents dans toutes les colonnes
  274. all_rows = sorted(list(set(r for col in data_json.values() for r in col.keys())))
  275. all_cols = sorted(list(data_json.keys()))
  276. df = pd.DataFrame(index=all_rows, columns=all_cols)
  277. for col, row_values in data_json.items():
  278. for row, val in row_values.items():
  279. df.at[row, col] = val
  280. # 4. Exportation
  281. with pd.ExcelWriter(output_file, engine="xlsxwriter") as writer:
  282. df.to_excel(writer, sheet_name="QRT_Export", index=True)
  283. # Ajoute ici tes formats xlsxwriter si nécessaire
  284. success_msg = f" Sauvegardé dans : {output_file}"
  285. print(success_msg)
  286. return {"messages": [HumanMessage(content=success_msg)]}
  287. except Exception as e:
  288. error_msg = f" Erreur construction Excel : {str(e)}"
  289. print(error_msg)
  290. return {"messages": [HumanMessage(content=error_msg)]}