fix: gpt-oss:20b ollama, streaming, tableaux JSON, recherche flexible salons/categories

This commit is contained in:
pi 2026-06-13 14:46:02 +00:00
parent 14985f6dbb
commit 189d56026b
21 changed files with 2824 additions and 491 deletions

914
core/bot.py Normal file
View file

@ -0,0 +1,914 @@
"""Main Superviseur Bot class - refactored for better organization."""
import discord
from discord.ext import commands
import aiohttp
import asyncio
import os
import time
import json
import logging
import re
from typing import Dict, Optional
from .utils import format_mentions_in_text, sanitize_guild_name
from .permissions import WhitelistManager, check_is_admin, get_permissions_info
from .messaging import MessagingManager
from .context import build_context_for_message
from .llm import LLMManager
from .redis_worker import RedisWorker
from .commands import setup_commands
from .voice import VoiceManager
from .dispatcher import AssetDispatcher
from .tasks import TaskManager
from .action_router import ActionRouter
import datetime
import pytz
from brain.memoire import add_interaction, build_context_for_model, flush_all, MEMORY_DIR
from brain.moderation import init_db, log_infraction, scan_message_toxicity
from commandes.autres.config import config
from commandes.autres.logger import action_logger
class MockMessage:
"""Mock discord.Message for voice-triggered interactions."""
def __init__(self, author, channel, content, guild=None):
self.author = author
self.channel = channel
self.content = content
self.guild = guild or (channel.guild if hasattr(channel, 'guild') else None)
self.attachments = []
self.mentions = []
self.channel_mentions = []
self.role_mentions = []
self.jump_url = ""
async def reply(self, content, **kwargs):
"""Mock reply - sends a message to the channel/user instead."""
# For vocal interaction, we don't need a prefix in DM as it's a private chat
is_dm = isinstance(self.channel, (discord.DMChannel, discord.User, discord.Member))
prefix = "" if is_dm else f"**{self.author.display_name}** (Vocal) : "
return await self.channel.send(f"{prefix}{content}", **kwargs)
logger = logging.getLogger('Superviseur')
class Superviseur(commands.Bot):
"""Main bot class for Superviseur Discord bot."""
def __init__(
self,
guild_id: int,
llama_model,
system_prompt: str,
command_prefix: str,
intents: discord.Intents,
temperature: float = 0.4,
model_loaded: bool = True
):
super().__init__(command_prefix=command_prefix, intents=intents)
# Configuration
self.guild_id = guild_id
self.llama_model = llama_model
self.system_prompt = system_prompt
self.temperature = temperature
self.model_loaded = model_loaded
# Identity
self.system_name = "Bêta"
admins_env = os.getenv("ADMIN_IDS", "")
self.admin_ids = [int(i.strip()) for i in admins_env.split(",") if i.strip().isdigit()]
self.introspection_channel_id = int(os.getenv("INTROSPECTION_CHANNEL_ID", 0))
# Assets (Staff)
assets_env = os.getenv("PRIMARY_ASSETS_IDS", "")
self.asset_ids = [int(i.strip()) for i in assets_env.split(",") if i.strip().isdigit()]
# Spam Prevention
self.insight_cooldowns = {} # {user_id: timestamp}
self.insight_cooldown_duration = 1800 # 30 minutes
self.insight_threshold = 5
self.ignored_words = {"salut", "cc", "hello", "ça va", "ca va", "bjr", "slt", "yo", "re", "ok"}
self.monitoring_cooldowns = {}
# Caches
self.channels_list_cache = {}
self.server_context_cache = {}
self._module_cache: Dict[str, object] = {}
# HTTP session
self.http_session: Optional[aiohttp.ClientSession] = None
# Concurrency control
self.max_concurrent_requests = int(os.environ.get("SUPERVISEUR_MAX_CONCURRENT", 4))
self._request_semaphore = asyncio.Semaphore(self.max_concurrent_requests)
# Metrics
self.metrics = {
"total_llm_requests": 0,
"total_llm_success": 0,
"total_llm_errors": 0,
"total_llm_time": 0.0,
}
# Redis
self.aioredis = None
try:
import redis.asyncio as aioredis
self.aioredis = aioredis
except Exception:
pass
self.redis_worker: Optional[RedisWorker] = None
# Whitelist
self.whitelist_manager = WhitelistManager()
# Messaging
self.messaging = MessagingManager(self)
# Disable command prefix for natural language focus
# command_prefix is set by the constructor argument (e.g. "!")
# Voice Management
self.voice_manager = VoiceManager(self)
# LLM
self.llm = llama_model
# Dispatcher & Sub-managers
self.dispatcher = AssetDispatcher(self)
self.task_manager = TaskManager(self)
self.action_router = ActionRouter(self)
# Init SQLite Moderation DB
init_db()
# Logging avec fuseau horaire local (Europe/Paris)
self.timezone = pytz.timezone(os.getenv("TIMEZONE", "Europe/Paris"))
def local_time_converter(*args):
return datetime.datetime.now(self.timezone).timetuple()
logging.Formatter.converter = local_time_converter
logging.basicConfig(
level=logging.INFO, # Augmenté à INFO par défaut pour le root
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Sourdine sur les logs Discord trop bavards (RTCP, Gateway)
logging.getLogger('discord').setLevel(logging.WARNING)
logging.getLogger('discord.voice_state').setLevel(logging.WARNING)
logging.getLogger('discord.gateway').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('huggingface_hub').setLevel(logging.WARNING)
try:
logging.getLogger('discord.ext.voice_recv').setLevel(logging.WARNING)
except: pass
# ==================== Utility Methods ====================
async def introspection_log(self, title: str, description: str, color: discord.Color = discord.Color.light_grey()):
"""Internal telemetry log - bypassing AI to preserve resources."""
if not self.introspection_channel_id: return
channel = self.get_channel(self.introspection_channel_id) or await self.fetch_channel(self.introspection_channel_id)
if channel:
await self.messaging.send_embed(
channel,
f"INTROSPECTION: {title}",
description,
color=color
)
def get_user_level(self, user_id: int, user_name: Optional[str] = None) -> Optional[str]:
"""Get user's whitelist level."""
return self.whitelist_manager.get_user_level(user_id, user_name)
def has_whitelist_permission(
self,
user_id: int,
required_level: str,
user_name: Optional[str] = None
) -> bool:
"""Check whitelist permission."""
return self.whitelist_manager.has_permission(user_id, required_level, user_name)
# ==================== Event Handlers ====================
async def on_ready(self):
"""Called when bot is ready."""
print(f'Logged in as {self.user}')
# Register commands
await setup_commands(self)
# Create memory directories
import os
for guild in self.guilds:
sanitized_name = sanitize_guild_name(guild.name)
guild_dir = os.path.join(MEMORY_DIR, sanitized_name)
os.makedirs(guild_dir, exist_ok=True)
print(f'Memory directory created for guild {guild.name}: {guild_dir}')
# Setup HTTP session (not needed for llama-cpp-python, but kept for compatibility)
pass
# Setup Redis
# Start background tasks
await self.voice_manager.initialize()
self.task_manager.start_all()
async def on_member_join(self, member):
"""Handle member join + cache invalidation."""
self._invalidate_caches(member.guild.id)
welcome_channel_id = config.get_welcome_channel(member.guild.id)
if welcome_channel_id:
channel = member.guild.get_channel(welcome_channel_id)
if channel:
embed = discord.Embed(
title="Bienvenue !",
description=f"Bienvenue {member.mention} sur {member.guild.name} !",
color=discord.Color.green()
)
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
await channel.send(embed=embed)
async def on_member_remove(self, member):
"""Handle member leave + cache invalidation."""
self._invalidate_caches(member.guild.id)
goodbye_channel_id = config.get_goodbye_channel(member.guild.id)
if goodbye_channel_id:
channel = member.guild.get_channel(goodbye_channel_id)
if channel:
embed = discord.Embed(
title="Au revoir !",
description=f"{member.name} a quitté {member.guild.name}.",
color=discord.Color.red()
)
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
await channel.send(embed=embed)
async def on_message(self, message):
"""Handle incoming messages."""
if message.author == self.user:
return
logger.debug(f"Message received from {message.author} in {message.channel}")
# 1. Silent Moderation Scan (Background)
# On ne bloque pas le bot pour l'analyse, mais on la lance pour chaque message
asyncio.create_task(self._handle_silent_scan(message))
# 2. DM Treatment
if isinstance(message.channel, discord.DMChannel):
processed = await self.dispatcher.handle_dm_response(message)
if processed: return
await self._handle_ai_interaction(message)
return
# 3. Mention/Ping Check
if not self._should_respond(message):
return
logger.info(f"◈ SYSTEM: Interaction directe détectée pour '{message.author.display_name}'")
# Direct interaction (AI Response)
await self._handle_ai_interaction(message)
# ==================== Cache Management ====================
def _invalidate_caches(self, guild_id: int):
"""Invalidate caches for a guild."""
self.channels_list_cache.pop(guild_id, None)
self.server_context_cache.pop(guild_id, None)
# Keep caches updated on changes
async def on_guild_channel_create(self, channel):
self._invalidate_caches(channel.guild.id)
async def on_guild_channel_delete(self, channel):
self._invalidate_caches(channel.guild.id)
async def on_voice_state_update(self, member, before, after):
"""Handle voice state changes for cleanup if needed."""
# On-demand only, so no automatic connection logic here.
pass
async def on_guild_channel_update(self, before, after):
self._invalidate_caches(before.guild.id)
async def on_guild_role_create(self, role):
self._invalidate_caches(role.guild.id)
async def on_guild_role_delete(self, role):
self._invalidate_caches(role.guild.id)
async def on_guild_role_update(self, before, after):
self._invalidate_caches(before.guild.id)
# ==================== Heuristic Engine (Risk Assessment) ====================
# ==================== Moderation ====================
# ==================== Moderation Silencieuse ====================
async def _handle_silent_scan(self, message):
"""Analyze message toxicity in the background and log to SQLite."""
if not message.content or len(message.content.strip()) < 3:
return
try:
# On ne scanne que si un modèle est chargé
if not self.model_loaded:
return
res = await scan_message_toxicity(self.llm, message.content)
if res and res.get('score', 0) > 0.4:
score = res['score']
reason = res.get('reason', 'N/A')
logger.info(f"◈ MODERATION: Infraction détectée ({score}/1.0) pour {message.author.display_name}: {reason}")
# Enregistrement en DB
log_infraction(
user_id=str(message.author.id),
username=message.author.display_name,
guild_id=str(message.guild.id) if message.guild else "DM",
content=message.content,
score=score,
reason=reason
)
except Exception as e:
logger.error(f"Erreur lors du scan silencieux: {e}")
def _should_respond(self, message) -> bool:
"""Check if bot should respond to message."""
if message.content.startswith(self.command_prefix):
return True
if self.user in message.mentions:
logger.info("◈ DEBUG: Triggered by standard mentions list")
return True
# Robust Mention Check (Regex Fallback)
bot_id = str(self.user.id)
logger.info(f"◈ DEBUG: Checking mention for ID {bot_id} in '{message.content[:30]}...'")
if f"<@{bot_id}>" in message.content or f"<@!{bot_id}>" in message.content:
logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)")
return True
return False
# ==================== AI Interaction ====================
async def _handle_ai_interaction(self, message, extra_context="", is_monitoring=False):
"""Handle AI interaction."""
logger.info(f"AI interaction triggered (Monitoring: {is_monitoring})")
# ... (same caching logic)
if message.guild:
perms = message.author.guild_permissions
if check_is_admin(perms):
self._invalidate_caches(message.guild.id)
# Build context
author_perms = message.author.guild_permissions if message.guild else None
# Détection du rôle pour le prompt
if message.author.id in self.admin_ids:
role_name = "ADMIN"
elif message.author.id in self.asset_ids:
role_name = "ASSET"
else:
role_name = "SUJET"
permissions_info = get_permissions_info(
author_perms,
self.get_user_level(message.author.id) or 'none',
role_name=role_name
)
channels_list, server_context = build_context_for_message(
message.guild,
self.channels_list_cache,
self.server_context_cache
)
user_id = str(message.author.id)
guild_name = sanitize_guild_name(message.guild.name) if message.guild else "Direct"
# Limite stricte de l'historique pour éviter la saturation CPU
history = await build_context_for_model(user_id, max_recent=8, guild_id=guild_name) or ""
# Prepare content
content = self._prepare_content(message) + extra_context
# Build payload
payload = self._build_payload(permissions_info, server_context, channels_list, history, content, message)
# Execute request
async with self._request_semaphore:
# Decider quel modèle utiliser
# Modèle HEAVY pour Admin/Asset ou si on lui parle directement
# Modèle FAST pour le monitoring pur (unloads the server)
is_direct = self._should_respond(message) or message.author.id in self.admin_ids
# For llama-cpp-python, we don't need to specify different models in payload
# The model is already set in the LLMManager initialization
async def run_call():
if self.redis_worker:
resp = await self.redis_worker.enqueue_job(payload, timeout=605) # 10 minutes + 5 seconds
return resp.get("result", "") if resp else ""
else:
return await self.llm.call_llama(payload)
try:
self.metrics["total_llm_requests"] += 1
start = time.perf_counter()
if is_monitoring:
# Pas d'indicateur de frappe en mode surveillance
accumulated_reply = await run_call()
else:
async with message.channel.typing():
accumulated_reply = await run_call()
# --- ReAct Loop for Memory (READ_LOGS) ---
if "READ_LOGS" in accumulated_reply:
logger.info("◈ SYSTEM: Memory access requested (READ_LOGS). Fetching logs and re-prompting...")
logs_context = self.get_recent_logs()
# Update payload with logs
extra_memory_context = f"\n\n[SYSTÈME: Voici les logs demandés. Utilisez-les pour répondre.]\n{logs_context}"
payload["prompt"] += extra_memory_context
# Re-run the call with memory
if is_monitoring:
accumulated_reply = await run_call()
else:
async with message.channel.typing():
accumulated_reply = await run_call()
# ----------------------------------------
await self._process_response(accumulated_reply, message, is_monitoring=is_monitoring)
dur = time.perf_counter() - start
self.metrics["total_llm_success"] += 1
self.metrics["total_llm_time"] += dur
except Exception as e:
try:
dur = time.perf_counter() - start
self.metrics["total_llm_errors"] += 1
self.metrics["total_llm_time"] += dur
except:
pass
logger.error(f"LLM error: {e}")
if not is_monitoring:
await self.messaging.reply_with_limit(message, f"Erreur: {e}")
async def handle_voice_interaction(self, user_name: str, user_id: int, content: str, channel):
"""Bridge for real-time voice-to-text interaction."""
try:
# Create a mock objects/context for internal processing
mock_author = self.get_user(user_id) or await self.fetch_user(user_id)
if not mock_author:
logger.error(f"◈ SYSTEM: Voice trigger FAIL - User {user_id} ({user_name}) introuvable.")
return
logger.info(f"◈ SYSTEM: Voice trigger processing for {user_name}: '{content}'")
# Role detection
role_name = "ADMIN" if user_id in self.admin_ids else "ASSET" if user_id in self.asset_ids else "SUJET"
# REFINEMENT: Only respond to Admins and Assets (Staff)
if role_name == "SUJET":
logger.info(f"◈ SYSTEM: Voice trigger ignored for SUJET {user_name}")
return
# Specialized context for vocal origin - FORCE PLAIN TEXT DIALOGUE
vocal_context = (
f"\n[MODE: DIALOGUE VOCAL DIRECT - PRIORITÉ ABSOLUE]\n"
f"L'interlocuteur {user_name} ({role_name}) vous parle à l'oral.\n"
f"RÈGLES CRITIQUES :\n"
f"1. RÉPONDEZ DIRECTEMENT en langage naturel. NE PAS UTILISER DE FORMAT JSON.\n"
f"2. INTERDICTION DE GÉNÉRER DES TÂCHES, INSIGHTS OU ALERTES.\n"
f"3. Votre réponse sera envoyée en MESSAGE PRIVÉ à cet utilisateur.\n"
f"4. Soyez concis, clinique et engagez la conversation.\n"
f"REMARQUE : Ignorez la règle 'JSON UNIQUEMENT' pour cette transmission.\n"
)
# Build context
author_perms = mock_author.guild_permissions if hasattr(mock_author, 'guild') else None
permissions_info = get_permissions_info(author_perms, self.get_user_level(user_id) or 'none', role_name=role_name)
# We don't need channel guild if we respond in DM
channels_list, server_context = build_context_for_message(None, self.channels_list_cache, self.server_context_cache)
# Prepare history (DM history is better here)
history = await build_context_for_model(str(user_id), max_recent=12) or ""
# Create MockMessage targeting the USER (for DM response)
mock_msg = MockMessage(author=mock_author, channel=mock_author, content=content)
# Prepare a specialized system prompt for vocal mode (no JSON requirement)
vocal_system_prompt = self.system_prompt.split("FORMAT DE RÉPONSE STRICT")[0]
vocal_system_prompt += (
"\n[PROTOCOLE VOCAL ACTIF]\n"
"RÉPONDEZ EXCLUSIVEMENT EN TEXTE PUR. INTERDICTION TOTALE DE GÉNÉRER DU JSON OU DES MARQUEURS.\n"
"Soyez concis, clinique et engagez une conversation fluide en Message Privé avec l'utilisateur."
)
# Prepare payload with specialized prompt
payload = self.llm.build_payload(
prompt=f"{permissions_info}\nNom d'utilisateur : {mock_author.display_name}{server_context}{channels_list}\n{history}\n\nUser: {content}",
system_prompt=vocal_system_prompt,
vision_model=False
)
async with self._request_semaphore:
try:
analysis = await self.llm.call_llama(payload)
if not analysis:
logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.")
return
# --- ReAct Loop for Memory (READ_LOGS) in Voice Mode ---
if "READ_LOGS" in analysis:
logger.info("◈ SYSTEM: Voice memory access requested (READ_LOGS). Fetching logs...")
logs_context = self.get_recent_logs()
extra_memory_context = f"\n\n[SYSTÈME: Voici les logs demandés. Utilisez-les pour répondre.]\n{logs_context}"
payload["prompt"] += extra_memory_context
analysis = await self.llm.call_llama(payload)
# ----------------------------------------------------
logger.debug(f"◈ DEBUG: Raw vocal response: {analysis[:100]}...")
# Use standard _process_response with mock_msg
await self._process_response(analysis, mock_msg)
logger.info(f"◈ SYSTEM: Voice trigger response processed for {user_name}.")
except Exception as e:
logger.error(f"◈ SYSTEM: Error during voice trigger AI processing: {e}")
except Exception as e:
logger.error(f"◈ SYSTEM: Error in handle_voice_interaction bridge: {e}")
def _prepare_content(self, message) -> str:
"""Prepare message content for LLM."""
if not message: return ""
content = message.content
# Replace channel mentions <#123> -> #general
for channel in message.channel_mentions:
content = content.replace(f'<#{channel.id}>', f'#{channel.name}')
# Replace role mentions <@&123> -> @admin
for role in message.role_mentions:
content = content.replace(f'<@&{role.id}>', f'@{role.name}')
# Replace user mentions <@123> or <@!123> -> @User
for mention in message.mentions:
if mention.id != self.user.id:
content = content.replace(f'<@{mention.id}>', f'@{mention.display_name}')
content = content.replace(f'<@!{mention.id}>', f'@{mention.display_name}')
# Remove bot mention
content = content.lstrip(f'<@{self.user.id}>').lstrip(f'<@!{self.user.id}>').strip()
# Handle attachments
if message.attachments:
for attachment in message.attachments:
if attachment.content_type and attachment.content_type.startswith('image/'):
content += f" [Image: {attachment.filename}]" if content else f"[Image: {attachment.filename}]"
else:
content += f" [Fichier: {attachment.filename}]"
return content
def get_recent_logs(self, limit: int = 15) -> str:
"""Read and format recent action logs for context."""
log_path = "data/actions.log"
if not os.path.exists(log_path):
return ""
try:
with open(log_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
last_lines = lines[-limit:]
formatted_logs = []
for line in last_lines:
if "ACTION:" in line:
try:
parts = line.split("ACTION:", 1)
if len(parts) > 1:
json_str = parts[1].strip()
data = json.loads(json_str)
timestamp = data.get("timestamp", "").split("T")[1].split(".")[0]
action = data.get("action", "UNKNOWN")
user = data.get("user", "System")
params = data.get("params", {})
success = data.get("success", True)
# Format details based on action type
details = ""
if action == 'SEND_MESSAGE':
details = f"(to: {params.get('channel_id') or params.get('user_id')}, content: {params.get('content', '')[:20]}...)"
elif 'CHANNEL' in action:
# Handle channel actions (list of channels)
items = params.get('channels', [])
if items:
names = [i.get('channel_name', 'unknown') for i in items]
details = f"(channels: {', '.join(names)})"
elif 'ROLE' in action:
items = params.get('roles', [])
if items:
names = [i.get('role_name', 'unknown') for i in items]
details = f"(roles: {', '.join(names)})"
elif action in ['KICK', 'BAN', 'WARN', 'MUTE', 'TIMEOUT']:
target = params.get('user_id', 'unknown')
reason = params.get('reason', 'no reason')
details = f"(target: {target}, reason: {reason})"
elif action == 'PURGE':
purges = params.get('purges', [])
if purges:
p = purges[0]
details = f"(channel: {p.get('channel_name')}, amount: {p.get('amount')})"
status = "" if success else ""
formatted_logs.append(f"[{timestamp}] {status} {action} {details} by {user}")
except Exception:
continue
if not formatted_logs:
return ""
return "\n[MÉMOIRE DES ACTIONS RÉCENTES (VÉRITÉ)]\n" + "\n".join(formatted_logs) + "\n"
except Exception as e:
logger.error(f"Error reading actions log: {e}")
return ""
def _build_payload(
self,
permissions_info: str,
server_context: str,
channels_list: str,
history: str,
content: str,
message
) -> dict:
"""Build LLM payload."""
user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})"
# We include server_context and channels_list so the AI can answer questions about the server
prompt = f"{permissions_info}\n{server_context}\n{channels_list}\n{user_name_info}\n{history}\n\nUser: {content}"
# For llama-cpp-python, we don't need to check for vision models in the same way
# Vision support would need to be handled differently with llama-cpp-python
vision_model = False # Simplified for now
return self.llm.build_payload(
prompt=prompt,
system_prompt=self.system_prompt,
vision_model=vision_model,
attachments=message.attachments
)
async def _process_response(self, accumulated_reply: str, message, is_monitoring=False):
"""Process LLM response, prevent JSON leakage, and scrub technical tags."""
# Append closing brace if missing (safety)
if accumulated_reply.strip() and not accumulated_reply.strip().endswith('}'):
accumulated_reply = accumulated_reply.strip() + "\n}"
json_str = self.llm.extract_json_actions(accumulated_reply)
action_executed = False
# 1. Nettoyer la réponse textuelle (texte hors-JSON)
clean_reply = accumulated_reply
if json_str:
# On retire le bloc JSON identifié
clean_reply = clean_reply.replace(json_str, "").strip()
# 2. Nettoyage Universel : Suppression des balises hallucinées (<|channel|>, thought, etc.)
# On supprime tout ce qui est entre < > (tags techniques)
clean_reply = re.sub(r'<[^>]+?>', '', clean_reply)
# 3. Nettoyage des résidus de formatage
clean_reply = clean_reply.replace('```json', '').replace('```', '').strip()
# 4. Suppression des clés orphelines si le modèle a foiré le JSON
clean_reply = re.sub(r'[“"\'](thought|action|response)[“"\']\s*:\s*[“"\'].*?[“"\'],?', '', clean_reply, flags=re.DOTALL | re.IGNORECASE)
# 5. Retrait ultime des accolades
if clean_reply.strip().startswith('{') or clean_reply.strip().endswith('}'):
clean_reply = clean_reply.strip('{}').strip()
clean_reply = clean_reply.strip()
is_explicit_none = False
responses = []
full_response_for_memory = ""
if json_str:
try:
# Normalisation JSON (guillemets)
json_str_norm = json_str.replace('', '"').replace('', '"').replace('', "'").replace('', "'")
data = json.loads(json_str_norm)
actions_list = data if isinstance(data, list) else [data]
for action_data in actions_list:
# Signaux BETA (Alert/Insight)
await self._handle_beta_signals(action_data, message)
# Normalisation des accès aux clés
norm_data = {k.lower(): v for k, v in action_data.items()}
if norm_data.get('action', '').upper() == 'NONE' and not norm_data.get('response'):
is_explicit_none = True
if norm_data.get('response'):
# Scrub tags from the specific response field too
resp_text = re.sub(r'<[^>]+?>', '', str(norm_data.get('response'))).strip()
if resp_text:
responses.append(resp_text)
# Exécution des actions système
res = await self._execute_actions(action_data, message, clean_reply, is_monitoring=is_monitoring)
if res: action_executed = True
except Exception as e:
logger.debug(f"JSON parsing/processing error: {e}")
# Reconstruction de la mémoire de conversation
final_responses_text = "\n".join(responses)
if final_responses_text:
full_response_for_memory = final_responses_text
elif clean_reply:
full_response_for_memory = clean_reply
# Store interaction
user_id = str(message.author.id)
guild_id = sanitize_guild_name(message.guild.name) if message.guild else None
mem_response = full_response_for_memory if not is_monitoring else ""
# Nettoyage final des tags techniques pour la mémoire
mem_response = re.sub(r'<[^>]+?>', '', mem_response).strip()
await add_interaction(user_id, message.channel.id, message.content, mem_response, guild_id)
# En mode monitoring, silence sauf demande explicite
if is_monitoring and not action_executed:
return
# Envoi de la réponse finale si pas déjà fait par une action
if not action_executed:
final_output = final_responses_text if responses else clean_reply
# Nettoyage final avant envoi
final_output = re.sub(r'<[^>]+?>', '', final_output).strip()
if final_output:
logger.debug(f"◈ Sending response: {final_output[:100]}...")
await self.messaging.reply_with_limit(message, format_mentions_in_text(final_output))
elif not is_monitoring:
# Si on a un JSON avec une response, l'extraire
if json_str:
try:
data = json.loads(json_str)
actions = data if isinstance(data, list) else [data]
for act in actions:
resp = act.get('response', '')
if resp:
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
return
except:
pass
logger.debug(f"◈ Raw response was: {accumulated_reply[:200]}...")
fallback_msg = "Hmm, quelque chose s'est mal passé dans ma réponse. Tu peux reformuler ?"
await self.messaging.reply_with_limit(message, fallback_msg)
async def _handle_beta_signals(self, action_data, message):
"""Handle Alert (Relevant) and Insight (Irrelevant) signals with full context."""
insight = action_data.get('insight')
alert = action_data.get('alert')
importance = action_data.get('importance', 0)
# Préparation du bloc de données structurées
context_data = {
"author_mention": message.author.mention,
"author_name": message.author.display_name,
"channel_mention": message.channel.mention if hasattr(message.channel, 'mention') else 'DM',
"content": f"*{message.content[:250]}{'...' if len(message.content) > 250 else ''}*",
"jump_url": message.jump_url if message.jump_url else None
}
# 1. ALERT -> L'ADMIN (Relevant) - Triage Critique
if alert and self.admin_ids:
# Formatage de l'alerte pour l'Admin (markdown propre)
admin_trigger_text = (
f"{alert}\n\n"
f"**◈ CONTEXTE DÉTECTION ◈**\n"
f"- **Sujet** : {context_data['author_mention']} ({context_data['author_name']})\n"
f"- **Salon** : {context_data['channel_mention']}\n"
+ (f"- **Lien direct** : [Accéder au message]({context_data['jump_url']})" if context_data['jump_url'] else "- **Source** : Transmission Vocale Directe")
)
for admin_id in self.admin_ids:
admin = self.get_user(admin_id) or await self.fetch_user(admin_id)
if admin:
await self.messaging.send_embed(
admin,
f"ALERTE RELEVANT (Priorité {importance}/10)",
admin_trigger_text,
color=discord.Color.red()
)
# 2. INSIGHT -> STAFF (Irrelevant) - Triage Opérationnel
if insight and self.asset_ids:
if alert:
logger.warning(f"◈ ALERT RELEVANT: {alert}")
else:
if importance > 7:
logger.info(f"◈ INSIGHT IRRELEVANT (High Importance {importance}): {insight[:50]}...")
# Utilisation du Dispatcher avec données structurées
await self.dispatcher.dispatch_insight(insight, importance, message.guild, context_data)
async def _execute_actions(self, action_data, message, final_content, is_monitoring=False) -> bool:
"""Execute JSON actions and provide feedback."""
action_executed = False
async def process_item(item):
nonlocal action_executed
if not isinstance(item, dict) or 'action' not in item:
return
action = item['action']
resp = item.get('response', "").strip()
if resp and not is_monitoring:
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
action_executed = True
if action != 'NONE':
success, result = await self.action_router.execute_action(action, item, message)
action_logger.log_action(action, str(message.author), str(message.guild), item, success, result)
# Feedback to user for systemic actions if not in monitoring
if not is_monitoring:
if success:
if result: # Some actions return a success string
await self.messaging.reply_with_limit(message, f"◈ SUCCESS: {result}")
else:
# Default success feedback for important actions if they are silent
if action in ['JOIN_VOICE', 'LEAVE_VOICE', 'FORGET_USER']:
confirmations = {
'JOIN_VOICE': "Signal reçu. Je rejoins le salon vocal.",
'LEAVE_VOICE': "Opération terminée. Je quitte le salon vocal.",
'FORGET_USER': "Protocole d'effacement terminé. Vos données ont été supprimées."
}
await self.messaging.reply_with_limit(message, f"{confirmations.get(action, 'Action exécutée avec succès.')}")
else:
# Error feedback
err_msg = result or "Une erreur est survenue lors de l'exécution."
await self.messaging.reply_with_limit(message, f"◈ ERREUR: {err_msg}")
action_executed = True
if isinstance(action_data, list):
executed = set()
for item in action_data:
key = json.dumps(item, sort_keys=True)
if key in executed: continue
executed.add(key)
await process_item(item)
elif isinstance(action_data, dict):
await process_item(action_data)
return action_executed
# ==================== Metrics & Cleanup ====================
def get_metrics(self) -> Dict:
"""Return runtime metrics."""
merged = {**self.metrics}
try:
from brain.memoire import get_metrics as _m
mem = _m()
merged["memory"] = mem
except Exception:
merged["memory"] = {}
if self.redis_worker:
merged["queue_pending"] = len(self.redis_worker._pending_jobs)
return merged
async def close(self):
"""Clean shutdown."""
try:
await flush_all()
except Exception:
logger.exception("Error flushing memories")
await self.llm.close_session()
if self.redis_worker:
await self.redis_worker.close()
await super().close()