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

View file

@ -0,0 +1,144 @@
import discord
import logging
from typing import Dict
logger = logging.getLogger('Superviseur')
class ActionRouter:
"""Routs generated AI semantic actions to the physical Discord moderative or administrative commands."""
def __init__(self, bot):
self.bot = bot
self._module_cache = {}
async def execute_action(self, action: str, params: Dict, message: discord.Message) -> (bool, str):
"""Execute an action from LLM response."""
# --- INSIGHT HOOK (LLM generated task for Staff) ---
if action == 'INSIGHT':
content = params.get('message') or params.get('content') or params.get('insight') or "Pas de contenu"
if 'conflit' in content.lower() or 'tension' in content.lower():
logger.debug("Insight (Tension) logged.")
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": message.content[:200]
}
await self.bot.dispatcher.dispatch_insight(
insight=content,
importance=params.get('importance', 2),
guild=message.guild,
context_data=context_data
)
return True, None
# -----------------------------
# --- ALERT HOOK (LLM generated alert for Admin) ---
if action == 'ALERT':
content = params.get('message') or params.get('content') or params.get('alert') or "Alerte vide"
embed = discord.Embed(title="🚨 ALERTE DIRECTE (IA)", description=content, color=discord.Color.red())
embed.set_footer(text=f"Source: {message.author.display_name} | Salon: {message.channel}")
for admin_id in self.bot.admin_ids:
u = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
if u: await u.send(embed=embed)
return True, None
# -----------------------------
# --- VOICE ACTIONS ---
if action == 'JOIN_VOICE':
voice_state = getattr(message.author, 'voice', None)
if not voice_state and self.bot.guild_id:
guild = self.bot.get_guild(self.bot.guild_id)
if guild:
member = guild.get_member(message.author.id) or await guild.fetch_member(message.author.id)
if member:
voice_state = member.voice
if voice_state and voice_state.channel:
await self.bot.voice_manager.join_channel(voice_state.channel)
return True, f"Connecté à {voice_state.channel.name}"
else:
return False, "Vous n'êtes pas dans un salon vocal identifiable par le système."
if action == 'LEAVE_VOICE':
await self.bot.voice_manager.leave_current()
return True, None
# --- PRIVACY ACTIONS ---
if action == 'FORGET_USER':
from ia.memoire import delete_user_memory
delete_user_memory(message.author.id)
return True, None
# Check whitelist
if self.bot.whitelist_manager.whitelist:
required_level = self.bot.whitelist_manager.get_required_level(action)
if required_level and not self.bot.has_whitelist_permission(message.author.id, required_level):
return False, f"Niveau whitelist requis: {required_level}"
action_map = {
'KICK': 'commandes.security.kick',
'BAN': 'commandes.security.ban',
'UNBAN': 'commandes.security.unban',
'UNMUTE': 'commandes.security.unmute',
'MUTE': 'commandes.security.mute',
'TIMEOUT': 'commandes.security.timeout',
'PURGE': 'commandes.security.purge',
'WARN': 'commandes.security.warn',
'LIST_WARNINGS': 'commandes.security.list_warnings',
'CREATE_CHANNEL': 'commandes.salons.creer',
'DELETE_CHANNEL': 'commandes.salons.supprimer',
'MODIFY_CHANNEL': 'commandes.salons.modifier',
'RENAME_CHANNEL': 'commandes.salons.renommer',
'MOVE_CHANNEL': 'commandes.salons.deplacer',
'CREATE_ROLE': 'commandes.roles.creer',
'DELETE_ROLE': 'commandes.roles.supprimer',
'MODIFY_ROLE': 'commandes.roles.modifier',
'RENAME_ROLE': 'commandes.roles.renommer',
'MOVE_ROLE': 'commandes.roles.deplacer',
'ADD_ROLE_TO_USER': 'commandes.roles.add_role',
'REMOVE_ROLE_FROM_USER': 'commandes.roles.remove_role',
'CREATE_CATEGORY': 'commandes.categories.creer',
'DELETE_CATEGORY': 'commandes.categories.supprimer',
'MODIFY_CATEGORY': 'commandes.categories.modifier',
'RENAME_CATEGORY': 'commandes.categories.renommer',
'MOVE_CATEGORY': 'commandes.categories.deplacer',
'PING': 'commandes.autres.ping',
'SET_WELCOME_CHANNEL': 'commandes.autres.setwelcomechannel',
'SET_GOODBYE_CHANNEL': 'commandes.autres.setgoodbyechannel',
'SET_MUTE_ROLE': 'commandes.autres.setmuterole',
'SEND_MESSAGE': 'commandes.autres.send_message',
'HELP': 'commandes.autres.help',
'STATUS': 'commandes.autres.status',
}
module_name = action_map.get(action)
if module_name:
logger.debug(f"Importing module: {module_name}")
try:
module = self._module_cache.get(module_name)
if module is None:
module = __import__(module_name, fromlist=['execute'])
self._module_cache[module_name] = module
logger.debug(f"Module {module_name} imported successfully")
await module.execute(self.bot, params, message)
logger.info(f"Action '{action}' executed successfully")
return True, None
except Exception as e:
error_msg = str(e)
logger.error(f"Error during action execution '{action}': {error_msg}")
return False, error_msg
else:
error_msg = f"Unknown action: {action}"
logger.error(error_msg)
return False, error_msg

