etat intermédiare

This commit is contained in:
2025-08-18 00:36:24 +02:00
parent b81c9760e1
commit 83a0b62dd3

View File

@@ -6,16 +6,17 @@ import hashlib
import textwrap import textwrap
import requests import requests
import httpx import httpx
import socket
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi import FastAPI, Query, HTTPException, Request from fastapi import FastAPI, Query, HTTPException, Request, Body
from langchain_community.vectorstores import Chroma from langchain_community.vectorstores import Chroma
from langchain_ollama import OllamaEmbeddings from langchain_ollama import OllamaEmbeddings
from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_text_splitters import RecursiveCharacterTextSplitter
from duckduckgo_search import DDGS from ddgs import DDGS
from unstructured.cleaners.core import clean_extra_whitespace, clean_non_ascii_chars, replace_unicode_quotes from unstructured.cleaners.core import clean_extra_whitespace, clean_non_ascii_chars, replace_unicode_quotes
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from pydantic import BaseModel from pydantic import BaseModel, Field, PrivateAttr, ValidationError
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@@ -23,6 +24,7 @@ from typing import Optional, List, Dict, Any
PERSIST_DIR = os.environ.get("CHROMA_PERSIST_DIR", "/chroma_db") PERSIST_DIR = os.environ.get("CHROMA_PERSIST_DIR", "/chroma_db")
CACHE_DIR = os.environ.get("RESPONSE_CACHE_DIR", "/response_cache") CACHE_DIR = os.environ.get("RESPONSE_CACHE_DIR", "/response_cache")
MODEL_NAME = os.environ.get("OLLAMA_MODEL", "llama3:13b") MODEL_NAME = os.environ.get("OLLAMA_MODEL", "llama3:13b")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "mxbai-embed-large:latest")
SRC_PATH=os.environ.get("SRC_PATH", ".") SRC_PATH=os.environ.get("SRC_PATH", ".")
# Configuration # Configuration
@@ -34,6 +36,37 @@ DDGS_SEARCH_ENABLED = True
os.makedirs(PERSIST_DIR, exist_ok=True) os.makedirs(PERSIST_DIR, exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True)
# Configuration de l'URL de base d'Ollama
def get_ollama_base_url():
"""Détermine dynamiquement l'URL d'Ollama"""
# 1. Vérifier la variable d'environnement
if "OLLAMA_BASE_URL" in os.environ:
return os.environ["OLLAMA_BASE_URL"]
# 2. Tester la connectivité locale
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
s.connect(("localhost", 11434))
return "http://localhost:11434"
except (socket.timeout, ConnectionRefusedError):
pass
# 3. Essayer l'adresse spéciale Docker
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
s.connect(("host.docker.internal", 11434))
return "http://host.docker.internal:11434"
except (socket.timeout, ConnectionRefusedError):
pass
# 4. Fallback pour les environnements cloud
return "http://127.0.0.1:11434"
OLLAMA_BASE_URL = get_ollama_base_url()
# --- Nettoyage du code --- # --- Nettoyage du code ---
def clean_code_content(content: str) -> str: def clean_code_content(content: str) -> str:
cleaned = replace_unicode_quotes(content) cleaned = replace_unicode_quotes(content)
@@ -62,20 +95,35 @@ def hash_code_dir(paths: list) -> str:
from typing import List from typing import List
class NomicEmbeddingsWrapper(OllamaEmbeddings): class NomicEmbeddingsWrapper(OllamaEmbeddings):
"""Wrapper automatique pour les préfixes Nomic""" """Wrapper Ollama pour les embeddings de code, compatible Chroma."""
_cached_dim: int = PrivateAttr() # attribut interne non validé par Pydantic
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Calcul de la dimension une seule fois
self._cached_dim = 768 #len(super().embed_query("Hello"))
def _prefix_text(self, text: str, is_document: bool) -> str: def _prefix_text(self, text: str, is_document: bool) -> str:
"""Ajoute un préfixe pour distinguer document vs query."""
prefix = "search_document: " if is_document else "search_query: " prefix = "search_document: " if is_document else "search_query: "
return prefix + text return prefix + text
def embed_documents(self, texts: List[str]) -> List[List[float]]: def embed_documents(self, texts: List[str]) -> List[List[float]]:
"""Embeds documents en ajoutant le préfixe, retourne liste de vecteurs float."""
prefixed_texts = [self._prefix_text(t, is_document=True) for t in texts] prefixed_texts = [self._prefix_text(t, is_document=True) for t in texts]
return super().embed_documents(prefixed_texts) embeddings = super().embed_documents(prefixed_texts)
# Normaliser les embeddings vides pour éviter les erreurs Chroma
return [e if e else [0.0] * self.model_dimensions for e in embeddings]
def embed_query(self, text: str) -> List[float]: def embed_query(self, text: str) -> List[float]:
return super().embed_query(self._prefix_text(text, is_document=False)) """Embeds une query en ajoutant le préfixe."""
emb = super().embed_query(self._prefix_text(text, is_document=False))
# Normaliser embedding vide
return emb if emb else [0.0] * self.model_dimensions
@property
def model_dimensions(self) -> int:
return self._cached_dim
# --- FastAPI --- # --- FastAPI ---
app = FastAPI() app = FastAPI()
@@ -131,8 +179,11 @@ def build_vectorstore():
splits = go_splitter.split_documents(all_docs) splits = go_splitter.split_documents(all_docs)
print(f"🔹 {len(splits)} chunks créés", file=sys.stderr) print(f"🔹 {len(splits)} chunks créés", file=sys.stderr)
embedding = NomicEmbeddingsWrapper(model="nomic-embed-text", base_url=OLLAMA_BASE_URL) embedding = NomicEmbeddingsWrapper(model=EMBED_MODEL, base_url=OLLAMA_BASE_URL)
splits = [doc for doc in splits if doc.page_content.strip()]
print(f"🔹 {len(splits)} fragments non vides à intégrer", file=sys.stderr)
# Créer ou recharger Chroma # Créer ou recharger Chroma
vectorstore = Chroma.from_documents( vectorstore = Chroma.from_documents(
documents=splits, documents=splits,
@@ -186,10 +237,7 @@ class GenerateRequest(BaseModel):
prompt: str prompt: str
system: Optional[str] = None system: Optional[str] = None
template: Optional[str] = None template: Optional[str] = None
context: Optional[List[int]] = None stream: Optional[bool] = False
stream: bool = False
raw: bool = False
format: Optional[str] = None
options: Optional[Dict[str, Any]] = None options: Optional[Dict[str, Any]] = None
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
@@ -247,39 +295,33 @@ def build_enhanced_prompt(original_prompt: str, rag_context: str, web_context: s
{original_prompt} {original_prompt}
""" """
# Endpoints compatibles Ollama # Endpoints compatibles Ollama
@app.post("/api/generate") @app.post("/api/generate")
async def generate(request: GenerateRequest): async def generate_endpoint(request_data: GenerateRequest = Body(...)):
"""Endpoint /api/generate avec enrichissement RAG""" """Endpoint pour la génération avec gestion du streaming"""
start_time = time.time() try:
# Récupération des contextes RAG et web
# Récupération des contextes rag_context = await perform_rag_search(request_data.prompt)
rag_context = await perform_rag_search(request.prompt) web_context = await perform_web_search(request_data.prompt)
web_context = await perform_web_search(request.prompt)
# Construction du prompt enrichi
# Construction du prompt enrichi enhanced_prompt = build_enhanced_prompt(
enhanced_prompt = build_enhanced_prompt( original_prompt=request_data.prompt,
original_prompt=request.prompt, rag_context=rag_context,
rag_context=rag_context, web_context=web_context
web_context=web_context )
)
# Préparation du payload pour Ollama
# Préparation de la requête pour le vrai Ollama ollama_payload = {
ollama_payload = { "model": request_data.model,
"model": request.model, "prompt": enhanced_prompt,
"prompt": enhanced_prompt, "stream": request_data.stream,
"system": request.system, "options": request_data.options or {}
"template": request.template, }
"context": request.context,
"stream": request.stream, # Appel à Ollama
"raw": request.raw, async with httpx.AsyncClient() as client:
"format": request.format,
"options": request.options
}
# Appel au vrai serveur Ollama
async with httpx.AsyncClient() as client:
try:
response = await client.post( response = await client.post(
f"{OLLAMA_BASE_URL}/api/generate", f"{OLLAMA_BASE_URL}/api/generate",
json=ollama_payload, json=ollama_payload,
@@ -287,17 +329,71 @@ async def generate(request: GenerateRequest):
) )
response.raise_for_status() response.raise_for_status()
# Si streaming, retourner le flux directement # Gestion des réponses NON-STREAMING
if request.stream: if not request_data.stream:
return response.iter_lines() result = response.json()
return {
"model": result["model"],
"response": result["response"],
"done": result["done"],
"context": result.get("context"),
"total_duration": result.get("total_duration")
}
# Pour les réponses non-streamées # Gestion des réponses STREAMING
result = response.json() else:
result["context"] = None # Reset du contexte pour éviter les fuites async def generate():
return result """Générateur pour le streaming des résultats"""
full_response = ""
except httpx.RequestError as e: async for line in response.aiter_lines():
raise HTTPException(500, f"Erreur de connexion à Ollama: {str(e)}") if line.strip():
try:
chunk = json.loads(line)
# 1. Format SSE valide avec double newline
yield f"data: {json.dumps(chunk)}\n\n"
# 2. Accumuler la réponse complète pour les logs
full_response += chunk.get("response", "")
# 3. Envoyer périodiquement un keep-alive
if random.random() < 0.1: # 10% des chunks
yield ": keep-alive\n\n"
# 4. Fin du stream
if chunk.get("done", False):
break
except json.JSONDecodeError:
print(f"⚠️ Ligne JSON invalide: {line}")
yield f"event: error\ndata: Invalid JSON line\n\n"
# 5. Envoyer un message de fin explicite
yield "event: end\ndata: Stream completed\n\n"
# 6. Log de la réponse complète
print(f"🔹 Réponse complète ({len(full_response)} caractères): {full_response[:200]}...", file=sys.stderr)
# Log de la réponse complète (optionnel)
print(f"🔹 Réponse complète: {full_response}")
# 7. Configuration de la réponse avec des headers spécifiques
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Important pour Nginx
}
)
except httpx.RequestError as e:
raise HTTPException(500, f"Erreur de connexion à Ollama: {str(e)}")
except json.JSONDecodeError as e:
raise HTTPException(500, f"Erreur de décodage JSON: {str(e)}")
except Exception as e:
raise HTTPException(500, f"Erreur interne: {str(e)}")
@app.post("/api/chat") @app.post("/api/chat")
async def chat(request: ChatRequest): async def chat(request: ChatRequest):
@@ -384,4 +480,17 @@ async def startup_event():
async def enable_web_search(enabled: bool = True): async def enable_web_search(enabled: bool = True):
global DDGS_SEARCH_ENABLED global DDGS_SEARCH_ENABLED
DDGS_SEARCH_ENABLED = enabled DDGS_SEARCH_ENABLED = enabled
return {"status": "success", "web_search_enabled": enabled} return {"status": "success", "web_search_enabled": enabled}
# Endpoint de debug simplifié
@app.post("/debug")
async def debug_endpoint(request: Request):
"""Endpoint de débogage simplifié"""
try:
body = await request.json()
return {
"status": "success",
"received_body": body
}
except json.JSONDecodeError:
raise HTTPException(400, "Invalid JSON format")