Optimisation performance (30t/s --> 50t/s) & adaptation parsing json --> llama.cpp & Fix système mémoire

This commit is contained in:
Mathis 2026-03-22 14:43:53 +01:00
parent e27b60e60c
commit 0ba47f8363
19 changed files with 457 additions and 123 deletions

View file

@ -647,7 +647,7 @@ class Superviseur(commands.Bot):
logger.info(f"AI interaction triggered (Monitoring: {is_monitoring})")
# ... (same caching logic)
if message.guild:
if message.guild and hasattr(message.author, 'guild_permissions'):
perms = message.author.guild_permissions
if check_is_admin(perms):
self._invalidate_caches(message.guild.id)
@ -676,13 +676,23 @@ class Superviseur(commands.Bot):
user_id = str(message.author.id)
guild_name = sanitize_guild_name(message.guild.name) if message.guild else "Direct"
history = await build_context_for_model(user_id, max_recent=12, guild_id=guild_name) or ""
from ia.memoire import get_structured_history
resume, structured_history = await get_structured_history(user_id, max_recent=12, guild_id=guild_name)
# Prepare content
content = self._prepare_content(message) + extra_context
# Build payload
payload = self._build_payload(permissions_info, server_context, channels_list, history, content, message)
payload = self._build_payload(
permissions_info,
server_context,
channels_list,
resume,
structured_history,
content,
message
)
# Execute request
async with self._request_semaphore:
@ -853,8 +863,8 @@ class Superviseur(commands.Bot):
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()
# Remove bot mention (Désactivé pour que le LLM voit qu'il est mentionné)
# content = content.lstrip(f'<@{self.user.id}>').lstrip(f'<@!{self.user.id}>').strip()
# Handle attachments
if message.attachments:
@ -935,20 +945,35 @@ class Superviseur(commands.Bot):
permissions_info: str,
server_context: str,
channels_list: str,
history: str,
resume: str,
structured_history: list,
content: str,
message
message,
is_monitoring: bool = False
) -> dict:
"""Build LLM payload."""
"""Build LLM payload with interaction mode context."""
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}"
# Indicateur de mode pour éviter que l'IA ne bégaye sur sa légitimité à répondre
mode_indicator = "[MODE: RÉPONSE DIRECTE (Vous avez été interpellé par l'utilisateur)]"
if is_monitoring:
mode_indicator = "[MODE: SURVEILLANCE HEURISTIQUE (N'intervenez que si nécessaire via ALERT/INSIGHT)]"
import time
current_time = time.strftime('%H:%M:%S le %d/%m/%Y')
time_info = f"[Heure actuelle du serveur : {current_time}]"
prompt_system = f"{self.system_prompt}\n{time_info}\n{permissions_info}\n{mode_indicator}\n{user_name_info}{server_context}{channels_list}"
if resume:
prompt_system += f"\nContexte global (mémoire) :\n{resume}"
vision_model = any(v in self.ollama_model.lower() for v in
['llava', 'bakllava', 'moondream', 'llama3.2-vision', 'minicpm-v']) and message.attachments
return self.llm.build_payload(
prompt=prompt,
system_prompt=self.system_prompt,
prompt=content,
system_prompt=prompt_system,
structured_history=structured_history,
vision_model=vision_model,
attachments=message.attachments
)
@ -968,7 +993,21 @@ class Superviseur(commands.Bot):
# 2. Nettoyage des éventuels backticks de code et résidus de JSON
clean_reply = clean_reply.replace('```json', '').replace('```', '').strip()
# 3. Filtrer les marqueurs internes fréquents (Leak prevention)
# 3. Supprimer les balises de réflexion (Thinking/Thought) et leur contenu
clean_reply = re.sub(r'<thought>.*?</thought>', '', clean_reply, flags=re.DOTALL | re.IGNORECASE)
clean_reply = re.sub(r'<reflexion>.*?</reflexion>', '', clean_reply, flags=re.DOTALL | re.IGNORECASE)
clean_reply = re.sub(r'\[THOUGHT\].*?\[/THOUGHT\]', '', clean_reply, flags=re.DOTALL | re.IGNORECASE)
# 4. Nettoyage des tokens spéciaux de modèles (ChatML/DeepSeek etc)
tokens_to_remove = [
"<|end_of_sentence|>", "<|endoftext|>", "<|end|>", "<|start|>",
"<|assistant|>", "<|user|>", "<|system|>", "<|channel|>",
"<|constrain|>", "<|message|>", "<|thought|>"
]
for token in tokens_to_remove:
clean_reply = clean_reply.replace(token, "")
# 5. Filtrer les marqueurs internes fréquents (Leak prevention)
markers_to_remove = [
"[Public]", "[Relevant]", "[Irrelevant]", "[Alerte]", "[Insight]",
"[CRITICITÉ", "[MENACE", "[RISQUE", "[DÉTECTION", "[ANALYSE", "[ACTION"
@ -986,10 +1025,10 @@ class Superviseur(commands.Bot):
else:
clean_reply = clean_reply.replace(marker, "")
# 4. Supprimer les résidus JSON rémanents avec un regex plus large
# 6. 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)
# 5. Supprimer les lignes vides en trop et les espaces multiples
# 7. 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()
@ -1010,7 +1049,7 @@ class Superviseur(commands.Bot):
# Store interaction (Even if silent, we store the input for better context next time)
user_id = str(message.author.id)
guild_id = sanitize_guild_name(message.guild.name) if message.guild else None
guild_id = sanitize_guild_name(message.guild.name) if message.guild else "Direct"
await add_interaction(user_id, message.channel.id, message.content, clean_reply if not is_monitoring else "", guild_id)
# En mode monitoring, on ne répond PAS au canal public sauf si l'IA l'a explicitement demandé via une action
@ -1088,10 +1127,10 @@ class Superviseur(commands.Bot):
action = item['action']
if action == 'NONE':
action_executed = True # Bloque le fallback text même si la réponse est vide
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)
action_logger.log_action(action, str(message.author), str(message.guild), item, success, result)