View file

@ -21,6 +21,9 @@ from .commands import setup_commands
from .voice import VoiceManager
from .voice import VoiceManager
from .dispatcher import AssetDispatcher
from .tasks import TaskManager
from .action_router import ActionRouter
from .heuristic import HeuristicsManager
import datetime
import pytz
@ -58,29 +61,21 @@ class Superviseur(commands.Bot):
def __init__(
self,
guild_id: int,
ollama_api_url: str,
ollama_model: str,
ollama_timeout: int,
llama_model,
system_prompt: str,
command_prefix: str,
intents: discord.Intents,
ollama_temperature: float = 0.7,
ollama_api_key: Optional[str] = None,
ollama_moderation_model: Optional[str] = None,
ollama_model_fast: Optional[str] = None
temperature: float = 0.4,
model_loaded: bool = True
):
super().__init__(command_prefix=command_prefix, intents=intents)
# Configuration
self.guild_id = guild_id
self.ollama_api_url = ollama_api_url
self.ollama_model = ollama_model
self.ollama_model_fast = ollama_model_fast or ollama_model
self.ollama_moderation_model = ollama_moderation_model or ollama_model
self.ollama_timeout = ollama_timeout
self.llama_model = llama_model
self.system_prompt = system_prompt
self.ollama_temperature = ollama_temperature
self.ollama_api_key = ollama_api_key
self.temperature = temperature
self.model_loaded = model_loaded
# Identity
self.system_name = "Bêta"
@ -142,14 +137,15 @@ class Superviseur(commands.Bot):
# LLM
self.llm = LLMManager(
ollama_api_url=ollama_api_url,
ollama_model=ollama_model,
ollama_timeout=ollama_timeout,
ollama_temperature=ollama_temperature
llama_model=llama_model,
temperature=temperature
)
# Dispatcher
# Dispatcher & Sub-managers
self.dispatcher = AssetDispatcher(self)
self.task_manager = TaskManager(self)
self.action_router = ActionRouter(self)
self.heuristics_manager = HeuristicsManager(self)
# Logging avec fuseau horaire local (Europe/Paris)
self.timezone = pytz.timezone(os.getenv("TIMEZONE", "Europe/Paris"))
@ -217,156 +213,14 @@ class Superviseur(commands.Bot):
os.makedirs(guild_dir, exist_ok=True)
print(f'Memory directory created for guild {guild.name}: {guild_dir}')
# Setup HTTP session
await self.llm.get_session()
# 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.loop.create_task(self._introspection_report_loop())
self.loop.create_task(self._voice_report_loop())
self.loop.create_task(self._voice_report_loop())
self.loop.create_task(self._daily_report_check_loop())
self.loop.create_task(self._gdpr_purge_loop())
# Start interviews for missing assets
for asset_id in self.asset_ids:
if str(asset_id) not in self.dispatcher.assets:
self.loop.create_task(self.dispatcher.start_interview(asset_id))
self.task_manager.start_all()
async def _voice_evaluation_loop(self):
"""Periodic check for tactical voice connections."""
await self.wait_until_ready()
while not self.is_closed():
for guild in self.guilds:
await self.voice_manager.evaluate_and_connect(guild)
await asyncio.sleep(30) # Reduit à 30s pour plus de réactivité en tâche de fond
async def _introspection_report_loop(self):
"""Periodic broadcast of system health metrics."""
await self.wait_until_ready()
while not self.is_closed():
await asyncio.sleep(600) # Every 10 minutes
await self._send_health_report()
async def _send_health_report(self):
"""Build and send a clinical health report embed."""
# Calculate success rate
total = self.metrics["total_llm_requests"]
success = self.metrics["total_llm_success"]
rate = (success / total * 100) if total > 0 else 100
avg_time = (self.metrics["total_llm_time"] / success) if success > 0 else 0
status_report = (
f"**◈ INTELLIGENCE CORE**\n"
f"- Requests Processed : `{total}`\n"
f"- Reliability Rate : `{rate:.1f}%`\n"
f"- Latency Heuristic : `{avg_time:.2f}s` (Target: < 5s)\n\n"
f"**◈ TACTICAL OVERVIEW**\n"
f"- Voice Protocol : `READY`\n"
f"- Active Deployment : `{self.voice_manager.current_channel.name if self.voice_manager.current_channel else 'NONE'}`\n"
)
await self.introspection_log(
"SYSTEM STATUS REPORT",
status_report,
discord.Color.dark_blue()
)
async def _daily_report_check_loop(self):
"""Check for 21:30 and trigger the daily report using local timezone."""
await self.wait_until_ready()
report_sent_today = False
while not self.is_closed():
now = datetime.datetime.now(self.timezone)
if now.hour == 21 and now.minute == 30:
if not report_sent_today:
await self._send_daily_summary()
report_sent_today = True
else:
report_sent_today = False
await asyncio.sleep(60)
async def _gdpr_purge_loop(self):
"""Daily purge of old memory files (30 days) for GDPR compliance."""
from ia.memoire import purge_old_memories
while not self.is_closed():
# Run purge at 03:00 AM
now = datetime.datetime.now(self.timezone)
if now.hour == 3 and now.minute == 0:
logger.info("◈ RGPD: Démarrage de la purge quotidienne...")
deleted = await purge_old_memories(days=30)
logger.info(f"◈ RGPD: Purge terminée. {deleted} fichiers supprimés.")
await asyncio.sleep(65) # Avoid multiple triggers in the same minute
await asyncio.sleep(30)
async def _send_daily_summary(self):
"""Generate and send a dense, highly detailed clinical report at 21:30."""
tasks = self.dispatcher.tasks
tense_sessions = self.metrics.get("daily_tense_sessions", 0)
# 1. Statistiques par Asset
asset_stats = {} # {asset_id: {"name": str, "accepted": 0, "missed": 0}}
for t_id, t_data in tasks.items():
a_id = t_data.get("asset_id")
if a_id not in asset_stats:
asset_name = "Inconnu"
if str(a_id) in self.dispatcher.assets:
asset_name = self.dispatcher.assets[str(a_id)].get("name", "Anonyme")
asset_stats[a_id] = {"name": asset_name, "accepted": 0, "missed": 0, "total": 0}
asset_stats[a_id]["total"] += 1
if t_data["status"] == "ACCEPTED":
asset_stats[a_id]["accepted"] += 1
elif t_data["status"] == "PENDING" and t_data.get("deadline", 0) < time.time():
asset_stats[a_id]["missed"] += 1
stats_lines = []
for a_id, s in asset_stats.items():
stats_lines.append(f"- **{s['name']}** : `{s['accepted']}/{s['total']}` acceptées, `{s['missed']}` manquées.")
# 2. Liste détaillée des tâches
task_details = []
for t_id, t_data in list(tasks.items())[-15:]: # On prend les 15 dernières
status_emoji = "" if t_data["status"] == "ACCEPTED" else "" if t_data.get("deadline", 0) < time.time() else ""
asset_name = self.dispatcher.assets.get(str(t_data['asset_id']), {}).get('name', 'N/A')
task_details.append(f"{status_emoji} `{t_id}` | Staff: {asset_name} | Urgence: {t_data['importance']} | Context: {t_data['content'][:50]}...")
summary = (
f"**◈ RAPPORT ANALYTIQUE DE FIN DE CYCLE ◈**\n"
f"Période : Dernières 24h | Heure : 21h30\n\n"
f"**◈ BILAN GLOBAL DES OPÉRATIONS**\n"
f"- Volume total d'actions de modération : `{len(tasks)}` \n"
f"- Sessions vocales sous tension : `{tense_sessions}`\n\n"
f"**◈ PERFORMANCE DES ASSETS**\n"
+ ("\n".join(stats_lines) if stats_lines else "Aucune activité de staff enregistrée.") + "\n\n"
f"**◈ LOG DÉTAILLÉ DES DERNIERS SIGNAUX**\n"
+ ("\n".join(task_details) if task_details else "Aucun signal capturé.") + "\n\n"
f"**◈ CONCLUSION HEURISTIQUE**\n"
f"Le système est nominal. " + ("PRÉCONISATION : Vigilance accrue requise sur les membres suspects." if tense_sessions > 0 else "État de l'écosystème : Stable.")
)
# 4. Envoi à tous les administrateurs
if summary and self.admin_ids:
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, "SYNTHÈSE CLINIQUE QUOTIDIENNE", summary, color=discord.Color.dark_purple())
# Reset pour le lendemain
self.metrics["daily_tense_sessions"] = 0
# On pourrait purger tasks ici mais on va les garder pour l'instant (ou purger les vieilles)
self.dispatcher.tasks = {k: v for k, v in tasks.items() if v['status'] == 'PENDING' and v.get('deadline', 0) > time.time()}
self.dispatcher._save_data(self.dispatcher.tasks, os.path.join(os.path.dirname(__file__), "..", "active_tasks.json"))
async def _voice_report_loop(self):
"""Periodic broadcast of active voice session summaries."""
await self.wait_until_ready()
while not self.is_closed():
await asyncio.sleep(900) # Every 15 minutes
if self.voice_manager.is_monitoring and self.voice_manager.session_buffer:
report = await self.voice_manager.generate_session_report()
await self.introspection_log("VOICE SESSION UPDATE (15m)", report, discord.Color.blue())
async def on_member_join(self, member):
"""Handle member join."""
@ -471,104 +325,6 @@ class Superviseur(commands.Bot):
# ==================== Heuristic Engine (Risk Assessment) ====================
def _assess_heuristic_risk(self, message) -> bool:
"""
Fast Python-side risk assessment to decide if we should AI-analyze fully.
Returns True if message is 'risky' enough to warrant LLM usage.
"""
score = 0
content = message.content
# CLEAN content for heuristic analysis (ignore mentions in the score)
# We use a copy to avoid modifying the original message object
scoring_content = re.sub(r'<@!?\d+>', '', content).strip()
if not scoring_content: scoring_content = content
# 1. Content Scoring
score = 0
# Proactive Analysis: base score if message has substance
if len(content) > 10:
score += 15
scoring_content = content
scoring_content_lower = scoring_content.lower()
# CAPS LOCK detection (> 35% caps on messages > 4 chars)
if len(scoring_content) > 4:
caps_ratio = sum(1 for c in scoring_content if c.isupper()) / len(scoring_content)
if caps_ratio > 0.35:
score += 35 # Heavy weight
logger.debug(f"◈ HEURISTIC: CAPS detected ({caps_ratio:.2f}) on '{scoring_content[:15]}...'")
# Profanity / Trigger words (lightweight list)
triggers = [
"raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler",
"suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free",
"@everyone", "@here", "detruire", "detruit", "destruction", "kill"
]
for t in triggers:
if t in scoring_content_lower:
score += 50
logger.debug(f"◈ HEURISTIC: Trigger word '{t}' detected")
break
# Link spam detection
if "http" in scoring_content_lower:
score += 20
# Length anomaly
if len(scoring_content) > 300:
score += 20
# 3. Gibberish / Randomness detection (Entropy)
if len(scoring_content) > 8:
unique_chars = len(set(scoring_content_lower))
diversity_ratio = unique_chars / len(scoring_content)
if diversity_ratio < 0.35: # Increased threshold
score += 25
logger.debug(f"◈ HEURISTIC: Low diversity detected ({diversity_ratio:.2f})")
# Too many non-alphanumeric chars (excluding spaces)
symbols = len(re.findall(r'[^a-zA-Z0-9\s]', scoring_content))
symbol_ratio = symbols / len(scoring_content)
if symbol_ratio > 0.15: # Lowered threshold for symbols
score += 30
logger.debug(f"◈ HEURISTIC: High symbol ratio detected ({symbol_ratio:.2f})")
# 4. Leetspeak / Digits in words detection
if len(re.findall(r'[a-zA-Z][0-9][a-zA-Z]', scoring_content)) > 0:
score += 25
logger.debug("◈ HEURISTIC: Leetspeak patterns detected")
# 5. Long word detection (No spaces)
words = scoring_content.split()
if any(len(w) > 25 for w in words):
score += 30
logger.debug("◈ HEURISTIC: Unusually long word detected")
# 3. Mention Spam
# Fallback detection if library fails to populate message.mentions
mention_count = len(message.mentions)
if mention_count == 0:
mention_count = len(re.findall(r'<@!?\d+>', content))
if mention_count > 3:
score += 40
logger.debug(f"◈ HEURISTIC: Mention spam detected ({mention_count} mentions)")
# 4. Keyword Detection (Sensitivity helper)
if score < 45:
for trigger in ["analyse", "écouter", "surveille", "check"]:
if trigger in scoring_content_lower:
score += 30
break
# Threshold decision
# Very sensitive threshold to capture more nuances (as requested)
logger.info(f"◈ MODERATION RISK: Score Final = {score}/15")
return score >= 15
# ==================== Moderation ====================
async def _handle_moderation(self, message):
@ -582,7 +338,7 @@ class Superviseur(commands.Bot):
if len(content_low) < 3 or content_low in self.ignored_words:
return
should_analyze = self._assess_heuristic_risk(message)
should_analyze = self.heuristics_manager.assess_risk(message)
if not should_analyze:
return
@ -627,11 +383,6 @@ class Superviseur(commands.Bot):
logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)")
return True
# Détection du nom "Bêta" ou "Beta" (insensible à la casse)
content_low = message.content.lower()
if "bêta" in content_low or "beta" in content_low:
logger.info("◈ DEBUG: Triggered by name match (Bêta/Beta)")
return True
if message.reference and message.reference.resolved:
if message.reference.resolved.author == self.user:
@ -690,15 +441,15 @@ class Superviseur(commands.Bot):
# 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
target_model = self.ollama_model if is_direct else self.ollama_model_fast
payload["model"] = target_model
# 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=self.ollama_timeout + 5)
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_ollama(payload)
return await self.llm.call_llama(payload)
try:
self.metrics["total_llm_requests"] += 1
@ -803,11 +554,10 @@ class Superviseur(commands.Bot):
system_prompt=vocal_system_prompt,
vision_model=False
)
payload["model"] = self.ollama_model
async with self._request_semaphore:
try:
analysis = await self.llm.call_ollama(payload)
analysis = await self.llm.call_llama(payload)
if not analysis:
logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.")
return
@ -820,7 +570,7 @@ class Superviseur(commands.Bot):
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_ollama(payload)
analysis = await self.llm.call_llama(payload)
# ----------------------------------------------------
logger.debug(f"◈ DEBUG: Raw vocal response: {analysis[:100]}...")
@ -868,7 +618,7 @@ class Superviseur(commands.Bot):
def get_recent_logs(self, limit: int = 15) -> str:
"""Read and format recent action logs for context."""
log_path = "actions.log"
log_path = "data/actions.log"
if not os.path.exists(log_path):
return ""
@ -941,10 +691,12 @@ class Superviseur(commands.Bot):
) -> dict:
"""Build LLM payload."""
user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})"
prompt = f"{self.system_prompt}\n{permissions_info}\n{user_name_info}{server_context}{channels_list}\n{history}\n\nUser: {content}"
# Removed redundant self.system_prompt as it's passed as system_prompt in build_payload
prompt = f"{permissions_info}\n{user_name_info}{server_context}{channels_list}\n{history}\n\nUser: {content}"
vision_model = any(v in self.ollama_model.lower() for v in
['llava', 'bakllava', 'moondream', 'llama3.2-vision', 'minicpm-v']) and message.attachments
# 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,
@ -963,7 +715,7 @@ class Superviseur(commands.Bot):
# 1. Enlever tout ce qui ressemble à du JSON de la réponse "publique"
if json_str:
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL).strip()
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL | re.IGNORECASE).strip()
# 2. Nettoyage des éventuels backticks de code et résidus de JSON
clean_reply = clean_reply.replace('```json', '').replace('```', '').strip()
@ -987,18 +739,22 @@ class Superviseur(commands.Bot):
clean_reply = clean_reply.replace(marker, "")
# 4. Supprimer les résidus JSON rémanents avec un regex plus large
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL)
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL | re.IGNORECASE)
# 5. Supprimer les lignes vides en trop et les espaces multiples
clean_reply = re.sub(r'\n{3,}', '\n\n', clean_reply)
clean_reply = clean_reply.strip()
is_explicit_none = False
if json_str:
try:
actions = json.loads(json_str)
if isinstance(actions, dict): actions = [actions]
for action_data in actions:
if action_data.get('action', '').upper() == 'NONE' and not action_data.get('response'):
is_explicit_none = True
# Traitement Alerte/Insight (Toujours en DM)
await self._handle_beta_signals(action_data, message)
@ -1022,7 +778,7 @@ class Superviseur(commands.Bot):
if not action_executed:
if clean_reply:
await self.messaging.reply_with_limit(message, format_mentions_in_text(clean_reply))
elif not is_monitoring:
elif not is_monitoring and not is_explicit_none:
# Log the raw response to understand why parsing failed
raw_snippet = accumulated_reply.strip()
logger.warning(f"◈ SYSTEM: Failed to parse action or empty response. Raw output: {raw_snippet[:200]}")
@ -1087,13 +843,14 @@ class Superviseur(commands.Bot):
return
action = item['action']
if action == 'NONE':
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
else:
success, result = await self.execute_action(action, item, message)
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
@ -1129,149 +886,6 @@ class Superviseur(commands.Bot):
await process_item(action_data)
return action_executed
# ==================== Action Execution ====================
async def execute_action(self, action: str, params: Dict, message) -> (bool, str):
"""Execute an action from LLM response."""
# --- INSIGHT HOOK (LLM generated task for Staff) ---
if action == 'INSIGHT':
# Extract content from params
content = params.get('message') or params.get('content') or params.get('insight') or "Pas de contenu"
level = params.get('level', 'STAFF')
# Update Social Score if relevant
if 'conflit' in content.lower() or 'tension' in content.lower():
logger.debug("Insight (Tension) logged.")
# Dispatch
# We reconstruct a minimal context if not present
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": message.content[:200]
}
await self.dispatcher.dispatch_insight(
content=content,
importance=params.get('importance', 2),
guild=message.guild,
context=context_data
)
return True, None
# -----------------------------
# --- ALERT HOOK (LLM generated alert for Admin) ---
if action == 'ALERT':
content = params.get('message') or params.get('content') or params.get('alert') or "Alerte vide"
# Send to Admins
embed = discord.Embed(title="🚨 ALERTE DIRECTE (IA)", description=content, color=discord.Color.red())
embed.set_footer(text=f"Source: {message.author.display_name} | Salon: {message.channel}")
for admin_id in self.admin_ids:
u = self.get_user(admin_id)
if u: await u.send(embed=embed)
return True, None
# -----------------------------
# --- VOICE ACTIONS ---
if action == 'JOIN_VOICE':
# Resilience: check message author, but also attempt to find them in the guild
# if we are in a DM interaction or if cache is stale.
voice_state = getattr(message.author, 'voice', None)
if not voice_state and self.guild_id:
guild = self.get_guild(self.guild_id)
if guild:
member = guild.get_member(message.author.id) or await guild.fetch_member(message.author.id)
if member:
voice_state = member.voice
if voice_state and voice_state.channel:
await self.voice_manager.join_channel(voice_state.channel)
return True, f"Connecté à {voice_state.channel.name}"
else:
return False, "Vous n'êtes pas dans un salon vocal identifiable par le système."
if action == 'LEAVE_VOICE':
await self.voice_manager.leave_current()
return True, None
# --- PRIVACY ACTIONS ---
if action == 'FORGET_USER':
from ia.memoire import delete_user_memory
delete_user_memory(message.author.id)
return True, None
# Check whitelist
if self.whitelist_manager.whitelist:
required_level = self.whitelist_manager.get_required_level(action)
if required_level and not self.has_whitelist_permission(message.author.id, required_level):
return False, f"Niveau whitelist requis: {required_level}"
action_map = {
'KICK': 'commandes.security.kick',
'BAN': 'commandes.security.ban',
'UNBAN': 'commandes.security.unban',
'UNMUTE': 'commandes.security.unmute',
'MUTE': 'commandes.security.mute',
'TIMEOUT': 'commandes.security.timeout',
'PURGE': 'commandes.security.purge',
'WARN': 'commandes.security.warn',
'LIST_WARNINGS': 'commandes.security.list_warnings',
'CREATE_CHANNEL': 'commandes.salons.creer',
'DELETE_CHANNEL': 'commandes.salons.supprimer',
'MODIFY_CHANNEL': 'commandes.salons.modifier',
'RENAME_CHANNEL': 'commandes.salons.renommer',
'MOVE_CHANNEL': 'commandes.salons.deplacer',
'CREATE_ROLE': 'commandes.roles.creer',
'DELETE_ROLE': 'commandes.roles.supprimer',
'MODIFY_ROLE': 'commandes.roles.modifier',
'RENAME_ROLE': 'commandes.roles.renommer',
'MOVE_ROLE': 'commandes.roles.deplacer',
'ADD_ROLE_TO_USER': 'commandes.roles.add_role',
'REMOVE_ROLE_FROM_USER': 'commandes.roles.remove_role',
'CREATE_CATEGORY': 'commandes.categories.creer',
'DELETE_CATEGORY': 'commandes.categories.supprimer',
'MODIFY_CATEGORY': 'commandes.categories.modifier',
'RENAME_CATEGORY': 'commandes.categories.renommer',
'MOVE_CATEGORY': 'commandes.categories.deplacer',
'PING': 'commandes.autres.ping',
'SET_WELCOME_CHANNEL': 'commandes.autres.setwelcomechannel',
'SET_GOODBYE_CHANNEL': 'commandes.autres.setgoodbyechannel',
'SET_MUTE_ROLE': 'commandes.autres.setmuterole',
'SEND_MESSAGE': 'commandes.autres.send_message',
'HELP': 'commandes.autres.help',
'STATUS': 'commandes.autres.status',
}
module_name = action_map.get(action)
if module_name:
logger.debug(f"Importing module: {module_name}")
try:
module = self._module_cache.get(module_name)
if module is None:
module = __import__(module_name, fromlist=['execute'])
self._module_cache[module_name] = module
logger.debug(f"Module {module_name} imported successfully")
await module.execute(self, params, message)
logger.info(f"Action '{action}' executed successfully")
return True, None
except Exception as e:
error_msg = str(e)
logger.error(f"Error during action execution '{action}': {error_msg}")
return False, error_msg
else:
error_msg = f"Unknown action: {action}"
logger.error(error_msg)
return False, error_msg
# ==================== Metrics & Cleanup ====================
def get_metrics(self) -> Dict:

