fix: gpt-oss:20b ollama, streaming, tableaux JSON, recherche flexible salons/categories
This commit is contained in:
parent
14985f6dbb
commit
189d56026b
21 changed files with 2824 additions and 491 deletions
160
core/llm.py
Normal file
160
core/llm.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""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
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
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."""
|
||||
async with self._lock:
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue