mirror of
https://github.com/Ladebeze66/llm_ticket3.git
synced 2025-12-15 19:06:50 +01:00
563 lines
26 KiB
Python
563 lines
26 KiB
Python
import os
|
|
import json
|
|
import logging
|
|
import time
|
|
import traceback
|
|
from typing import List, Dict, Any, Optional, Union
|
|
from agents.base_agent import BaseAgent
|
|
|
|
# Configuration du logging
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
|
|
filename='orchestrator.log', filemode='w')
|
|
logger = logging.getLogger("Orchestrator")
|
|
|
|
class Orchestrator:
|
|
def __init__(self,
|
|
output_dir: str = "output/",
|
|
json_agent: Optional[BaseAgent] = None,
|
|
image_sorter: Optional[BaseAgent] = None,
|
|
image_analyser: Optional[BaseAgent] = None,
|
|
report_generator: Optional[BaseAgent] = None):
|
|
|
|
self.output_dir = output_dir
|
|
|
|
# Assignation directe des agents
|
|
self.json_agent = json_agent
|
|
self.image_sorter = image_sorter
|
|
self.image_analyser = image_analyser
|
|
self.report_generator = report_generator
|
|
|
|
# Collecter et enregistrer les informations détaillées sur les agents
|
|
agents_info = self._collecter_info_agents()
|
|
|
|
logger.info(f"Orchestrator initialisé avec output_dir: {output_dir}")
|
|
logger.info(f"Agents disponibles: JSON={json_agent is not None}, ImageSorter={image_sorter is not None}, ImageAnalyser={image_analyser is not None}, ReportGenerator={report_generator is not None}")
|
|
logger.info(f"Configuration des agents: {json.dumps(agents_info, indent=2)}")
|
|
|
|
def _collecter_info_agents(self) -> Dict[str, Dict[str, Any]]:
|
|
"""
|
|
Collecte des informations détaillées sur les agents configurés
|
|
"""
|
|
agents_info = {}
|
|
|
|
# Information sur l'agent JSON
|
|
if self.json_agent:
|
|
agents_info["json_agent"] = self._get_agent_info(self.json_agent)
|
|
|
|
# Information sur l'agent Image Sorter
|
|
if self.image_sorter:
|
|
agents_info["image_sorter"] = self._get_agent_info(self.image_sorter)
|
|
|
|
# Information sur l'agent Image Analyser
|
|
if self.image_analyser:
|
|
agents_info["image_analyser"] = self._get_agent_info(self.image_analyser)
|
|
|
|
# Information sur l'agent Report Generator
|
|
if self.report_generator:
|
|
agents_info["report_generator"] = self._get_agent_info(self.report_generator)
|
|
|
|
return agents_info
|
|
|
|
def detecter_tickets(self) -> List[str]:
|
|
"""Détecte tous les tickets disponibles dans le répertoire de sortie"""
|
|
logger.info(f"Recherche de tickets dans: {self.output_dir}")
|
|
tickets = []
|
|
|
|
if not os.path.exists(self.output_dir):
|
|
logger.warning(f"Le répertoire de sortie {self.output_dir} n'existe pas")
|
|
print(f"ERREUR: Le répertoire {self.output_dir} n'existe pas")
|
|
return tickets
|
|
|
|
for ticket_dir in os.listdir(self.output_dir):
|
|
ticket_path = os.path.join(self.output_dir, ticket_dir)
|
|
if os.path.isdir(ticket_path) and ticket_dir.startswith("ticket_"):
|
|
tickets.append(ticket_path)
|
|
|
|
logger.info(f"Tickets trouvés: {len(tickets)}")
|
|
print(f"Tickets détectés: {len(tickets)}")
|
|
return tickets
|
|
|
|
def lister_tickets(self) -> Dict[int, str]:
|
|
"""Liste les tickets disponibles et retourne un dictionnaire {index: chemin}"""
|
|
tickets = self.detecter_tickets()
|
|
ticket_dict = {}
|
|
|
|
print("\nTickets disponibles:")
|
|
for i, ticket_path in enumerate(tickets, 1):
|
|
ticket_id = os.path.basename(ticket_path)
|
|
ticket_dict[i] = ticket_path
|
|
print(f"{i}. {ticket_id}")
|
|
|
|
return ticket_dict
|
|
|
|
def trouver_rapport(self, extraction_path: str, ticket_id: str) -> Dict[str, Optional[str]]:
|
|
"""
|
|
Cherche le rapport du ticket dans différents emplacements possibles (JSON ou MD)
|
|
|
|
Args:
|
|
extraction_path: Chemin de l'extraction
|
|
ticket_id: ID du ticket (ex: T0101)
|
|
|
|
Returns:
|
|
Un dictionnaire avec les chemins des fichiers JSON et MD s'ils sont trouvés
|
|
"""
|
|
result: Dict[str, Optional[str]] = {"json_path": None, "md_path": None}
|
|
|
|
# Liste des emplacements possibles pour les rapports
|
|
possible_locations = [
|
|
# 1. Dans le répertoire d'extraction directement
|
|
extraction_path,
|
|
|
|
# 2. Dans un sous-répertoire "data"
|
|
os.path.join(extraction_path, "data"),
|
|
|
|
# 3. Dans un sous-répertoire spécifique au ticket pour les rapports
|
|
os.path.join(extraction_path, f"{ticket_id}_rapports"),
|
|
|
|
# 4. Dans un sous-répertoire "rapports"
|
|
os.path.join(extraction_path, "rapports")
|
|
]
|
|
|
|
# Vérifier chaque emplacement
|
|
for base_location in possible_locations:
|
|
# Chercher le fichier JSON
|
|
json_path = os.path.join(base_location, f"{ticket_id}_rapport.json")
|
|
if os.path.exists(json_path):
|
|
logger.info(f"Rapport JSON trouvé à: {json_path}")
|
|
result["json_path"] = json_path
|
|
|
|
# Chercher le fichier Markdown
|
|
md_path = os.path.join(base_location, f"{ticket_id}_rapport.md")
|
|
if os.path.exists(md_path):
|
|
logger.info(f"Rapport Markdown trouvé à: {md_path}")
|
|
result["md_path"] = md_path
|
|
|
|
if not result["json_path"] and not result["md_path"]:
|
|
logger.warning(f"Aucun rapport trouvé pour {ticket_id} dans {extraction_path}")
|
|
|
|
return result
|
|
|
|
def traiter_ticket(self, ticket_path: str) -> bool:
|
|
"""Traite un ticket spécifique et retourne True si le traitement a réussi"""
|
|
logger.info(f"Début du traitement du ticket: {ticket_path}")
|
|
print(f"\nTraitement du ticket: {os.path.basename(ticket_path)}")
|
|
|
|
success = False
|
|
extractions_trouvees = False
|
|
|
|
if not os.path.exists(ticket_path):
|
|
logger.error(f"Le chemin du ticket n'existe pas: {ticket_path}")
|
|
print(f"ERREUR: Le chemin du ticket n'existe pas: {ticket_path}")
|
|
return False
|
|
|
|
ticket_id = os.path.basename(ticket_path).replace("ticket_", "")
|
|
|
|
for extraction in os.listdir(ticket_path):
|
|
extraction_path = os.path.join(ticket_path, extraction)
|
|
if os.path.isdir(extraction_path):
|
|
extractions_trouvees = True
|
|
logger.info(f"Traitement de l'extraction: {extraction}")
|
|
print(f" Traitement de l'extraction: {extraction}")
|
|
|
|
# Recherche des rapports (JSON et MD) dans différents emplacements
|
|
rapports = self.trouver_rapport(extraction_path, ticket_id)
|
|
|
|
# Dossier des pièces jointes
|
|
attachments_dir = os.path.join(extraction_path, "attachments")
|
|
|
|
# Dossier pour les rapports générés
|
|
rapports_dir = os.path.join(extraction_path, f"{ticket_id}_rapports")
|
|
os.makedirs(rapports_dir, exist_ok=True)
|
|
|
|
# Préparer les données du ticket à partir des rapports trouvés
|
|
ticket_data = self._preparer_donnees_ticket(rapports, ticket_id)
|
|
|
|
if ticket_data:
|
|
success = True
|
|
logger.info(f"Données du ticket chargées avec succès")
|
|
print(f" Données du ticket chargées")
|
|
|
|
# Traitement JSON avec l'agent JSON
|
|
if self.json_agent:
|
|
logger.info("Exécution de l'agent JSON")
|
|
print(" Analyse du ticket en cours...")
|
|
|
|
# Log détaillé sur l'agent JSON
|
|
agent_info = self._get_agent_info(self.json_agent)
|
|
logger.info(f"Agent JSON: {json.dumps(agent_info, indent=2)}")
|
|
|
|
json_analysis = self.json_agent.executer(ticket_data)
|
|
logger.info("Analyse JSON terminée")
|
|
else:
|
|
logger.warning("Agent JSON non disponible")
|
|
json_analysis = None
|
|
print(" Agent JSON non disponible, analyse ignorée")
|
|
|
|
# Traitement des images
|
|
relevant_images = []
|
|
images_analyses = {}
|
|
if os.path.exists(attachments_dir):
|
|
logger.info(f"Vérification des pièces jointes dans: {attachments_dir}")
|
|
print(f" Vérification des pièces jointes...")
|
|
|
|
# Log détaillé sur l'agent Image Sorter
|
|
if self.image_sorter:
|
|
agent_info = self._get_agent_info(self.image_sorter)
|
|
logger.info(f"Agent Image Sorter: {json.dumps(agent_info, indent=2)}")
|
|
|
|
images_count = 0
|
|
for attachment in os.listdir(attachments_dir):
|
|
attachment_path = os.path.join(attachments_dir, attachment)
|
|
if attachment.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
|
|
images_count += 1
|
|
|
|
if self.image_sorter:
|
|
print(f" Analyse de l'image: {attachment}")
|
|
sorting_result = self.image_sorter.executer(attachment_path)
|
|
is_relevant = sorting_result.get("is_relevant", False)
|
|
reason = sorting_result.get("reason", "")
|
|
|
|
# Ajouter les métadonnées de tri à la liste des analyses
|
|
images_analyses[attachment_path] = {
|
|
"sorting": sorting_result,
|
|
"analysis": None # Sera rempli plus tard si pertinent
|
|
}
|
|
|
|
if is_relevant:
|
|
logger.info(f"Image pertinente identifiée: {attachment} ({reason})")
|
|
print(f" => Pertinente: {reason}")
|
|
relevant_images.append(attachment_path)
|
|
else:
|
|
logger.info(f"Image non pertinente: {attachment} ({reason})")
|
|
print(f" => Non pertinente: {reason}")
|
|
else:
|
|
logger.warning("Image Sorter non disponible")
|
|
|
|
logger.info(f"Images analysées: {images_count}, Images pertinentes: {len(relevant_images)}")
|
|
print(f" Images analysées: {images_count}, Images pertinentes: {len(relevant_images)}")
|
|
else:
|
|
logger.warning(f"Répertoire des pièces jointes non trouvé: {attachments_dir}")
|
|
print(f" Répertoire des pièces jointes non trouvé")
|
|
|
|
# Analyse approfondie des images pertinentes
|
|
# Log détaillé sur l'agent Image Analyser
|
|
if self.image_analyser:
|
|
agent_info = self._get_agent_info(self.image_analyser)
|
|
logger.info(f"Agent Image Analyser: {json.dumps(agent_info, indent=2)}")
|
|
|
|
for image_path in relevant_images:
|
|
if self.image_analyser and json_analysis:
|
|
image_name = os.path.basename(image_path)
|
|
logger.info(f"Analyse approfondie de l'image: {image_name}")
|
|
print(f" Analyse approfondie de l'image: {image_name}")
|
|
analysis_result = self.image_analyser.executer(image_path, contexte=json_analysis)
|
|
|
|
# Ajouter l'analyse au dictionnaire des analyses d'images
|
|
if image_path in images_analyses:
|
|
images_analyses[image_path]["analysis"] = analysis_result
|
|
else:
|
|
images_analyses[image_path] = {
|
|
"sorting": {"is_relevant": True, "reason": "Auto-sélectionné"},
|
|
"analysis": analysis_result
|
|
}
|
|
|
|
logger.info(f"Analyse complétée pour {image_name}")
|
|
|
|
# Génération du rapport final
|
|
rapport_data = {
|
|
"ticket_data": ticket_data,
|
|
"ticket_id": ticket_id,
|
|
"analyse_json": json_analysis,
|
|
"analyse_images": images_analyses,
|
|
"metadata": {
|
|
"timestamp_debut": self._get_timestamp(),
|
|
"ticket_id": ticket_id,
|
|
"images_analysees": images_count if 'images_count' in locals() else 0,
|
|
"images_pertinentes": len(relevant_images)
|
|
}
|
|
}
|
|
|
|
if self.report_generator:
|
|
logger.info("Génération du rapport final")
|
|
print(" Génération du rapport final")
|
|
|
|
# Log détaillé sur l'agent Report Generator
|
|
agent_info = self._get_agent_info(self.report_generator)
|
|
logger.info(f"Agent Report Generator: {json.dumps(agent_info, indent=2)}")
|
|
|
|
rapport_path = os.path.join(rapports_dir, ticket_id)
|
|
os.makedirs(rapport_path, exist_ok=True)
|
|
self.report_generator.executer(rapport_data, rapport_path)
|
|
logger.info(f"Rapport généré à: {rapport_path}")
|
|
print(f" Rapport généré à: {rapport_path}")
|
|
else:
|
|
logger.warning("Report Generator non disponible")
|
|
print(" Report Generator non disponible, génération de rapport ignorée")
|
|
|
|
print(f"Traitement du ticket {os.path.basename(ticket_path)} terminé avec succès.\n")
|
|
logger.info(f"Traitement du ticket {ticket_path} terminé avec succès.")
|
|
else:
|
|
logger.warning(f"Aucune donnée de ticket trouvée pour: {ticket_id}")
|
|
print(f" ERREUR: Aucune donnée de ticket trouvée pour {ticket_id}")
|
|
|
|
if not extractions_trouvees:
|
|
logger.warning(f"Aucune extraction trouvée dans le ticket: {ticket_path}")
|
|
print(f" ERREUR: Aucune extraction trouvée dans le ticket")
|
|
|
|
return success
|
|
|
|
def _preparer_donnees_ticket(self, rapports: Dict[str, Optional[str]], ticket_id: str) -> Optional[Dict]:
|
|
"""
|
|
Prépare les données du ticket à partir des rapports trouvés (JSON et/ou MD)
|
|
|
|
Args:
|
|
rapports: Dictionnaire avec les chemins des rapports JSON et MD
|
|
ticket_id: ID du ticket
|
|
|
|
Returns:
|
|
Dictionnaire avec les données du ticket, ou None si aucun rapport n'est trouvé
|
|
"""
|
|
ticket_data = None
|
|
|
|
# Essayer d'abord le fichier JSON
|
|
if rapports["json_path"]:
|
|
try:
|
|
with open(rapports["json_path"], 'r', encoding='utf-8') as file:
|
|
ticket_data = json.load(file)
|
|
logger.info(f"Données JSON chargées depuis: {rapports['json_path']}")
|
|
print(f" Rapport JSON chargé: {os.path.basename(rapports['json_path'])}")
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du chargement du JSON: {e}")
|
|
print(f" ERREUR: Impossible de charger le fichier JSON: {e}")
|
|
|
|
# Si pas de JSON ou erreur, essayer le Markdown
|
|
if not ticket_data and rapports["md_path"]:
|
|
try:
|
|
# Créer une structure de données à partir du contenu Markdown
|
|
ticket_data = self._extraire_donnees_de_markdown(rapports["md_path"])
|
|
logger.info(f"Données Markdown chargées depuis: {rapports['md_path']}")
|
|
print(f" Rapport Markdown chargé: {os.path.basename(rapports['md_path'])}")
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du chargement du Markdown: {e}")
|
|
print(f" ERREUR: Impossible de charger le fichier Markdown: {e}")
|
|
|
|
# Assurer que l'ID du ticket est correct
|
|
if ticket_data:
|
|
ticket_data["code"] = ticket_id
|
|
|
|
return ticket_data
|
|
|
|
def _extraire_donnees_de_markdown(self, md_path: str) -> Dict:
|
|
"""
|
|
Extrait les données d'un fichier Markdown et les structure
|
|
|
|
Args:
|
|
md_path: Chemin vers le fichier Markdown
|
|
|
|
Returns:
|
|
Dictionnaire structuré avec les données du ticket
|
|
"""
|
|
with open(md_path, 'r', encoding='utf-8') as file:
|
|
content = file.read()
|
|
|
|
# Initialiser la structure de données
|
|
ticket_data = {
|
|
"id": "",
|
|
"code": "",
|
|
"name": "",
|
|
"description": "",
|
|
"messages": [],
|
|
"metadata": {
|
|
"source_file": md_path,
|
|
"format": "markdown"
|
|
}
|
|
}
|
|
|
|
# Extraire le titre (première ligne)
|
|
lines = content.split('\n')
|
|
if lines and lines[0].startswith('# '):
|
|
title = lines[0].replace('# ', '')
|
|
ticket_parts = title.split(':')
|
|
if len(ticket_parts) >= 1:
|
|
ticket_data["code"] = ticket_parts[0].strip()
|
|
if len(ticket_parts) >= 2:
|
|
ticket_data["name"] = ticket_parts[1].strip()
|
|
|
|
# Extraire la description
|
|
description_section = self._extraire_section(content, "description")
|
|
if description_section:
|
|
ticket_data["description"] = description_section.strip()
|
|
|
|
# Extraire les informations du ticket
|
|
info_section = self._extraire_section(content, "Informations du ticket")
|
|
if info_section:
|
|
for line in info_section.split('\n'):
|
|
if ':' in line and line.startswith('- **'):
|
|
key = line.split('**')[1].strip()
|
|
value = line.split(':')[1].strip()
|
|
if key == "id":
|
|
ticket_data["id"] = value
|
|
elif key == "code":
|
|
ticket_data["code"] = value
|
|
# Ajouter d'autres champs au besoin
|
|
|
|
# Extraire les messages
|
|
messages_section = self._extraire_section(content, "Messages")
|
|
if messages_section:
|
|
message_blocks = messages_section.split("### Message ")
|
|
for block in message_blocks[1:]: # Ignorer le premier élément (vide)
|
|
message = {}
|
|
|
|
# Extraire les en-têtes du message
|
|
lines = block.split('\n')
|
|
for i, line in enumerate(lines):
|
|
if line.startswith('**') and ':' in line:
|
|
key = line.split('**')[1].lower()
|
|
value = line.split(':')[1].strip()
|
|
message[key] = value
|
|
|
|
# Extraire le contenu du message (tout ce qui n'est pas un en-tête)
|
|
content_start = 0
|
|
for i, line in enumerate(lines):
|
|
if i > 0 and not line.startswith('**') and line and content_start == 0:
|
|
content_start = i
|
|
break
|
|
|
|
if content_start > 0:
|
|
content_end = -1
|
|
for i in range(content_start, len(lines)):
|
|
if lines[i].startswith('**attachment_ids**') or lines[i].startswith('---'):
|
|
content_end = i
|
|
break
|
|
|
|
if content_end == -1:
|
|
message["content"] = "\n".join(lines[content_start:])
|
|
else:
|
|
message["content"] = "\n".join(lines[content_start:content_end])
|
|
|
|
# Extraire les pièces jointes
|
|
attachments = []
|
|
for line in lines:
|
|
if line.startswith('- ') and '[ID:' in line:
|
|
attachments.append(line.strip('- ').strip())
|
|
|
|
if attachments:
|
|
message["attachments"] = attachments
|
|
|
|
ticket_data["messages"].append(message)
|
|
|
|
return ticket_data
|
|
|
|
def _extraire_section(self, content: str, section_title: str) -> Optional[str]:
|
|
"""
|
|
Extrait une section du contenu Markdown
|
|
|
|
Args:
|
|
content: Contenu Markdown complet
|
|
section_title: Titre de la section à extraire
|
|
|
|
Returns:
|
|
Contenu de la section ou None si non trouvée
|
|
"""
|
|
import re
|
|
|
|
# Chercher les sections de niveau 2 (##)
|
|
pattern = r'## ' + re.escape(section_title) + r'\s*\n(.*?)(?=\n## |$)'
|
|
match = re.search(pattern, content, re.DOTALL)
|
|
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
# Si pas trouvé, chercher les sections de niveau 3 (###)
|
|
pattern = r'### ' + re.escape(section_title) + r'\s*\n(.*?)(?=\n### |$)'
|
|
match = re.search(pattern, content, re.DOTALL)
|
|
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
return None
|
|
|
|
def executer(self, ticket_specifique: Optional[str] = None):
|
|
"""
|
|
Exécute l'orchestrateur soit sur un ticket spécifique, soit permet de choisir
|
|
|
|
Args:
|
|
ticket_specifique: Chemin du ticket spécifique à traiter (optionnel)
|
|
"""
|
|
start_time = time.time()
|
|
|
|
# Stocker le ticket spécifique
|
|
self.ticket_specifique = ticket_specifique
|
|
|
|
# Obtenir la liste des tickets
|
|
if ticket_specifique:
|
|
# Utiliser juste le ticket spécifique
|
|
ticket_dirs = self.detecter_tickets()
|
|
ticket_dirs = [t for t in ticket_dirs if t.endswith(ticket_specifique)]
|
|
logger.info(f"Ticket spécifique à traiter: {ticket_specifique}")
|
|
else:
|
|
# Lister tous les tickets
|
|
ticket_dirs = self.detecter_tickets()
|
|
logger.info(f"Tickets à traiter: {len(ticket_dirs)}")
|
|
|
|
if not ticket_dirs:
|
|
logger.warning("Aucun ticket trouvé dans le répertoire de sortie")
|
|
return
|
|
|
|
# Un seul log de début d'exécution
|
|
logger.info("Début de l'exécution de l'orchestrateur")
|
|
print("Début de l'exécution de l'orchestrateur")
|
|
|
|
# Traitement des tickets
|
|
for ticket_dir in ticket_dirs:
|
|
if ticket_specifique and not ticket_dir.endswith(ticket_specifique):
|
|
continue
|
|
|
|
try:
|
|
self.traiter_ticket(ticket_dir)
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du traitement du ticket {ticket_dir}: {str(e)}")
|
|
print(f"Erreur lors du traitement du ticket {ticket_dir}: {str(e)}")
|
|
traceback.print_exc()
|
|
|
|
# Calcul de la durée d'exécution
|
|
duration = time.time() - start_time
|
|
logger.info(f"Fin de l'exécution de l'orchestrateur (durée: {duration:.2f} secondes)")
|
|
print(f"Fin de l'exécution de l'orchestrateur (durée: {duration:.2f} secondes)")
|
|
|
|
def _get_timestamp(self) -> str:
|
|
"""Retourne un timestamp au format YYYYMMDD_HHMMSS"""
|
|
from datetime import datetime
|
|
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
def _get_agent_info(self, agent: Optional[BaseAgent]) -> Dict:
|
|
"""
|
|
Récupère les informations détaillées sur un agent.
|
|
"""
|
|
if not agent:
|
|
return {"status": "non configuré"}
|
|
|
|
# Récupérer les informations du modèle
|
|
model_info = {
|
|
"nom": agent.nom,
|
|
"model": getattr(agent.llm, "modele", str(type(agent.llm))),
|
|
}
|
|
|
|
# Ajouter les paramètres de configuration s'ils sont disponibles directement dans l'agent
|
|
# Utiliser getattr avec une valeur par défaut pour éviter les erreurs
|
|
model_info["temperature"] = getattr(agent, "temperature", None)
|
|
model_info["top_p"] = getattr(agent, "top_p", None)
|
|
model_info["max_tokens"] = getattr(agent, "max_tokens", None)
|
|
|
|
# Ajouter le prompt système s'il est disponible
|
|
if hasattr(agent, "system_prompt"):
|
|
prompt_preview = getattr(agent, "system_prompt", "")
|
|
# Tronquer le prompt s'il est trop long
|
|
if prompt_preview and len(prompt_preview) > 200:
|
|
prompt_preview = prompt_preview[:200] + "..."
|
|
model_info["system_prompt_preview"] = prompt_preview
|
|
|
|
# Supprimer les valeurs None
|
|
model_info = {k: v for k, v in model_info.items() if v is not None}
|
|
|
|
return model_info |