View file

@ -9,8 +9,8 @@ from .permissions import check_is_admin
logger = logging.getLogger('Superviseur')
ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "assets_config.json")
TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "active_tasks.json")
ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets_config.json")
TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "active_tasks.json")
class AssetDispatcher:
"""Manages autonomous staff coordination and task assignments."""

View file

@ -1,4 +1,15 @@
def _assess_heuristic_risk(self, message) -> bool:
import logging
import re
logger = logging.getLogger('Superviseur')
class HeuristicsManager:
"""Handles parsing and computing heuristic risk scores for messages."""
def __init__(self, bot):
self.bot = bot
def assess_risk(self, message) -> bool:
"""
Fast Python-side risk assessment to decide if we should AI-analyze fully.
Returns True if message is 'risky' enough to warrant LLM usage.
@ -6,41 +17,92 @@
score = 0
content = message.content
# 1. Social Score Factor (Base scrutiny)
# If user is Suspect (<40), we start with higher risk score
social_data = self.social_manager.get_user_data(message.author.id)
reliability = social_data.get('score', 50)
# CLEAN content for heuristic analysis (ignore mentions in the score)
# We use a copy to avoid modifying the original message object
scoring_content = re.sub(r'<@!?\d+>', '', content).strip()
if not scoring_content: scoring_content = content
if reliability < 30: score += 40 # Very suspicious user -> almost auto-check
elif reliability < 50: score += 20
# 1. Content Scoring
score = 0
# 2. Pattern Matching (Fast Regex/Keywords)
content_lower = content.lower()
# CAPS LOCK detection (> 60% caps on long messages)
# Proactive Analysis: base score if message has substance
if len(content) > 10:
caps_ratio = sum(1 for c in content if c.isupper()) / len(content)
if caps_ratio > 0.6: score += 30
score += 15
scoring_content = content
scoring_content_lower = scoring_content.lower()
# CAPS LOCK detection (> 35% caps on messages > 4 chars)
if len(scoring_content) > 4:
caps_ratio = sum(1 for c in scoring_content if c.isupper()) / len(scoring_content)
if caps_ratio > 0.35:
score += 35 # Heavy weight
logger.debug(f"◈ HEURISTIC: CAPS detected ({caps_ratio:.2f}) on '{scoring_content[:15]}...'")
# Profanity / Trigger words (lightweight list)
triggers = ["raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler", "suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free", "@everyone", "@here"]
triggers = [
"raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler",
"suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free",
"@everyone", "@here", "detruire", "detruit", "destruction", "kill"
]
for t in triggers:
if t in content_lower:
if t in scoring_content_lower:
score += 50
logger.debug(f"◈ HEURISTIC: Trigger word '{t}' detected")
break
# Link spam detection
if "http" in content_lower:
score += 15
# Length anomaly (very long messages often rants)
if len(content) > 400:
if "http" in scoring_content_lower:
score += 20
# 3. Mention Spam
if len(message.mentions) > 3:
score += 40
# Length anomaly
if len(scoring_content) > 300:
score += 20
# 3. Gibberish / Randomness detection (Entropy)
if len(scoring_content) > 8:
unique_chars = len(set(scoring_content_lower))
diversity_ratio = unique_chars / len(scoring_content)
if diversity_ratio < 0.35: # Increased threshold
score += 25
logger.debug(f"◈ HEURISTIC: Low diversity detected ({diversity_ratio:.2f})")
# Too many non-alphanumeric chars (excluding spaces)
symbols = len(re.findall(r'[^a-zA-Z0-9\s]', scoring_content))
symbol_ratio = symbols / len(scoring_content)
if symbol_ratio > 0.15: # Lowered threshold for symbols
score += 30
logger.debug(f"◈ HEURISTIC: High symbol ratio detected ({symbol_ratio:.2f})")
# 4. Leetspeak / Digits in words detection
if len(re.findall(r'[a-zA-Z][0-9][a-zA-Z]', scoring_content)) > 0:
score += 25
logger.debug("◈ HEURISTIC: Leetspeak patterns detected")
# 5. Long word detection (No spaces)
words = scoring_content.split()
if any(len(w) > 25 for w in words):
score += 30
logger.debug("◈ HEURISTIC: Unusually long word detected")
# 3. Mention Spam
# Fallback detection if library fails to populate message.mentions
mention_count = len(message.mentions)
if mention_count == 0:
mention_count = len(re.findall(r'<@!?\d+>', content))
if mention_count > 3:
score += 40
logger.debug(f"◈ HEURISTIC: Mention spam detected ({mention_count} mentions)")
# 4. Keyword Detection (Sensitivity helper)
if score < 45:
for trigger in ["analyse", "écouter", "surveille", "check"]:
if trigger in scoring_content_lower:
score += 30
break
# Threshold decision
# If score > 45, we analyze.
return score >= 45
# Very sensitive threshold to capture more nuances (as requested)
logger.info(f"◈ MODERATION RISK: Score Final = {score}/15")
return score >= 15

159
superviseur/tasks.py Normal file
View file

@ -0,0 +1,159 @@
import asyncio
import datetime
import logging
import time
import os
import discord
logger = logging.getLogger('Superviseur')
class TaskManager:
"""Handles global background tasks, scheduled loops and reporting."""
def __init__(self, bot):
self.bot = bot
def start_all(self):
"""Starts all background loops attached to the bot's event loop."""
self.bot.loop.create_task(self._introspection_report_loop())
self.bot.loop.create_task(self._voice_report_loop())
self.bot.loop.create_task(self._daily_report_check_loop())
self.bot.loop.create_task(self._gdpr_purge_loop())
self.bot.loop.create_task(self._voice_evaluation_loop())
# Start interviews for missing assets
for asset_id in self.bot.asset_ids:
if str(asset_id) not in self.bot.dispatcher.assets:
self.bot.loop.create_task(self.bot.dispatcher.start_interview(asset_id))
async def _voice_evaluation_loop(self):
"""Periodic check for tactical voice connections."""
await self.bot.wait_until_ready()
while not self.bot.is_closed():
for guild in self.bot.guilds:
await self.bot.voice_manager.evaluate_and_connect(guild)
await asyncio.sleep(30) # Reduit à 30s pour plus de réactivité en tâche de fond
async def _introspection_report_loop(self):
"""Periodic broadcast of system health metrics."""
await self.bot.wait_until_ready()
while not self.bot.is_closed():
await asyncio.sleep(600) # Every 10 minutes
await self._send_health_report()
async def _send_health_report(self):
"""Build and send a clinical health report embed."""
total = self.bot.metrics["total_llm_requests"]
success = self.bot.metrics["total_llm_success"]
rate = (success / total * 100) if total > 0 else 100
avg_time = (self.bot.metrics["total_llm_time"] / success) if success > 0 else 0
status_report = (
f"**◈ INTELLIGENCE CORE**\n"
f"- Requests Processed : `{total}`\n"
f"- Reliability Rate : `{rate:.1f}%`\n"
f"- Latency Heuristic : `{avg_time:.2f}s` (Target: < 5s)\n\n"
f"**◈ TACTICAL OVERVIEW**\n"
f"- Voice Protocol : `READY`\n"
f"- Active Deployment : `{self.bot.voice_manager.current_channel.name if self.bot.voice_manager.current_channel else 'NONE'}`\n"
)
await self.bot.introspection_log(
"SYSTEM STATUS REPORT",
status_report,
discord.Color.dark_blue()
)
async def _daily_report_check_loop(self):
"""Check for 21:30 and trigger the daily report using local timezone."""
await self.bot.wait_until_ready()
report_sent_today = False
while not self.bot.is_closed():
now = datetime.datetime.now(self.bot.timezone)
if now.hour == 21 and now.minute == 30:
if not report_sent_today:
await self._send_daily_summary()
report_sent_today = True
else:
report_sent_today = False
await asyncio.sleep(60)
async def _gdpr_purge_loop(self):
"""Daily purge of old memory files (30 days) for GDPR compliance."""
from ia.memoire import purge_old_memories
while not self.bot.is_closed():
now = datetime.datetime.now(self.bot.timezone)
if now.hour == 3 and now.minute == 0:
logger.info("◈ RGPD: Démarrage de la purge quotidienne...")
deleted = await purge_old_memories(days=30)
logger.info(f"◈ RGPD: Purge terminée. {deleted} fichiers supprimés.")
await asyncio.sleep(65)
await asyncio.sleep(30)
async def _send_daily_summary(self):
"""Generate and send a dense, highly detailed clinical report at 21:30."""
tasks = self.bot.dispatcher.tasks
tense_sessions = self.bot.metrics.get("daily_tense_sessions", 0)
asset_stats = {}
for t_id, t_data in tasks.items():
a_id = t_data.get("asset_id")
if a_id not in asset_stats:
asset_name = "Inconnu"
if str(a_id) in self.bot.dispatcher.assets:
asset_name = self.bot.dispatcher.assets[str(a_id)].get("name", "Anonyme")
asset_stats[a_id] = {"name": asset_name, "accepted": 0, "missed": 0, "total": 0}
asset_stats[a_id]["total"] += 1
if t_data["status"] == "ACCEPTED":
asset_stats[a_id]["accepted"] += 1
elif t_data["status"] == "PENDING" and t_data.get("deadline", 0) < time.time():
asset_stats[a_id]["missed"] += 1
stats_lines = []
for a_id, s in asset_stats.items():
stats_lines.append(f"- **{s['name']}** : `{s['accepted']}/{s['total']}` acceptées, `{s['missed']}` manquées.")
task_details = []
for t_id, t_data in list(tasks.items())[-15:]:
status_emoji = "" if t_data["status"] == "ACCEPTED" else "" if t_data.get("deadline", 0) < time.time() else ""
asset_name = self.bot.dispatcher.assets.get(str(t_data['asset_id']), {}).get('name', 'N/A')
task_details.append(f"{status_emoji} `{t_id}` | Staff: {asset_name} | Urgence: {t_data['importance']} | Context: {t_data['content'][:50]}...")
summary = (
f"**◈ RAPPORT ANALYTIQUE DE FIN DE CYCLE ◈**\n"
f"Période : Dernières 24h | Heure : 21h30\n\n"
f"**◈ BILAN GLOBAL DES OPÉRATIONS**\n"
f"- Volume total d'actions de modération : `{len(tasks)}` \n"
f"- Sessions vocales sous tension : `{tense_sessions}`\n\n"
f"**◈ PERFORMANCE DES ASSETS**\n"
+ ("\n".join(stats_lines) if stats_lines else "Aucune activité de staff enregistrée.") + "\n\n"
f"**◈ LOG DÉTAILLÉ DES DERNIERS SIGNAUX**\n"
+ ("\n".join(task_details) if task_details else "Aucun signal capturé.") + "\n\n"
f"**◈ CONCLUSION HEURISTIQUE**\n"
f"Le système est nominal. " + ("PRÉCONISATION : Vigilance accrue requise sur les membres suspects." if tense_sessions > 0 else "État de l'écosystème : Stable.")
)
if summary and self.bot.admin_ids:
for admin_id in self.bot.admin_ids:
admin = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
if admin:
await self.bot.messaging.send_embed(admin, "SYNTHÈSE CLINIQUE QUOTIDIENNE", summary, color=discord.Color.dark_purple())
self.bot.metrics["daily_tense_sessions"] = 0
self.bot.dispatcher.tasks = {k: v for k, v in tasks.items() if v['status'] == 'PENDING' and v.get('deadline', 0) > time.time()}
# Le chemin de Active tasks doit être vers /data si on l'a déplacé.
# Pour l'instant, on sauvegarde dans data/
data_dir = os.path.join(os.path.dirname(__file__), "..", "data")
os.makedirs(data_dir, exist_ok=True)
self.bot.dispatcher._save_data(self.bot.dispatcher.tasks, os.path.join(data_dir, "active_tasks.json"))
async def _voice_report_loop(self):
"""Periodic broadcast of active voice session summaries."""
await self.bot.wait_until_ready()
while not self.bot.is_closed():
await asyncio.sleep(900) # Every 15 minutes
if self.bot.voice_manager.is_monitoring and self.bot.voice_manager.session_buffer:
report = await self.bot.voice_manager.generate_session_report()
await self.bot.introspection_log("VOICE SESSION UPDATE (15m)", report, discord.Color.blue())