Plusieurs gros ajouts :
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question - Mise en place de l'import directement dans les outils dans la sidebar - Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie - Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF - Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama - Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud - version 0.11.0-bêta
This commit is contained in:
178
brain/app/infrastructure/gemini_adapter.py
Normal file
178
brain/app/infrastructure/gemini_adapter.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider.
|
||||
|
||||
Gemini expose un endpoint COMPATIBLE OpenAI
|
||||
(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client
|
||||
"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral.
|
||||
|
||||
Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour
|
||||
l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2
|
||||
appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais
|
||||
atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
|
||||
|
||||
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit
|
||||
# rien). Gemini répond vite ; 120s est large.
|
||||
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class GeminiLLMProvider:
|
||||
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.gemini_api_key:
|
||||
raise LLMProviderError(
|
||||
"Clé API Gemini manquante. Configure-la depuis l'écran Paramètres "
|
||||
"(clé gratuite sur aistudio.google.com)."
|
||||
)
|
||||
self._api_key = settings.gemini_api_key
|
||||
self._model = settings.gemini_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""One-shot via streaming (puis recollage), avec garde-fous au temps écoulé."""
|
||||
return await self._collect_with_timeouts(
|
||||
[ChatMessage(role="user", content=prompt)], temperature, output_format
|
||||
)
|
||||
|
||||
async def _collect_with_timeouts(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float | None,
|
||||
output_format: str | None,
|
||||
) -> str:
|
||||
"""Collecte le stream avec deux garde-fous : 1er token borné (échec rapide
|
||||
si rien ne sort) + ceiling global `self._timeout`."""
|
||||
async def _collect() -> str:
|
||||
chunks: list[str] = []
|
||||
agen = self._stream(messages, None, temperature, output_format)
|
||||
try:
|
||||
while True:
|
||||
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
|
||||
try:
|
||||
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise LLMProviderError(
|
||||
f"Erreur Gemini : aucun contenu produit en "
|
||||
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
|
||||
"votre quota gratuit."
|
||||
)
|
||||
chunks.append(token)
|
||||
finally:
|
||||
await agen.aclose()
|
||||
return "".join(chunks)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import ou augmentez le timeout."
|
||||
) from exc
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
async for token in self._stream(messages, system_prompt, temperature):
|
||||
yield token
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: str | None,
|
||||
temperature: float | None,
|
||||
output_format: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
payload_messages: list[dict[str, str]] = []
|
||||
if system_prompt:
|
||||
payload_messages.append({"role": "system", "content": system_prompt})
|
||||
for m in messages:
|
||||
payload_messages.append({"role": m.role, "content": m.content})
|
||||
|
||||
body: dict[str, object] = {
|
||||
"model": self._model,
|
||||
"messages": payload_messages,
|
||||
"stream": True,
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", _API_URL, headers=self._headers(), json=body
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
||||
raise LLMProviderError(
|
||||
f"Erreur Gemini (HTTP {response.status_code})"
|
||||
+ (f" : {detail[:500]}" if detail else "")
|
||||
)
|
||||
async for token in self._parse_sse(response):
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
|
||||
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
data = line[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = obj.get("choices")
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
def _format_http_error(self, exc: httpx.HTTPError) -> str:
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return (
|
||||
f"Erreur Gemini : délai dépassé (timeout {self._timeout}s). Le modèle a "
|
||||
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
|
||||
)
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}"
|
||||
188
brain/app/infrastructure/mistral_adapter.py
Normal file
188
brain/app/infrastructure/mistral_adapter.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
|
||||
|
||||
Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions,
|
||||
SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que
|
||||
l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis
|
||||
recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini.
|
||||
|
||||
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à
|
||||
coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand
|
||||
contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_URL = "https://api.mistral.ai/v1/chat/completions"
|
||||
|
||||
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file
|
||||
# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit
|
||||
# peut être longue.
|
||||
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class MistralLLMProvider:
|
||||
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.mistral_api_key:
|
||||
raise LLMProviderError(
|
||||
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
|
||||
)
|
||||
self._api_key = settings.mistral_api_key
|
||||
self._model = settings.mistral_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
|
||||
|
||||
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx :
|
||||
si le provider envoyait des keep-alive sans contenu, l'appel pendrait à
|
||||
l'infini. Ici on coupe net après `self._timeout` secondes.
|
||||
"""
|
||||
return await self._collect_with_timeouts(
|
||||
[ChatMessage(role="user", content=prompt)], temperature, output_format
|
||||
)
|
||||
|
||||
async def _collect_with_timeouts(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float | None,
|
||||
output_format: str | None,
|
||||
) -> str:
|
||||
"""Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné
|
||||
(file d'attente → échec rapide) + ceiling global `self._timeout`."""
|
||||
async def _collect() -> str:
|
||||
chunks: list[str] = []
|
||||
agen = self._stream(messages, None, temperature, output_format)
|
||||
try:
|
||||
while True:
|
||||
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
|
||||
try:
|
||||
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise LLMProviderError(
|
||||
f"Erreur Mistral : aucun contenu produit en "
|
||||
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement "
|
||||
"en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
|
||||
"choisissez un modèle plus disponible."
|
||||
)
|
||||
chunks.append(token)
|
||||
finally:
|
||||
await agen.aclose()
|
||||
return "".join(chunks)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||
) from exc
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
async for token in self._stream(messages, system_prompt, temperature):
|
||||
yield token
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: str | None,
|
||||
temperature: float | None,
|
||||
output_format: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
payload_messages: list[dict[str, str]] = []
|
||||
if system_prompt:
|
||||
payload_messages.append({"role": "system", "content": system_prompt})
|
||||
for m in messages:
|
||||
payload_messages.append({"role": m.role, "content": m.content})
|
||||
|
||||
body: dict[str, object] = {
|
||||
"model": self._model,
|
||||
"messages": payload_messages,
|
||||
"stream": True,
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", _API_URL, headers=self._headers(), json=body
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
# En streaming le corps n'est pas lu automatiquement : on le
|
||||
# lit pour exposer le détail de Mistral (modèle inconnu, clé
|
||||
# invalide 401, quota 429…), sinon on n'a que le code HTTP nu.
|
||||
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
||||
raise LLMProviderError(
|
||||
f"Erreur Mistral (HTTP {response.status_code})"
|
||||
+ (f" : {detail[:500]}" if detail else "")
|
||||
)
|
||||
async for token in self._parse_sse(response):
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
|
||||
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue # lignes vides ou keep-alive (`: ...`)
|
||||
data = line[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = obj.get("choices")
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
def _format_http_error(self, exc: httpx.HTTPError) -> str:
|
||||
"""Message lisible (timeout, quota 429, clé invalide 401, modèle inconnu…)."""
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return (
|
||||
f"Erreur Mistral : délai dépassé (timeout {self._timeout}s). Le modèle a "
|
||||
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
|
||||
)
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
return f"Erreur Mistral ({exc.__class__.__name__}) : {detail}"
|
||||
58
brain/app/infrastructure/mistral_embedding_adapter.py
Normal file
58
brain/app/infrastructure/mistral_embedding_adapter.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Adapter d'embeddings Mistral (cloud, EU) — POST /v1/embeddings.
|
||||
|
||||
Soumis au rate limit du tier gratuit : pour indexer un gros document on envoie
|
||||
les textes par lots (et l'appelant peut espacer les appels si besoin).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.embeddings import EmbeddingError
|
||||
from app.core.config import Settings
|
||||
|
||||
_API_URL = "https://api.mistral.ai/v1/embeddings"
|
||||
# Lot raisonnable pour ne pas envoyer un payload géant d'un coup.
|
||||
_BATCH_SIZE = 64
|
||||
|
||||
|
||||
class MistralEmbeddingProvider:
|
||||
"""Implémente EmbeddingProvider via l'API Mistral embeddings."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.mistral_api_key:
|
||||
raise EmbeddingError(
|
||||
"Clé API Mistral manquante (requise pour les embeddings Mistral). "
|
||||
"Configure-la dans les Paramètres ou choisis Ollama pour les embeddings."
|
||||
)
|
||||
self._api_key = settings.mistral_api_key
|
||||
self._model = settings.mistral_embedding_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
if not texts:
|
||||
return []
|
||||
out: list[list[float]] = []
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
for start in range(0, len(texts), _BATCH_SIZE):
|
||||
batch = texts[start:start + _BATCH_SIZE]
|
||||
try:
|
||||
response = await client.post(
|
||||
_API_URL, headers=headers, json={"model": self._model, "input": batch})
|
||||
if response.status_code >= 400:
|
||||
raise EmbeddingError(
|
||||
f"Mistral embeddings HTTP {response.status_code} : "
|
||||
f"{response.text.strip()[:300]}")
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise EmbeddingError(f"Erreur Mistral embeddings : {exc}") from exc
|
||||
|
||||
items = data.get("data")
|
||||
if not isinstance(items, list) or len(items) != len(batch):
|
||||
raise EmbeddingError("Réponse d'embeddings Mistral inattendue (taille incohérente).")
|
||||
for item in items:
|
||||
out.append([float(x) for x in item.get("embedding", [])])
|
||||
return out
|
||||
43
brain/app/infrastructure/ollama_embedding_adapter.py
Normal file
43
brain/app/infrastructure/ollama_embedding_adapter.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Adapter d'embeddings Ollama (local) — endpoint /api/embed.
|
||||
|
||||
Gratuit et illimité (tourne sur la machine). Nécessite d'avoir pullé le modèle
|
||||
d'embedding (ex. `ollama pull nomic-embed-text`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.embeddings import EmbeddingError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class OllamaEmbeddingProvider:
|
||||
"""Implémente EmbeddingProvider via Ollama /api/embed (batch)."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.ollama_base_url
|
||||
self._model = settings.ollama_embedding_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
if not texts:
|
||||
return []
|
||||
url = f"{self._base_url}/api/embed"
|
||||
payload = {"model": self._model, "input": texts}
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload)
|
||||
if response.status_code >= 400:
|
||||
body = response.text
|
||||
raise EmbeddingError(
|
||||
f"Ollama embeddings HTTP {response.status_code} : {body.strip()[:300]}. "
|
||||
f"Le modèle '{self._model}' est-il installé ? (ollama pull {self._model})"
|
||||
)
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise EmbeddingError(f"Erreur Ollama embeddings : {exc}") from exc
|
||||
|
||||
vectors = data.get("embeddings")
|
||||
if not isinstance(vectors, list) or len(vectors) != len(texts):
|
||||
raise EmbeddingError("Réponse d'embeddings Ollama inattendue (taille incohérente).")
|
||||
return [[float(x) for x in v] for v in vectors]
|
||||
@@ -11,17 +11,26 @@ qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.domain.ports import LLMProviderError
|
||||
|
||||
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente"
|
||||
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu
|
||||
# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue.
|
||||
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class OpenRouterLLMProvider:
|
||||
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
|
||||
@@ -51,11 +60,63 @@ class OpenRouterLLMProvider:
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties."""
|
||||
chunks: list[str] = []
|
||||
async for token in self._stream([ChatMessage(role="user", content=prompt)], None, temperature):
|
||||
chunks.append(token)
|
||||
return "".join(chunks)
|
||||
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
|
||||
|
||||
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un
|
||||
modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER
|
||||
PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout
|
||||
(des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après
|
||||
`self._timeout` secondes, quoi qu'il arrive.
|
||||
"""
|
||||
return await self._collect_with_timeouts(
|
||||
[ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter"
|
||||
)
|
||||
|
||||
async def _collect_with_timeouts(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float | None,
|
||||
output_format: str | None,
|
||||
provider: str,
|
||||
) -> str:
|
||||
"""Collecte le stream avec DEUX garde-fous au temps écoulé :
|
||||
- 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué
|
||||
en file d'attente (que des keep-alive, aucun contenu) → échec rapide ;
|
||||
- ceiling global (`self._timeout`) : génération qui ne se termine jamais.
|
||||
Le timeout réseau d'httpx ne suffit pas : des keep-alive font 'arriver des
|
||||
octets' et empêchent son read-timeout de se déclencher.
|
||||
"""
|
||||
async def _collect() -> str:
|
||||
chunks: list[str] = []
|
||||
agen = self._stream(messages, None, temperature, output_format)
|
||||
try:
|
||||
while True:
|
||||
# Borne SEULEMENT l'attente du 1er token (file d'attente) ; ensuite
|
||||
# on laisse générer (le ceiling global couvre le reste).
|
||||
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
|
||||
try:
|
||||
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise LLMProviderError(
|
||||
f"Erreur {provider} : aucun contenu produit en "
|
||||
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
|
||||
"probablement en file d'attente / saturé. Réessayez plus tard ou "
|
||||
"choisissez un autre modèle (1min.ai, ou payant)."
|
||||
)
|
||||
chunks.append(token)
|
||||
finally:
|
||||
await agen.aclose()
|
||||
return "".join(chunks)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||
) from exc
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
@@ -72,6 +133,7 @@ class OpenRouterLLMProvider:
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: str | None,
|
||||
temperature: float | None,
|
||||
output_format: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
payload_messages: list[dict[str, str]] = []
|
||||
if system_prompt:
|
||||
@@ -86,6 +148,10 @@ class OpenRouterLLMProvider:
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
# NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/
|
||||
# providers GRATUITS ne le supportent pas et renvoient une réponse VIDE.
|
||||
# On laisse le modèle répondre librement ; l'extraction JSON en aval
|
||||
# (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
|
||||
109
brain/app/infrastructure/vector_store.py
Normal file
109
brain/app/infrastructure/vector_store.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Stockage vectoriel fichier (RAG des notebooks) — sans dépendance lourde.
|
||||
|
||||
Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain :
|
||||
data/notebooks/{source_id}.json = {"dim": N, "chunks": [{"text":..., "vector":[...]}]}
|
||||
|
||||
À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en
|
||||
Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STORE_DIR = Path("data/notebooks")
|
||||
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
|
||||
|
||||
|
||||
def _path(source_id: str) -> Path:
|
||||
safe = _SAFE_ID.sub("_", str(source_id))
|
||||
return _STORE_DIR / f"{safe}.json"
|
||||
|
||||
|
||||
def save(
|
||||
source_id: str,
|
||||
chunks: list[str],
|
||||
vectors: list[list[float]],
|
||||
pages: list[int] | None = None,
|
||||
) -> int:
|
||||
"""Persiste les (chunk, vecteur[, page]) d'une source. Renvoie le nb d'extraits."""
|
||||
if len(chunks) != len(vectors):
|
||||
raise ValueError("chunks et vectors de tailles différentes")
|
||||
if pages is not None and len(pages) != len(chunks):
|
||||
raise ValueError("pages et chunks de tailles différentes")
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
items = []
|
||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
||||
item = {"text": c, "vector": v}
|
||||
if pages is not None:
|
||||
item["page"] = pages[i]
|
||||
items.append(item)
|
||||
payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items}
|
||||
_path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
return len(chunks)
|
||||
|
||||
|
||||
def exists(source_id: str) -> bool:
|
||||
return _path(source_id).exists()
|
||||
|
||||
|
||||
def delete(source_id: str) -> None:
|
||||
_path(source_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _load(source_id: str) -> list[dict]:
|
||||
p = _path(source_id)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
return data.get("chunks", []) if isinstance(data, dict) else []
|
||||
|
||||
|
||||
def all_chunks(source_id: str) -> list[dict]:
|
||||
"""Tous les extraits d'une source (texte + page), sans vecteurs — pour le mode
|
||||
« analyse approfondie » (map-reduce sur tout le document)."""
|
||||
return [{"text": c.get("text", ""), "page": c.get("page")} for c in _load(source_id)]
|
||||
|
||||
|
||||
def _cosine(a: list[float], b: list[float]) -> float:
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = 0.0
|
||||
na = 0.0
|
||||
nb = 0.0
|
||||
for x, y in zip(a, b):
|
||||
dot += x * y
|
||||
na += x * x
|
||||
nb += y * y
|
||||
if na == 0.0 or nb == 0.0:
|
||||
return 0.0
|
||||
return dot / (math.sqrt(na) * math.sqrt(nb))
|
||||
|
||||
|
||||
def search(
|
||||
source_ids: list[str],
|
||||
query_vector: list[float],
|
||||
top_k: int = 6,
|
||||
) -> list[dict]:
|
||||
"""Renvoie les `top_k` extraits les plus proches, toutes sources confondues.
|
||||
|
||||
Chaque résultat : {"text": str, "score": float, "source_id": str}.
|
||||
"""
|
||||
scored: list[dict] = []
|
||||
for sid in source_ids:
|
||||
for chunk in _load(sid):
|
||||
vector = chunk.get("vector") or []
|
||||
score = _cosine(query_vector, vector)
|
||||
scored.append({
|
||||
"text": chunk.get("text", ""),
|
||||
"score": score,
|
||||
"source_id": sid,
|
||||
"page": chunk.get("page"),
|
||||
})
|
||||
scored.sort(key=lambda c: c["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
Reference in New Issue
Block a user