Files
David Blanc Brioir 4823fd1b10 Fix: Gestion robuste des valeurs None dans .lower()
Problème:
- AttributeError: 'NoneType' object has no attribute 'lower'
- Se produisait quand section.get("title") retournait None au lieu de ""

Corrections:
- llm_classifier.py:
  * is_excluded_section(): (section.get("title") or "").lower()
  * filter_indexable_sections(): (s.get("chapterTitle") or "").lower()
  * validate_classified_sections(): Idem pour chapter_title et section_title

- llm_validator.py:
  * apply_corrections(): Ajout de vérification "if title and ..."

- llm_chat.py:
  * call_llm(): Ajout d'une exception si provider est None/vide

Pattern de correction:
  AVANT: section.get("title", "").lower()  # Échoue si None
  APRÈS: (section.get("title") or "").lower()  # Sûr avec None

Raison:
.get(key, default) retourne le default SEULEMENT si la clé n'existe pas.
Si la clé existe avec valeur None, .get() retourne None, pas le default!

Donc: {"title": None}.get("title", "") -> None (pas "")

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 22:26:29 +01:00

323 lines
9.9 KiB
Python

"""Multi-LLM Integration Module for Chat Conversation.
Provides a unified interface for calling different LLM providers with streaming support:
- Ollama (local, free)
- Mistral API
- Anthropic API (Claude)
- OpenAI API
Example:
>>> for token in call_llm("Hello world", "ollama", "qwen2.5:7b"):
... print(token, end="", flush=True)
"""
import os
import json
import time
import logging
from typing import Iterator, Optional
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger(__name__)
class LLMError(Exception):
"""Base exception for LLM errors."""
pass
def call_llm(
prompt: str,
provider: str,
model: str,
stream: bool = True,
temperature: float = 0.7,
max_tokens: int = 16384,
) -> Iterator[str]:
"""Call an LLM provider with unified interface.
Args:
prompt: The prompt to send to the LLM.
provider: Provider name ("ollama", "mistral", "anthropic", "openai").
model: Model name (e.g., "qwen2.5:7b", "mistral-small-latest", "claude-sonnet-4-5").
stream: Whether to stream tokens (default: True).
temperature: Temperature for generation (0-1).
max_tokens: Maximum tokens to generate (default 16384 for philosophical discussions).
Yields:
Tokens as strings (when streaming).
Raises:
LLMError: If provider is invalid or API call fails.
Example:
>>> for token in call_llm("Test", "ollama", "qwen2.5:7b"):
... print(token, end="")
"""
if not provider:
raise LLMError("Provider cannot be None or empty")
provider = provider.lower()
logger.info(f"[LLM Call] Provider: {provider}, Model: {model}, Stream: {stream}")
start_time = time.time()
try:
if provider == "ollama":
yield from _call_ollama(prompt, model, temperature, stream)
elif provider == "mistral":
yield from _call_mistral(prompt, model, temperature, max_tokens, stream)
elif provider == "anthropic":
yield from _call_anthropic(prompt, model, temperature, max_tokens, stream)
elif provider == "openai":
yield from _call_openai(prompt, model, temperature, max_tokens, stream)
else:
raise LLMError(f"Provider '{provider}' non supporté. Utilisez: ollama, mistral, anthropic, openai")
except Exception as e:
elapsed = time.time() - start_time
logger.error(f"[LLM Call] Error after {elapsed:.2f}s: {e}")
raise
elapsed = time.time() - start_time
logger.info(f"[LLM Call] Completed in {elapsed:.2f}s")
def _call_ollama(prompt: str, model: str, temperature: float, stream: bool) -> Iterator[str]:
"""Call Ollama API with streaming support.
Args:
prompt: The prompt text.
model: Ollama model name.
temperature: Temperature (0-1).
stream: Whether to stream.
Yields:
Tokens from the model.
"""
import requests
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
url = f"{base_url}/api/generate"
payload = {
"model": model,
"prompt": prompt,
"stream": stream,
"options": {
"temperature": temperature,
}
}
try:
response = requests.post(url, json=payload, stream=stream, timeout=120)
response.raise_for_status()
if stream:
# Stream mode: each line is a JSON object with "response" field
for line in response.iter_lines():
if line:
try:
data = json.loads(line)
token = data.get("response", "")
if token:
yield token
# Check if done
if data.get("done", False):
break
except json.JSONDecodeError:
continue
else:
# Non-stream mode
data = response.json()
yield data.get("response", "")
except requests.exceptions.RequestException as e:
raise LLMError(f"Ollama API error: {e}")
def _call_mistral(prompt: str, model: str, temperature: float, max_tokens: int, stream: bool) -> Iterator[str]:
"""Call Mistral API with streaming support.
Args:
prompt: The prompt text.
model: Mistral model name.
temperature: Temperature (0-1).
max_tokens: Max tokens to generate.
stream: Whether to stream.
Yields:
Tokens from the model.
"""
api_key = os.getenv("MISTRAL_API_KEY")
if not api_key:
raise LLMError("MISTRAL_API_KEY not set in environment")
try:
from mistralai import Mistral
except ImportError:
raise LLMError("mistralai package not installed. Run: pip install mistralai")
client = Mistral(api_key=api_key)
messages = [{"role": "user", "content": prompt}]
try:
if stream:
# Streaming mode
stream_response = client.chat.stream(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
for chunk in stream_response:
if chunk.data.choices:
delta = chunk.data.choices[0].delta
if hasattr(delta, 'content') and delta.content:
yield delta.content
else:
# Non-streaming mode
response = client.chat.complete(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
if response.choices:
yield response.choices[0].message.content or ""
except Exception as e:
raise LLMError(f"Mistral API error: {e}")
def _call_anthropic(prompt: str, model: str, temperature: float, max_tokens: int, stream: bool) -> Iterator[str]:
"""Call Anthropic API (Claude) with streaming support.
Args:
prompt: The prompt text.
model: Claude model name.
temperature: Temperature (0-1).
max_tokens: Max tokens to generate.
stream: Whether to stream.
Yields:
Tokens from the model.
"""
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise LLMError("ANTHROPIC_API_KEY not set in environment")
try:
from anthropic import Anthropic
except ImportError:
raise LLMError("anthropic package not installed. Run: pip install anthropic")
client = Anthropic(api_key=api_key)
try:
if stream:
# Streaming mode
with client.messages.stream(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[{"role": "user", "content": prompt}],
) as stream:
for text in stream.text_stream:
yield text
else:
# Non-streaming mode
response = client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[{"role": "user", "content": prompt}],
)
if response.content:
yield response.content[0].text
except Exception as e:
raise LLMError(f"Anthropic API error: {e}")
def _call_openai(prompt: str, model: str, temperature: float, max_tokens: int, stream: bool) -> Iterator[str]:
"""Call OpenAI API with streaming support.
Args:
prompt: The prompt text.
model: OpenAI model name.
temperature: Temperature (0-1).
max_tokens: Max tokens to generate.
stream: Whether to stream.
Yields:
Tokens from the model.
"""
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise LLMError("OPENAI_API_KEY not set in environment")
try:
from openai import OpenAI
except ImportError:
raise LLMError("openai package not installed. Run: pip install openai")
client = OpenAI(api_key=api_key)
messages = [{"role": "user", "content": prompt}]
# Detect if model uses max_completion_tokens (o1, gpt-5.x) instead of max_tokens
uses_completion_tokens = model.startswith("o1") or model.startswith("gpt-5")
try:
if stream:
# Streaming mode
if uses_completion_tokens:
stream_response = client.chat.completions.create(
model=model,
messages=messages,
max_completion_tokens=max_tokens,
stream=True,
)
else:
stream_response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
for chunk in stream_response:
if chunk.choices:
delta = chunk.choices[0].delta
if hasattr(delta, 'content') and delta.content:
yield delta.content
else:
# Non-streaming mode
if uses_completion_tokens:
response = client.chat.completions.create(
model=model,
messages=messages,
max_completion_tokens=max_tokens,
stream=False,
)
else:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=False,
)
if response.choices:
yield response.choices[0].message.content or ""
except Exception as e:
raise LLMError(f"OpenAI API error: {e}")