915 lines
40 KiB
Python
915 lines
40 KiB
Python
|
|
"""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()
|
|||
|
|
|