158 lines
No EOL
5.8 KiB
Python
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 |