"""Interprétation automatique des emails par Hermes.""" import os import sys import json import requests from datetime import datetime, timezone, timedelta # Ajouter le chemin pour les imports sys.path.insert(0, '/root/hermes-workspace/gmao-college') from app_new import create_app, db from app_new.outlook.models import OutlookAccount, OutlookMail, OutlookMailInterpretation, OutlookFolder, GmaoContext from app_new.core.models import User def get_openrouter_api_key(): """Récupère la clé API OpenRouter depuis les paramètres.""" from app_new.core.models.settings import AppSettings setting = AppSettings.query.filter_by(key='openrouter_api_key').first() return setting.value if setting else None def get_openrouter_model(): """Recupere le nom du modele OpenRouter depuis les parametres.""" from app_new.core.models.settings import AppSettings return AppSettings.get('openrouter_model', 'poolside/laguna-m.1:free') def get_ollama_model(gmao_context=None): """Récupère le nom du modèle Ollama depuis le contexte ou AppSettings.""" from app_new.core.models.settings import AppSettings if gmao_context and gmao_context.ollama_model: return gmao_context.ollama_model return AppSettings.get('ollama_model', 'gemma3:4b-it') def analyze_email_with_hermes(mail, gmao_context): """Analyse un email avec Hermes et retourne les interprétations. Utilise OpenRouter (cloud) ou Ollama (local) selon la configuration ai_provider. """ provider = gmao_context.ai_provider if gmao_context and gmao_context.ai_provider else 'openrouter' # Préparer le contenu de l'email email_content = f"""Sujet: {mail.subject or '(sans sujet)'} De: {mail.from_name or 'Inconnu'}<{mail.from_email or ''}> À: {mail.to_recipients or ''} Date: {mail.received_at.strftime('%d/%m/%Y %H:%M') if mail.received_at else '-'} Contenu: {mail.body_content or mail.body_preview or '(pas de contenu)'} """ # Ajouter les pièces jointes PDF if mail.attachments: from app_new.outlook.models import OutlookAttachment attachments = OutlookAttachment.query.filter_by(mail_id=mail.id).all() if attachments: email_content += "\n=== PIÈCES JOINTES ===\n" for att in attachments: email_content += f"- {att.name} ({att.content_type})\n" if att.name and att.name.lower().endswith('.pdf'): # Extraction PDF try: import pdfplumber import tempfile if att.content: with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f: f.write(att.content) f.flush() with pdfplumber.open(f.name) as pdf: text = "" for i, page in enumerate(pdf.pages[:5]): text += page.extract_text() or "" if len(text) > 2000: break email_content += f"[CONTENU PDF]:\n{text[:2000]}\n" os.unlink(f.name) except Exception as e: email_content += f"[Erreur extraction PDF: {e}]\n" # Construire le prompt prompt = """Tu es un assistant pour la GMAO (Gestion de Maintenance Assistée par Ordinaire) d'un établissement scolaire. Analyse cet email et EXTRAIS les informations pour créer une ou plusieurs interventions/tâches. IMPORTANT: Tu DOIS répondre UNIQUEMENT avec un JSON valide, sans texte avant ni après. Types possibles: - "curative": Panne, problème, réparation nécessaire (ex: fuite, équipement cassé) - "preventive": Maintenance planifiée, contrôle périodique (ex: inspection, entretien) - "administrative": Tâche administrative à effectuer (ex: déposer des documents, remplir un formulaire, rappel) - "formation": Formation à suivre, session de formation (ex: formation sécurité, recyclage) - "information": Information simple, pas d'action requise Actions possibles: - "create": Créer une nouvelle intervention/tâche - "update": Mettre à jour une intervention existante - "inform": Juste informer, pas d'action Format de réponse OBLIGATOIRE: { "analyses": [ { "type": "curative|preventive|administrative|formation|information", "action": "create|update|inform", "title": "Titre court et précis de l'intervention", "description": "Description détaillée du problème ou de la tâche", "urgency": "haute|normale|basse", "location": "Lieu concerné (salle, bâtiment)", "equipment": "Équipement concerné (nom exact si identifiable, sinon 'Divers')", "suggested_date": "YYYY-MM-DD ou null", "notes": "Notes additionnelles" } ] } Si plusieurs tâches distinctes sont mentionnées, crée plusieurs entrées dans "analyses". Si c'est juste de l'information sans action, utilise "action": "inform". """ # Ajouter le contexte GMAO if gmao_context: full_context = gmao_context.get_full_context() if full_context: prompt += f"\n\n=== CONTEXTE GMAO ===\n{full_context}\n" prompt += f"\n\n=== EMAIL À ANALYSER ===\n{email_content}\n=== FIN DE L'EMAIL ===\n\nFournis ton analyse:" # Appeler le provider IA selon le choix try: if provider == 'ollama': return _call_ollama(prompt, gmao_context) else: return _call_openrouter(prompt, gmao_context) except Exception as e: return {'error': str(e)} def _call_ollama(prompt, gmao_context): """Appelle le service Ollama local avec auto-identification.""" model = get_ollama_model(gmao_context) ollama_host = os.environ.get('OLLAMA_HOST', 'http://ollama:11434') try: # Appel Ollama avec le modèle local response = requests.post( f'{ollama_host}/api/chat', json={ 'model': model, 'messages': [{'role': 'user', 'content': prompt}], 'stream': False, 'options': { 'temperature': 0.1, 'num_ctx': 8192 } }, timeout=60 ) if response.status_code != 200: return {'error': f'Erreur Ollama: {response.status_code} - {response.text[:200]}'} data = response.json() content = data.get('message', {}).get('content', '') except requests.exceptions.ConnectionError: return {'error': 'Service Ollama inaccessible. Vérifiez que le container ollama tourne.'} except Exception as e: return {'error': f'Erreur Ollama: {str(e)}'} return _parse_json_response(content) def _call_openrouter(prompt, gmao_context): """Appelle l'API OpenRouter (cloud).""" api_key = get_openrouter_api_key() if not api_key: return {'error': 'Clé API OpenRouter non configurée'} try: response = requests.post( 'https://openrouter.ai/api/v1/chat/completions', headers={ 'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json' }, json={ 'model': get_openrouter_model(), 'messages': [{'role': 'user', 'content': prompt}], 'max_tokens': 2000 }, timeout=60 ) if response.status_code != 200: return {'error': f'Erreur API: {response.status_code}'} data = response.json() content = data.get('choices', [{}])[0].get('message', {}).get('content', '') except Exception as e: return {'error': str(e)} return _parse_json_response(content) def _parse_json_response(content): """Parse la réponse JSON de l'IA et gère les erreurs de formattage.""" try: # Nettoyer le contenu (enlever les balises markdown) if '```json' in content: content = content.split('```json')[1].split('```')[0] elif '```' in content: content = content.split('```')[1].split('```')[0] # Enlever les espaces et retours à la ligne content = content.strip() # Vérifier si le contenu est vide if not content: return {'error': 'Réponse vide de l\'API', 'raw': ''} # Chercher le début du JSON ( { ou [ ) json_start = content.find('{') if json_start == -1: json_start = content.find('[') if json_start > 0: content = content[json_start:] result = json.loads(content) return result except json.JSONDecodeError as e: # Logger l'erreur avec le contenu problématique print(f"[auto_interpret] Erreur JSON: {e}") print(f"[auto_interpret] Contenu reçu (500 premiers chars): {content[:500]}") return {'error': f'Impossible de parser la réponse: {str(e)}', 'raw': content[:500]} def run_auto_interpret(user_id=None): """Exécute l'interprétation automatique pour les utilisateurs activés. Args: user_id: Si fourni, exécute uniquement pour cet utilisateur (mode manuel) Returns: dict: Résultat de l'exécution """ # Si appelé depuis une route Flask, utiliser le contexte existant try: from flask import has_request_context if has_request_context(): # Utiliser le contexte Flask existant return _run_auto_interpret_internal(user_id) except: pass # Sinon, créer une nouvelle application (mode cron) app = create_app() with app.app_context(): return _run_auto_interpret_internal(user_id) def _run_auto_interpret_internal(user_id=None): """Implémentation interne de l'interprétation automatique.""" # Trouver les utilisateurs avec un contexte GMAO if user_id: contexts = GmaoContext.query.filter_by(user_id=user_id).all() else: contexts = GmaoContext.query.filter_by(auto_interpret_enabled=True).all() if not contexts: # Si pas de contexte, créer un contexte par défaut pour l'utilisateur if user_id: gmao_context = GmaoContext(user_id=user_id) db.session.add(gmao_context) db.session.commit() contexts = [gmao_context] else: return {'success': False, 'error': 'Aucun compte Outlook configuré. Veuillez d\'abord connecter votre compte Outlook.'} total_processed = 0 total_created = 0 errors = [] for gmao_context in contexts: user = User.query.get(gmao_context.user_id) if not user: continue # Récupérer le compte Outlook account = OutlookAccount.query.filter_by(user_id=user.id, is_active=True).first() if not account: errors.append(f"Aucun compte Outlook actif pour l'utilisateur {user.id}") continue # Trouver les emails non interprétés des dernières 24h # Uniquement les emails REÇUS (pas envoyés par l'utilisateur) lookback_hours = gmao_context.auto_interpret_lookback_hours or 24 since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours) # Récupérer le dossier "Boîte de réception" s'il existe inbox_folder = OutlookFolder.query.filter_by( account_id=account.id, name='Boîte de réception' ).first() if inbox_folder: # Filtrer par dossier Boîte de réception # On prend plus d'emails car certains seront exclus limit = (gmao_context.auto_interpret_max_emails or 10) * 3 mails = OutlookMail.query.filter( OutlookMail.account_id == account.id, OutlookMail.folder_id == inbox_folder.id, OutlookMail.received_at >= since, ~OutlookMail.id.in_( db.session.query(OutlookMailInterpretation.mail_id) ) ).order_by(OutlookMail.received_at.desc()).limit(limit).all() else: # Si pas de dossier, filtrer par expéditeur (exclure les emails envoyés par l'utilisateur) limit = (gmao_context.auto_interpret_max_emails or 10) * 3 mails = OutlookMail.query.filter( OutlookMail.account_id == account.id, OutlookMail.received_at >= since, OutlookMail.from_email != account.email, # Exclure les emails envoyés ~OutlookMail.id.in_( db.session.query(OutlookMailInterpretation.mail_id) ) ).order_by(OutlookMail.received_at.desc()).limit(limit).all() processed_count = 0 max_to_process = gmao_context.auto_interpret_max_emails or 10 for mail in mails: # Arrêter si on a traité le nombre voulu if processed_count >= max_to_process: break processed_count += 1 # Vérifier si l'expéditeur est exclu if mail.from_email and gmao_context.is_sender_excluded(mail.from_email): # Créer une interprétation refusée pour cet email interpretation = OutlookMailInterpretation( mail_id=mail.id, user_id=user.id, interpretation=f"Email ignoré - expéditeur exclu: {mail.from_email}", analysis_type='information', analysis_action='ignore', suggested_title=mail.subject or 'Email ignoré', suggested_description=f"Email automatiquement ignoré car l'expéditeur '{mail.from_email}' est dans la liste d'exclusion.", suggested_urgency='normale', status='rejected' ) db.session.add(interpretation) total_processed += 1 continue # Analyser l'email result = analyze_email_with_hermes(mail, gmao_context) if 'error' in result: errors.append(f"Email {mail.id}: {result['error']}") continue # Créer les interprétations analyses = result.get('analyses', []) if not analyses: analyses = [result] # Format legacy for analysis in analyses: # Créer toutes les interprétations, y compris les informations suggested_date = None if analysis.get('suggested_date'): try: suggested_date = datetime.strptime(analysis['suggested_date'], '%Y-%m-%d').date() except: pass interpretation = OutlookMailInterpretation( mail_id=mail.id, user_id=user.id, interpretation=analysis.get('description', mail.subject or ''), analysis_type=analysis.get('type', 'curative'), analysis_action=analysis.get('action', 'create'), suggested_title=analysis.get('title', mail.subject or 'Sans titre'), suggested_description=analysis.get('description', ''), suggested_urgency=analysis.get('urgency', 'normale'), suggested_location=analysis.get('location', ''), suggested_equipment=analysis.get('equipment', 'Divers'), suggested_date=suggested_date, user_context=gmao_context.get_full_context() if gmao_context else '', status='pending' ) db.session.add(interpretation) total_created += 1 total_processed += 1 db.session.commit() return { 'success': True, 'processed': total_processed, 'created': total_created, 'errors': errors } def get_last_run_status(): """Retourne le statut de la dernière exécution.""" # TODO: Implémenter un suivi des exécutions return { 'last_run': None, 'next_run': None, 'emails_processed': 0 } if __name__ == '__main__': """Point d'entrée pour le cron.""" result = run_auto_interpret() print(json.dumps(result, indent=2, ensure_ascii=False))