View file

@ -40,34 +40,60 @@ class LLMManager:
self,
prompt: str,
system_prompt: Optional[str] = None,
structured_history: Optional[list] = None,
vision_model: bool = False,
attachments: Optional[list] = None
) -> Dict[str, Any]:
"""Build the API payload for Ollama."""
payload = {
"model": self.ollama_model,
"prompt": prompt,
"stream": True,
"options": {"temperature": self.ollama_temperature}
}
"""Build the API payload for Ollama or OpenAI Chat API (llama.cpp)."""
is_openai = "/v1/chat/completions" in self.ollama_api_url
if system_prompt:
payload["system"] = system_prompt
if is_openai:
# OpenAI Chat API format
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
if structured_history:
messages.extend(structured_history)
messages.append({"role": "user", "content": prompt})
payload = {
"model": self.ollama_model,
"messages": messages,
"stream": True,
"temperature": self.ollama_temperature,
"max_tokens": 2048
}
else:
# Ollama /api/generate payload (Fallback)
full_prompt = prompt
if structured_history:
hist_str = "\n".join([f"{m['role'].capitalize()}: {m['content']}" for m in structured_history])
full_prompt = f"Historique:\n{hist_str}\n\nUser: {prompt}"
payload = {
"model": self.ollama_model,
"prompt": full_prompt,
"stream": True,
"options": {"temperature": self.ollama_temperature}
}
if system_prompt:
payload["system"] = system_prompt
# Vision support (simplified)
if vision_model and attachments:
for attachment in attachments:
if attachment.content_type and attachment.content_type.startswith('image/'):
if any(v in self.ollama_model.lower() for v in
['llava', 'bakllava', 'moondream', 'llama3.2-vision', 'minicpm-v']):
# Note: actual image handling would require download
pass
# actual image handling would require download and base64 for OpenAI
pass
return payload
async def call_ollama(self, payload: Dict[str, Any]) -> str:
"""Call Ollama API and accumulate response."""
"""Call LLM API and accumulate response."""
session = await self.get_session()
is_openai = "/v1/chat/completions" in self.ollama_api_url
async with session.post(
self.ollama_api_url,
@ -76,86 +102,132 @@ class LLMManager:
) as response:
response.raise_for_status()
# Si on ne streame pas, on récupère tout d'un coup
if not payload.get("stream", True):
data = await response.json()
return data.get("response", "")
accumulated_reply = ""
async for line in response.content:
line = line.decode('utf-8').strip()
accumulated_thoughts = ""
async for line_bytes in response.content:
line = line_bytes.decode('utf-8').strip()
if not line:
continue
# Strip SSE prefix if present
if line.startswith("data: "):
line = line[6:]
elif line.startswith("event:"):
continue
if line == "[DONE]":
break
try:
data = json.loads(line)
if 'thinking' in data and data['thinking']:
print(f"🤔 Le superviseur réfléchit : {data['thinking']}", flush=True)
continue
# 1. Handle Thinking (OpenAI style reasoning or special field)
thought = None
if 'thinking' in data: # Custom llama.cpp field
thought = data['thinking']
elif 'choices' in data and len(data['choices']) > 0:
delta = data['choices'][0].get('delta', {})
thought = delta.get('reasoning_content')
if thought:
accumulated_thoughts += thought
print(f"🤔 Le superviseur réfléchit : {thought}", flush=True)
if 'response' in data:
# 2. Handle Content
chunk = ""
if 'response' in data: # Ollama
chunk = data['response']
elif 'choices' in data and len(data['choices']) > 0: # OpenAI
delta = data['choices'][0].get('delta', {})
if delta.get('content') is not None:
chunk = delta.get('content', "")
elif 'content' in data: # llama.cpp legacy
chunk = data['content']
if chunk:
accumulated_reply += chunk
if chunk.strip():
print(f"🤖 Réponse du Superviseur (fragment) : {chunk}", flush=True)
elif 'action' in data:
elif 'action' in data: # Action already in JSON (unlikely for chat API)
accumulated_reply = line
break
except json.JSONDecodeError:
accumulated_reply += line
# In case of malformed line or raw text
if not line.startswith("{"):
accumulated_reply += line
# Fallback for models that put the final JSON response inside the thought block
if not accumulated_reply.strip() and accumulated_thoughts.strip():
# Test if the thought block contains our strict format using robust search
if re.search(r'\{[\s\n]*[\"\']action[\"\']', accumulated_thoughts, re.IGNORECASE):
print("⚠️ Aucun texte de contenu final mais du JSON détecté dans les pensées. Récupération.", flush=True)
accumulated_reply = accumulated_thoughts
return accumulated_reply
def extract_json_actions(self, text: str) -> Optional[str]:
"""Extract JSON action objects from text more robustly."""
"""Extract JSON action objects from text more robustly using an O(N) brace matcher."""
# 1. First, try to extract from markdown blocks if present
markdown_blocks = re.findall(r'```(?:json)?\s*(.*?)\s*```', text, re.DOTALL)
candidates = markdown_blocks if markdown_blocks else [text]
actions = []
# Regex plus laxiste pour trouver des blocs JSON potentiels
# On cherche des blocs commençant par {"action" ou {'action'
matches = re.finditer(r'\{[\s\n]*[\"\']action[\"\'].*?\}', text, re.DOTALL)
for match in matches:
json_str = match.group(0)
for candidate_text in candidates:
# Try to parse the whole text as a list or dict first
try:
# Tentative de parsing standard
parsed = json.loads(json_str)
if isinstance(parsed, dict) and 'action' in parsed:
parsed = json.loads(candidate_text.strip())
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'action' in item:
actions.append(item)
if actions: return json.dumps(actions)
elif isinstance(parsed, dict) and 'action' in parsed:
actions.append(parsed)
except json.JSONDecodeError:
# Si erreur, on tente de remplacer les simples quotes par des doubles
# (Cas fréquent si le LLM mélange les styles)
try:
# Remplacement très basique (peut échouer si contenu complexe)
json_corrected = json_str.replace("'", '"')
parsed = json.loads(json_corrected)
if isinstance(parsed, dict) and 'action' in parsed:
actions.append(parsed)
continue
except: pass
return json.dumps(actions)
except:
pass
# Méthode récursive des accolades si le bloc est tronqué ou complexe
start = match.start()
brace_count = 0
for i in range(start, len(text)):
if text[i] == '{': brace_count += 1
elif text[i] == '}':
# Fallback: O(N) scan for top-level { ... } blocks
brace_count = 0
current_block = []
for char in candidate_text:
if char == '{':
if brace_count < 0:
brace_count = 0
current_block = []
brace_count += 1
if brace_count > 0:
current_block.append(char)
if char == '}':
if brace_count > 0:
brace_count -= 1
if brace_count == 0:
if brace_count == 0 and current_block:
json_str = "".join(current_block)
current_block = []
try:
candidate = text[start:i+1]
try:
parsed = json.loads(candidate)
except:
parsed = json.loads(candidate.replace("'", '"'))
parsed = json.loads(json_str)
if isinstance(parsed, dict) and 'action' in parsed:
actions.append(parsed)
break
except: pass
if parsed not in actions: # avoid duplicates
actions.append(parsed)
except:
# Fallback with single quote replace
try:
parsed = json.loads(json_str.replace("'", '"'))
if isinstance(parsed, dict) and 'action' in parsed:
if parsed not in actions:
actions.append(parsed)
except:
pass
else:
brace_count = 0
current_block = []
if actions:
return json.dumps(actions)
return None