gmao/app_new/outlook/auto_interpret.py

423 lines
16 KiB
Python
Raw Normal View History

2026-08-14 18:02:25 +02:00
"""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
# AppSettings.get déchiffre les valeurs sensibles ``enc:...``.
return AppSettings.get('openrouter_api_key')
2026-08-14 18:02:25 +02:00
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))