Beta/core/llm.py

158 lines
No EOL
5.8 KiB
Python

"""Ollama integration for Superviseur bot - replaces llama-cpp-python for better stability."""
import json
import logging
import asyncio
import aiohttp
import re
import os
from typing import Optional, Dict, Any, List
logger = logging.getLogger('Superviseur')
class LLMManager:
"""Manages LLM interactions via Ollama API."""
def __init__(
self,
model_name: str = "gpt-oss:20b",
base_url: str = None,
temperature: float = 0.5
):
self.model_name = model_name
self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
self.temperature = temperature
def build_payload(
self,
prompt: str,
system_prompt: Optional[str] = None,
vision_model: bool = False,
attachments: Optional[list] = None,
force_json: bool = True
) -> Dict[str, Any]:
"""Build the payload for Ollama Generate API."""
return {
"model": self.model_name,
"prompt": prompt,
"system": system_prompt or "Tu es une IA serviable.",
"stream": True, # Streaming pour voir la réponse en direct dans le terminal
"format": "json" if force_json else "",
"options": {
"temperature": self.temperature,
"num_ctx": 16384,
"repeat_penalty": 1.2,
"num_predict": 4096,
"top_p": 0.9,
"top_k": 40
}
}
async def call_llama(self, payload: Dict[str, Any]) -> str:
"""Call Ollama Generate API and stream response."""
try:
# Automatic payload conversion for legacy compatibility (e.g. from moderation.py)
ollama_payload = {
"model": payload.get("model", self.model_name),
"prompt": payload["prompt"],
"system": payload.get("system", payload.get("system_prompt", "Tu es une IA serviable.")),
"stream": True, # Streaming pour voir la réponse en direct
"think": False, # Désactive le reasoning GPT-OSS
"format": "",
"options": {
"temperature": payload.get("temperature", self.temperature),
"num_ctx": payload.get("n_ctx", 16384),
"num_predict": payload.get("max_tokens") or payload.get("num_predict", 8192),
"repeat_penalty": payload.get("repeat_penalty") or payload.get("repeat_last_n", 1.2),
"stop": payload.get("stop", [])
}
}
accumulated_reply = ""
api_url = f"{self.base_url}/api/generate"
# Build headers with optional API key
headers = {}
api_key = os.environ.get("OLLAMA_API_KEY", "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with aiohttp.ClientSession() as session:
async with session.post(api_url, json=ollama_payload, headers=headers) as response:
if response.status != 200:
err_text = await response.text()
logger.error(f"Ollama API Error ({response.status}): {err_text}")
return ""
# Streaming en direct dans le terminal
async for line in response.content:
if line:
try:
chunk = json.loads(line)
resp = chunk.get("response", "")
if resp:
accumulated_reply += resp
print(resp, end="", flush=True)
if chunk.get("done"):
break
except json.JSONDecodeError:
continue
print()
return accumulated_reply.strip()
except Exception as e:
logger.error(f"Error calling Ollama: {e}")
return ""
async def close_session(self):
pass
def extract_json_actions(self, text: str) -> Optional[str]:
"""
Extract JSON action objects from text with surgical precision.
Handles both single objects {...} and arrays [...].
Also handles // comments that GPT-OSS sometimes inserts.
"""
if not text:
return None
text = text.strip()
# Remove // comments (GPT-OSS sometimes adds them)
text = re.sub(r'//.*?\n', '\n', text)
text = re.sub(r'//.*?$', '', text, flags=re.MULTILINE)
# 1. Try to find a JSON array [...]
first_bracket = text.find('[')
if first_bracket != -1:
last_bracket = text.rfind(']')
if last_bracket > first_bracket:
array_str = text[first_bracket:last_bracket+1]
try:
json.loads(array_str)
return array_str
except json.JSONDecodeError:
pass
# 2. Try to find a JSON object {...}
first_brace = text.find('{')
if first_brace == -1:
return None
last_brace = text.rfind('}')
if last_brace == -1 or last_brace < first_brace:
return None
stack = []
for i in range(first_brace, len(text)):
if text[i] == '{':
stack.append('{')
elif text[i] == '}':
if stack:
stack.pop()
if not stack:
match = text[first_brace:i+1]
if '"action"' in match.lower():
return match
return None