422 lines
No EOL
16 KiB
Python
422 lines
No EOL
16 KiB
Python
"""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)) |