2025-08-17 21:24:53 +02:00
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import glob
|
|
|
|
|
import time
|
|
|
|
|
import hashlib
|
|
|
|
|
import textwrap
|
|
|
|
|
import requests
|
|
|
|
|
import httpx
|
2025-08-18 00:36:24 +02:00
|
|
|
import socket
|
2025-08-17 21:24:53 +02:00
|
|
|
from fastapi.responses import StreamingResponse
|
2025-08-18 00:36:24 +02:00
|
|
|
from fastapi import FastAPI, Query, HTTPException, Request, Body
|
2025-08-17 21:24:53 +02:00
|
|
|
from langchain_community.vectorstores import Chroma
|
|
|
|
|
from langchain_ollama import OllamaEmbeddings
|
|
|
|
|
from langchain_community.document_loaders import DirectoryLoader, TextLoader
|
|
|
|
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
2025-08-18 00:36:24 +02:00
|
|
|
from ddgs import DDGS
|
2025-08-17 21:24:53 +02:00
|
|
|
from unstructured.cleaners.core import clean_extra_whitespace, clean_non_ascii_chars, replace_unicode_quotes
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
2025-08-18 00:36:24 +02:00
|
|
|
from pydantic import BaseModel, Field, PrivateAttr, ValidationError
|
2025-08-17 21:24:53 +02:00
|
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Configuration via variables d'environnement ---
|
|
|
|
|
PERSIST_DIR = os.environ.get("CHROMA_PERSIST_DIR", "/chroma_db")
|
|
|
|
|
CACHE_DIR = os.environ.get("RESPONSE_CACHE_DIR", "/response_cache")
|
|
|
|
|
MODEL_NAME = os.environ.get("OLLAMA_MODEL", "llama3:13b")
|
2025-08-18 00:36:24 +02:00
|
|
|
EMBED_MODEL = os.environ.get("EMBED_MODEL", "mxbai-embed-large:latest")
|
2025-08-17 21:24:53 +02:00
|
|
|
SRC_PATH=os.environ.get("SRC_PATH", ".")
|
|
|
|
|
|
|
|
|
|
# Configuration
|
|
|
|
|
OLLAMA_BASE_URL = "http://127.0.0.1:11434"
|
|
|
|
|
VECTORSTORE = None # Initialisé ailleurs
|
|
|
|
|
DDGS_SEARCH_ENABLED = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
os.makedirs(PERSIST_DIR, exist_ok=True)
|
|
|
|
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
# 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()
|
|
|
|
|
|
|
|
|
|
|
2025-08-17 21:24:53 +02:00
|
|
|
# --- Nettoyage du code ---
|
|
|
|
|
def clean_code_content(content: str) -> str:
|
|
|
|
|
cleaned = replace_unicode_quotes(content)
|
|
|
|
|
cleaned = clean_non_ascii_chars(cleaned)
|
|
|
|
|
cleaned = clean_extra_whitespace(cleaned)
|
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
|
|
# --- Cache simple ---
|
|
|
|
|
def get_cache_key(question: str) -> str:
|
|
|
|
|
return hashlib.md5(question.encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
# --- Hot-reload : hash du code ---
|
|
|
|
|
def hash_code_dir(paths: list) -> str:
|
|
|
|
|
m = hashlib.md5()
|
|
|
|
|
for path in paths:
|
|
|
|
|
abs_path = os.path.join("/code", path) if path != "." else "/code"
|
|
|
|
|
for f in glob.glob(f"{abs_path}/**/*.go", recursive=True):
|
|
|
|
|
try:
|
|
|
|
|
with open(f, "rb") as file:
|
|
|
|
|
m.update(file.read())
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
return m.hexdigest()
|
|
|
|
|
|
|
|
|
|
# --- Wrapper Nomic Embeddings ---
|
|
|
|
|
from typing import List
|
|
|
|
|
|
|
|
|
|
class NomicEmbeddingsWrapper(OllamaEmbeddings):
|
2025-08-18 00:36:24 +02:00
|
|
|
"""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"))
|
2025-08-17 21:24:53 +02:00
|
|
|
|
|
|
|
|
def _prefix_text(self, text: str, is_document: bool) -> str:
|
2025-08-18 00:36:24 +02:00
|
|
|
"""Ajoute un préfixe pour distinguer document vs query."""
|
2025-08-17 21:24:53 +02:00
|
|
|
prefix = "search_document: " if is_document else "search_query: "
|
|
|
|
|
return prefix + text
|
2025-08-18 00:36:24 +02:00
|
|
|
|
2025-08-17 21:24:53 +02:00
|
|
|
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
2025-08-18 00:36:24 +02:00
|
|
|
"""Embeds documents en ajoutant le préfixe, retourne liste de vecteurs float."""
|
2025-08-17 21:24:53 +02:00
|
|
|
prefixed_texts = [self._prefix_text(t, is_document=True) for t in texts]
|
2025-08-18 00:36:24 +02:00
|
|
|
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]
|
2025-08-17 21:24:53 +02:00
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
def embed_query(self, text: str) -> List[float]:
|
|
|
|
|
"""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
|
2025-08-17 21:24:53 +02:00
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
@property
|
|
|
|
|
def model_dimensions(self) -> int:
|
|
|
|
|
return self._cached_dim
|
2025-08-17 21:24:53 +02:00
|
|
|
|
|
|
|
|
# --- FastAPI ---
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
|
|
|
|
# --- Traitement des chemins ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
paths = SRC_PATH.split(":")
|
|
|
|
|
if not paths:
|
|
|
|
|
paths = ["."]
|
|
|
|
|
|
|
|
|
|
# --- Initialisation ---
|
|
|
|
|
vectorstore = None
|
|
|
|
|
code_hash = ""
|
|
|
|
|
|
|
|
|
|
def build_vectorstore():
|
|
|
|
|
global vectorstore, code_hash, paths
|
|
|
|
|
print("🔹 Construction du vectorstore...", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
# Hash du code pour hot-reload
|
|
|
|
|
new_hash = hash_code_dir(paths)
|
|
|
|
|
if vectorstore and new_hash == code_hash:
|
|
|
|
|
print("🔹 Pas de changement dans /code, utilisation du vectorstore existant", file=sys.stderr)
|
|
|
|
|
return
|
|
|
|
|
code_hash = new_hash
|
|
|
|
|
|
|
|
|
|
# Text splitter optimisé Go
|
|
|
|
|
go_splitter = RecursiveCharacterTextSplitter.from_language(
|
|
|
|
|
language="go",
|
|
|
|
|
chunk_size=800,
|
|
|
|
|
chunk_overlap=150 #,
|
|
|
|
|
#separators=["\n\n", "\nfunc ", "}\n\n", "\n//", "\n/*", "\t"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
all_docs = []
|
|
|
|
|
for path in paths:
|
|
|
|
|
abs_path = os.path.join("/code", path) if path != "." else "/code"
|
|
|
|
|
print(f" 🔹 Chargement du code Go depuis: {abs_path}", file=sys.stderr)
|
|
|
|
|
loader = DirectoryLoader(
|
|
|
|
|
abs_path,
|
|
|
|
|
glob="**/*.go",
|
|
|
|
|
loader_cls=TextLoader,
|
|
|
|
|
use_multithreading=True,
|
|
|
|
|
loader_kwargs={'autodetect_encoding': True}
|
|
|
|
|
)
|
|
|
|
|
loaded_docs = loader.load()
|
|
|
|
|
print(f" 🔸 {len(loaded_docs)} fichiers chargés", file=sys.stderr)
|
|
|
|
|
for doc in loaded_docs:
|
|
|
|
|
doc.page_content = clean_code_content(doc.page_content)
|
|
|
|
|
all_docs.extend(loaded_docs)
|
|
|
|
|
|
|
|
|
|
print(f"🔹 {len(all_docs)} documents après chargement", file=sys.stderr)
|
|
|
|
|
splits = go_splitter.split_documents(all_docs)
|
|
|
|
|
print(f"🔹 {len(splits)} chunks créés", file=sys.stderr)
|
|
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
embedding = NomicEmbeddingsWrapper(model=EMBED_MODEL, base_url=OLLAMA_BASE_URL)
|
2025-08-17 21:24:53 +02:00
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
splits = [doc for doc in splits if doc.page_content.strip()]
|
|
|
|
|
|
|
|
|
|
print(f"🔹 {len(splits)} fragments non vides à intégrer", file=sys.stderr)
|
2025-08-17 21:24:53 +02:00
|
|
|
# Créer ou recharger Chroma
|
|
|
|
|
vectorstore = Chroma.from_documents(
|
|
|
|
|
documents=splits,
|
|
|
|
|
embedding=embedding,
|
|
|
|
|
persist_directory=PERSIST_DIR,
|
|
|
|
|
collection_metadata={"hnsw:space": "cosine"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
print("🔹 Vectorstore créé", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
# --- Formatage du contexte ---
|
|
|
|
|
def format_context(docs: list) -> str:
|
|
|
|
|
context = []
|
|
|
|
|
for i, doc in enumerate(docs):
|
|
|
|
|
source = doc.metadata.get('source', 'unknown')
|
|
|
|
|
filename = os.path.basename(source)
|
|
|
|
|
context.append(f"### Fichier: {filename} (Extrait {i+1}) ###")
|
|
|
|
|
context.append(textwrap.indent(doc.page_content, ' '))
|
|
|
|
|
return "\n\n".join(context)
|
|
|
|
|
|
|
|
|
|
def format_iso_time_with_ns():
|
|
|
|
|
# 1. Obtenir le timestamp actuel avec nanosecondes
|
|
|
|
|
current_time_ns = time.time_ns()
|
|
|
|
|
|
|
|
|
|
# 2. Convertir en datetime avec timezone locale
|
|
|
|
|
dt = datetime.fromtimestamp(current_time_ns / 1e9).astimezone()
|
|
|
|
|
|
|
|
|
|
# 3. Formater avec les nanosecondes et décalage horaire
|
|
|
|
|
# - Extraire les nanosecondes
|
|
|
|
|
nanoseconds = current_time_ns % 10**9
|
|
|
|
|
|
|
|
|
|
# - Formater la partie datetime de base
|
|
|
|
|
base_format = dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
|
|
|
|
|
# - Ajouter les nanosecondes (9 chiffres)
|
|
|
|
|
nano_format = f".{nanoseconds:09d}"
|
|
|
|
|
|
|
|
|
|
# - Formater le décalage horaire
|
|
|
|
|
utc_offset = dt.utcoffset()
|
|
|
|
|
offset_hours = utc_offset.total_seconds() // 3600
|
|
|
|
|
offset_minutes = (utc_offset.total_seconds() % 3600) // 60
|
|
|
|
|
offset_sign = '-' if offset_hours < 0 else '+'
|
|
|
|
|
offset_format = f"{offset_sign}{abs(int(offset_hours)):02d}:{int(offset_minutes):02d}"
|
|
|
|
|
|
|
|
|
|
return base_format + nano_format + offset_format
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Modèles Pydantic pour l'API compatible Ollama
|
|
|
|
|
class GenerateRequest(BaseModel):
|
|
|
|
|
model: str
|
|
|
|
|
prompt: str
|
|
|
|
|
system: Optional[str] = None
|
|
|
|
|
template: Optional[str] = None
|
2025-08-18 00:36:24 +02:00
|
|
|
stream: Optional[bool] = False
|
2025-08-17 21:24:53 +02:00
|
|
|
options: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
|
|
|
role: str
|
|
|
|
|
content: str
|
|
|
|
|
images: Optional[List[str]] = None
|
|
|
|
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
|
|
|
model: str
|
|
|
|
|
messages: List[ChatMessage]
|
|
|
|
|
format: Optional[str] = None
|
|
|
|
|
options: Optional[Dict[str, Any]] = None
|
|
|
|
|
stream: bool = False
|
|
|
|
|
keep_alive: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
class EmbeddingRequest(BaseModel):
|
|
|
|
|
model: str
|
|
|
|
|
prompt: str
|
|
|
|
|
options: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
|
|
|
|
class EmbeddingResponse(BaseModel):
|
|
|
|
|
embedding: List[float]
|
|
|
|
|
|
|
|
|
|
# Fonctions utilitaires
|
|
|
|
|
async def perform_rag_search(prompt: str, k: int = 4) -> str:
|
|
|
|
|
"""Effectue une recherche RAG et retourne le contexte"""
|
|
|
|
|
build_vectorstore()
|
|
|
|
|
|
|
|
|
|
rag_docs = vectorstore.similarity_search(prompt, k=k)
|
|
|
|
|
return format_context(rag_docs) if rag_docs else "Aucun contexte trouvé."
|
|
|
|
|
|
|
|
|
|
async def perform_web_search(prompt: str, k: int = 2) -> str:
|
|
|
|
|
"""Effectue une recherche web et retourne les résultats"""
|
|
|
|
|
if not DDGS_SEARCH_ENABLED:
|
|
|
|
|
return "Recherche web désactivée"
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from duckduckgo_search import DDGS
|
|
|
|
|
with DDGS() as ddgs:
|
|
|
|
|
results = list(ddgs.text(prompt, max_results=k))
|
|
|
|
|
web_info = "\n".join(f"- [{r['title']}]({r['href']}): {r['body'][:150]}..." for r in results) if results else "Aucun résultat web trouvé."
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return f"Erreur recherche web: {str(e)}"
|
|
|
|
|
|
|
|
|
|
def build_enhanced_prompt(original_prompt: str, rag_context: str, web_context: str) -> str:
|
|
|
|
|
"""Construit un prompt enrichi avec les contextes"""
|
|
|
|
|
return f"""
|
|
|
|
|
### CONTEXTE RAG (Code) ###
|
|
|
|
|
{rag_context or "Aucun contexte code disponible"}
|
|
|
|
|
|
|
|
|
|
### CONTEXTE WEB ###
|
|
|
|
|
{web_context or "Aucune information web disponible"}
|
|
|
|
|
|
|
|
|
|
### QUESTION UTILISATEUR ###
|
|
|
|
|
{original_prompt}
|
|
|
|
|
"""
|
|
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
|
2025-08-17 21:24:53 +02:00
|
|
|
# Endpoints compatibles Ollama
|
|
|
|
|
@app.post("/api/generate")
|
2025-08-18 00:36:24 +02:00
|
|
|
async def generate_endpoint(request_data: GenerateRequest = Body(...)):
|
|
|
|
|
"""Endpoint pour la génération avec gestion du streaming"""
|
|
|
|
|
try:
|
|
|
|
|
# Récupération des contextes RAG et web
|
|
|
|
|
rag_context = await perform_rag_search(request_data.prompt)
|
|
|
|
|
web_context = await perform_web_search(request_data.prompt)
|
|
|
|
|
|
|
|
|
|
# Construction du prompt enrichi
|
|
|
|
|
enhanced_prompt = build_enhanced_prompt(
|
|
|
|
|
original_prompt=request_data.prompt,
|
|
|
|
|
rag_context=rag_context,
|
|
|
|
|
web_context=web_context
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Préparation du payload pour Ollama
|
|
|
|
|
ollama_payload = {
|
|
|
|
|
"model": request_data.model,
|
|
|
|
|
"prompt": enhanced_prompt,
|
|
|
|
|
"stream": request_data.stream,
|
|
|
|
|
"options": request_data.options or {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Appel à Ollama
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
2025-08-17 21:24:53 +02:00
|
|
|
response = await client.post(
|
|
|
|
|
f"{OLLAMA_BASE_URL}/api/generate",
|
|
|
|
|
json=ollama_payload,
|
|
|
|
|
timeout=120.0
|
|
|
|
|
)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
# Gestion des réponses NON-STREAMING
|
|
|
|
|
if not request_data.stream:
|
|
|
|
|
result = response.json()
|
|
|
|
|
return {
|
|
|
|
|
"model": result["model"],
|
|
|
|
|
"response": result["response"],
|
|
|
|
|
"done": result["done"],
|
|
|
|
|
"context": result.get("context"),
|
|
|
|
|
"total_duration": result.get("total_duration")
|
|
|
|
|
}
|
2025-08-17 21:24:53 +02:00
|
|
|
|
2025-08-18 00:36:24 +02:00
|
|
|
# Gestion des réponses STREAMING
|
|
|
|
|
else:
|
|
|
|
|
async def generate():
|
|
|
|
|
"""Générateur pour le streaming des résultats"""
|
|
|
|
|
full_response = ""
|
|
|
|
|
async for line in response.aiter_lines():
|
|
|
|
|
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)}")
|
|
|
|
|
|
2025-08-17 21:24:53 +02:00
|
|
|
|
|
|
|
|
@app.post("/api/chat")
|
|
|
|
|
async def chat(request: ChatRequest):
|
|
|
|
|
"""Endpoint /api/chat avec enrichissement du dernier message"""
|
|
|
|
|
# Copie profonde des messages
|
|
|
|
|
processed_messages = [msg.dict() for msg in request.messages]
|
|
|
|
|
|
|
|
|
|
# Enrichissement uniquement du dernier message utilisateur
|
|
|
|
|
if processed_messages and processed_messages[-1]["role"] == "user":
|
|
|
|
|
last_msg = processed_messages[-1]["content"]
|
|
|
|
|
|
|
|
|
|
rag_context = await perform_rag_search(last_msg)
|
|
|
|
|
web_context = await perform_web_search(last_msg)
|
|
|
|
|
|
|
|
|
|
enhanced_content = build_enhanced_prompt(
|
|
|
|
|
original_prompt=last_msg,
|
|
|
|
|
rag_context=rag_context,
|
|
|
|
|
web_context=web_context
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
processed_messages[-1]["content"] = enhanced_content
|
|
|
|
|
|
|
|
|
|
# Appel au vrai Ollama
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
try:
|
|
|
|
|
response = await client.post(
|
|
|
|
|
f"{OLLAMA_BASE_URL}/api/chat",
|
|
|
|
|
json={
|
|
|
|
|
"model": request.model,
|
|
|
|
|
"messages": processed_messages,
|
|
|
|
|
"format": request.format,
|
|
|
|
|
"options": request.options,
|
|
|
|
|
"stream": request.stream,
|
|
|
|
|
"keep_alive": request.keep_alive
|
|
|
|
|
},
|
|
|
|
|
timeout=120.0
|
|
|
|
|
)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
|
|
|
|
if request.stream:
|
|
|
|
|
return response.iter_lines()
|
|
|
|
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
raise HTTPException(500, f"Erreur de connexion à Ollama: {str(e)}")
|
|
|
|
|
|
|
|
|
|
@app.post("/api/embeddings")
|
|
|
|
|
async def embeddings(request: EmbeddingRequest):
|
|
|
|
|
"""Proxy direct pour les embeddings"""
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
try:
|
|
|
|
|
response = await client.post(
|
|
|
|
|
f"{OLLAMA_BASE_URL}/api/embeddings",
|
|
|
|
|
json=request.dict()
|
|
|
|
|
)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
raise HTTPException(500, f"Erreur de connexion à Ollama: {str(e)}")
|
|
|
|
|
|
|
|
|
|
@app.get("/api/tags")
|
|
|
|
|
async def list_models():
|
|
|
|
|
"""Proxy pour lister les modèles disponibles"""
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
try:
|
|
|
|
|
response = await client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
raise HTTPException(500, f"Erreur de connexion à Ollama: {str(e)}")
|
|
|
|
|
|
|
|
|
|
# Initialisation du vectorstore (à adapter à votre code)
|
|
|
|
|
@app.on_event("startup")
|
|
|
|
|
async def startup_event():
|
|
|
|
|
global vectorstore
|
|
|
|
|
build_vectorstore()
|
|
|
|
|
print("🔹 Initialisation du serveur proxy Ollama+RAG")
|
|
|
|
|
|
|
|
|
|
# Endpoint supplémentaire pour le contrôle
|
|
|
|
|
@app.get("/control/enable_web_search")
|
|
|
|
|
async def enable_web_search(enabled: bool = True):
|
|
|
|
|
global DDGS_SEARCH_ENABLED
|
|
|
|
|
DDGS_SEARCH_ENABLED = enabled
|
2025-08-18 00:36:24 +02:00
|
|
|
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")
|