update the LLM Docker description

This commit is contained in:
2025-09-06 17:47:24 +02:00
parent 2a0d69e6b8
commit a0260b8520
6 changed files with 325 additions and 533 deletions

View File

@@ -34,10 +34,11 @@ COPY start.sh .
RUN chmod +x start.sh RUN chmod +x start.sh
# Variables d'environnement par défaut # Variables d'environnement par défaut
ENV OLLAMA_API_URL="http://localhost:11434/api/generate" ENV OLLAMA_HOST="http://host.docker.internal:11434"
ENV OLLAMA_MODEL="deepseek-coder:33b" ENV GENERATE_MODEL="deepseek-coder:6.7b-instruct"
ENV CHROMA_PERSIST_DIR="/chroma_db" ENV CHROMA_PERSIST_DIR="/chroma_db"
ENV RESPONSE_CACHE_DIR="/response_cache" ENV RESPONSE_CACHE_DIR="/response_cache"
ENV EMBED_MODEL="nomic-embed-text"
# Exposer FastAPI # Exposer FastAPI
EXPOSE 8000 EXPOSE 8000

View File

@@ -7,9 +7,12 @@ import textwrap
import requests import requests
import httpx import httpx
import socket import socket
import json
import random
import traceback
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi import FastAPI, Query, HTTPException, Request, Body from fastapi import FastAPI, Query, HTTPException, Request, Body
from langchain_community.vectorstores import Chroma from langchain_chroma 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
@@ -17,55 +20,42 @@ 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, Field, PrivateAttr, ValidationError from pydantic import BaseModel, Field, PrivateAttr, ValidationError
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any, Union, Literal
from collections import Counter
# --- Configuration via variables d'environnement --- # --- Configuration via variables d'environnement ---
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") GENERATE_MODEL = os.environ.get("GENERATE_MODEL", "deepseek-coder:6.7b-instruct")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "mxbai-embed-large:latest") CHAT_MODEL = os.environ.get("CHAT_MODEL", GENERATE_MODEL)
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text:latest")
SRC_PATH=os.environ.get("SRC_PATH", ".") SRC_PATH=os.environ.get("SRC_PATH", ".")
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://host.docker.internal:11434")
PROG_LANG = os.environ.get("PROG_LANG", "go")
try:
CHUNCK_SIZE = int(os.environ.get("CHUNCK_SIZE", "300"))
except (ValueError, TypeError):
CHUNCK_SIZE = 300
try:
CHUNCK_OVERLAP = int(os.environ.get("CHUNCK_OVERLAP", "50"))
except (ValueError, TypeError):
CHUNCK_OVERLAP = 50
try:
QUERY_TIMEOUT = int(os.environ.get("QUERY_TIMEOUT", "120"))
except (ValueError, TypeError):
QUERY_TIMEOUT = 120
# Configuration
OLLAMA_BASE_URL = "http://127.0.0.1:11434"
VECTORSTORE = None # Initialisé ailleurs
DDGS_SEARCH_ENABLED = True 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:
@@ -131,35 +121,39 @@ app = FastAPI()
# --- Traitement des chemins --- # --- Traitement des chemins ---
paths = SRC_PATH.split(":") src_paths_directories = SRC_PATH.split(":")
if not paths: if not src_paths_directories:
paths = ["."] src_paths_directories = ["."]
# --- Initialisation --- # --- Initialisation ---
vectorstore = None vectorstore = None
code_hash = "" code_hash = ""
def build_vectorstore(): def build_vectorstore():
global vectorstore, code_hash, paths global vectorstore, code_hash, src_paths_directories
print("🔹 Construction du vectorstore ...", file=sys.stderr) print("🔹 Construction du vectorstore ...", file=sys.stderr)
# Hash du code pour hot-reload # Hash du code pour hot-reload
new_hash = hash_code_dir(paths) new_hash = hash_code_dir(src_paths_directories)
if vectorstore and new_hash == code_hash: if vectorstore and new_hash == code_hash:
print("🔹 Pas de changement dans /code, utilisation du vectorstore existant", file=sys.stderr) print("🔹 Pas de changement dans /code, utilisation du vectorstore existant", file=sys.stderr)
return return
code_hash = new_hash code_hash = new_hash
print(f" 🔹 Programmation language: {PROG_LANG}", file=sys.stderr)
print(f" 🔹 Chunck size: {CHUNCK_SIZE}", file=sys.stderr)
print(f" 🔹 Chunck overlap: {CHUNCK_OVERLAP}", file=sys.stderr)
# Text splitter optimisé Go # Text splitter optimisé Go
go_splitter = RecursiveCharacterTextSplitter.from_language( code_splitter = RecursiveCharacterTextSplitter.from_language(
language="go", language=PROG_LANG,
chunk_size=800, chunk_size=CHUNCK_SIZE,
chunk_overlap=150 #, chunk_overlap=CHUNCK_OVERLAP,
#separators=["\n\n", "\nfunc ", "}\n\n", "\n//", "\n/*", "\t"] keep_separator=True
) )
all_docs = [] all_docs = []
for path in paths: for path in src_paths_directories:
abs_path = os.path.join("/code", path) if path != "." else "/code" abs_path = os.path.join("/code", path) if path != "." else "/code"
print(f" 🔹 Chargement du code Go depuis: {abs_path}", file=sys.stderr) print(f" 🔹 Chargement du code Go depuis: {abs_path}", file=sys.stderr)
loader = DirectoryLoader( loader = DirectoryLoader(
@@ -176,32 +170,69 @@ def build_vectorstore():
all_docs.extend(loaded_docs) all_docs.extend(loaded_docs)
print(f"🔹 {len(all_docs)} documents après chargement", file=sys.stderr) 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)
embedding = NomicEmbeddingsWrapper(model=EMBED_MODEL, base_url=OLLAMA_BASE_URL) embedding = NomicEmbeddingsWrapper(model=EMBED_MODEL, base_url=OLLAMA_HOST)
chat_collection = Chroma.from_documents(
documents=all_docs,
embedding=embedding,
persist_directory=PERSIST_DIR,
collection_metadata={"hnsw:space": "cosine"},
collection_name="chat_context"
)
splits = code_splitter.split_documents(all_docs)
print(f"🔹 {len(splits)} chunks créés", file=sys.stderr)
splits = [doc for doc in splits if doc.page_content.strip()] splits = [doc for doc in splits if doc.page_content.strip()]
# Ajout des statistiques de longueur du split
bins=50
counter = Counter(int(len(split.page_content) / bins) * bins for split in splits)
print("🔹 Histogramme de la longueur des splits :", file=sys.stderr)
max_value = max(counter.values())
for length, count in sorted(counter.items()):
normalized_count = int((count / max_value) * 50)
print(f" {length+1:6}-{length+bins:-6}: { '#' * normalized_count}", file=sys.stderr)
print(f"🔹 {len(splits)} fragments non vides à intégrer", file=sys.stderr) 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(
documents=splits, # 2. Vectorstore pour la Génération (splits courts)
gen_collection = Chroma.from_documents(
documents=splits, # Morceaux de 200-400 tokens
embedding=embedding, embedding=embedding,
collection_name="code_completion",
persist_directory=PERSIST_DIR, persist_directory=PERSIST_DIR,
collection_metadata={"hnsw:space": "cosine"} collection_metadata={"hnsw:space": "cosine"}
) )
vectorstore = {
"chat": chat_collection,
"generate": gen_collection
}
print("🔹 Vectorstore créé", file=sys.stderr) print("🔹 Vectorstore créé", file=sys.stderr)
# --- Formatage du contexte --- # --- Formatage du contexte ---
def format_context(docs: list) -> str: def format_context(docs: list) -> str:
context = [] context = []
extraits = {}
print("🔹 Les fichiers suivants ont été selectionnés:", file=sys.stderr)
for i, doc in enumerate(docs): for i, doc in enumerate(docs):
source = doc.metadata.get('source', 'unknown') source = doc.metadata.get('source', 'unknown')
filename = os.path.basename(source) filename = os.path.basename(source)
print(f" 🔹 {filename} -- extrait {i+1} --", file=sys.stderr)
if doc.page_content not in extraits:
extraits[doc.page_content] = True
context.append(f"### Fichier: {filename} (Extrait {i+1}) ###") context.append(f"### Fichier: {filename} (Extrait {i+1}) ###")
context.append(textwrap.indent(doc.page_content, ' ')) context.append(textwrap.indent(doc.page_content, ' '))
print(f" 🔹 {filename} -- fin extrait {i+1} --", file=sys.stderr)
else:
print(f" 🔸 {filename} -- extrait {i+1} duppliqué et éliminé --", file=sys.stderr)
return "\n\n".join(context) return "\n\n".join(context)
def format_iso_time_with_ns(): def format_iso_time_with_ns():
@@ -250,8 +281,8 @@ class ChatRequest(BaseModel):
messages: List[ChatMessage] messages: List[ChatMessage]
format: Optional[str] = None format: Optional[str] = None
options: Optional[Dict[str, Any]] = None options: Optional[Dict[str, Any]] = None
stream: bool = False stream: bool = True
keep_alive: Optional[str] = None keep_alive: Optional[Union[str, int]] = None # Modification ici
class EmbeddingRequest(BaseModel): class EmbeddingRequest(BaseModel):
model: str model: str
@@ -262,11 +293,11 @@ class EmbeddingResponse(BaseModel):
embedding: List[float] embedding: List[float]
# Fonctions utilitaires # Fonctions utilitaires
async def perform_rag_search(prompt: str, k: int = 4) -> str: async def perform_rag_search(mode: Literal["generate", "chat"], prompt: str, k: int = 4) -> str:
"""Effectue une recherche RAG et retourne le contexte""" """Effectue une recherche RAG et retourne le contexte"""
build_vectorstore() build_vectorstore()
rag_docs = vectorstore.similarity_search(prompt, k=k) rag_docs = vectorstore[mode].similarity_search(prompt, k=k)
return format_context(rag_docs) if rag_docs else "Aucun contexte trouvé." return format_context(rag_docs) if rag_docs else "Aucun contexte trouvé."
async def perform_web_search(prompt: str, k: int = 2) -> str: async def perform_web_search(prompt: str, k: int = 2) -> str:
@@ -275,171 +306,265 @@ async def perform_web_search(prompt: str, k: int = 2) -> str:
return "Recherche web désactivée" return "Recherche web désactivée"
try: try:
from duckduckgo_search import DDGS from ddgs import DDGS
with DDGS() as ddgs: with DDGS() as ddgs:
results = list(ddgs.text(prompt, max_results=k)) 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é." print(f"🔹 {len(results)} résultats trouvés sur le web", file=sys.stderr)
for i, r in enumerate(results):
print(f" - {i+1}. {r['title']} : {r['href']}", file=sys.stderr)
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: except Exception as e:
return f"Erreur recherche web: {str(e)}" return f"Erreur recherche web: {str(e)}"
def build_enhanced_prompt(original_prompt: str, rag_context: str, web_context: str) -> str: def build_enhanced_prompt(
"""Construit un prompt enrichi avec les contextes""" mode: Literal["generate", "chat"],
return f""" question: str,
### CONTEXTE RAG (Code) ### rag_context: str,
{rag_context or "Aucun contexte code disponible"} web_context: str):
### CONTEXTE WEB ### if mode == "chat":
{web_context or "Aucune information web disponible"} prompt = f"""
# Consigne
### QUESTION UTILISATEUR ### Vous êtes un expert en programmation {PROG_LANG}. Répondez à la question en utilisant le contexte fourni (extraits de code) et les informations web si disponibles.
{original_prompt} Autant que possible tu indiqueras tes sources, url, nom du fichier source...
# Contexte de la question:
## **Contexte Code (extraits pertinents):**
{rag_context}
## **Informations Web:**
{web_context}
## **Instructions:**
- Répondez de manière concise et précise à la question
- Si la réponse se trouve dans le contexte code, citez le fichier et l'extrait correspondant
- Si vous utilisez les informations web, citez la source
- Si la question est en anglais, répondez en anglais. Sinon, en français
- Pour les extraits de code, conservez le formatage et l'indentation
# **Question** à laquelle tu dois répondre
{question}
"""
else:
prompt=f"""
# Consigne
Vous êtes un expert en programmation {PROG_LANG}. Essayer de concevoir un petit bout de code permetant de résoudre la question
# Contexte de la question:
## **Contexte Code (extraits pertinents):**
{rag_context}
## **Instructions:**
- Rédigez les commentaires de code dans la même langue que le code qui vous est fourni. À défaut en anglais.
- Nommez les variables dans la même langue que le code qui vous est fourni. À défaut en anglais.
- Si la question est en anglais, répondez en anglais. Sinon, en français
- Pour les extraits de code, conservez le formatage et l'indentation
# **Question** à laquelle tu dois répondre
{question}
""" """
return prompt
# Endpoints compatibles Ollama # --- Fonctions utilitaires factorisées ---
@app.post("/api/generate") async def build_augmented_prompt(
async def generate_endpoint(request_data: GenerateRequest = Body(...)): mode: Literal["generate", "chat"],
"""Endpoint pour la génération avec gestion du streaming""" question: str
try: ) -> str:
# Récupération des contextes RAG et web """Construit un prompt enrichi avec 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 rag_context = await perform_rag_search(mode,question,k= 2 if mode=='chat' else 8)
enhanced_prompt = build_enhanced_prompt(
original_prompt=request_data.prompt, if mode == "chat":
web_context = await perform_web_search(question)
else:
web_context = ""
return build_enhanced_prompt(
mode=mode,
question=question,
rag_context=rag_context, rag_context=rag_context,
web_context=web_context web_context=web_context
) )
# Préparation du payload pour Ollama async def _stream_ollama_response(response: httpx.Response, model_name: str):
ollama_payload = { """Générateur pour le streaming de la réponse de chat au format Ollama"""
"model": request_data.model, start_time = datetime.now(timezone.utc).isoformat()
"prompt": enhanced_prompt,
"stream": request_data.stream,
"options": request_data.options or {}
}
# Appel à Ollama
async with httpx.AsyncClient() as client:
response = await client.post(
f"{OLLAMA_BASE_URL}/api/generate",
json=ollama_payload,
timeout=120.0
)
response.raise_for_status()
# 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")
}
# 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(): async for line in response.aiter_lines():
if line.strip(): if line.strip():
try: try:
chunk = json.loads(line) chunk = json.loads(line)
# 1. Format SSE valide avec double newline # Construction du message conforme à l'API Ollama
yield f"data: {json.dumps(chunk)}\n\n" message_chunk = {
"model": model_name,
"created_at": start_time,
"message": {
"role": "assistant",
"content": chunk.get("message", {}).get("content", "") if "message" in chunk else chunk.get("content", "")
},
"done": chunk.get("done", False)
}
# 2. Accumuler la réponse complète pour les logs # Ajout des champs optionnels
full_response += chunk.get("response", "") for field in ["total_duration", "load_duration", "prompt_eval_count", "eval_count"]:
if field in chunk:
message_chunk[field] = chunk[field]
# 3. Envoyer périodiquement un keep-alive yield f"data: {json.dumps(message_chunk)}\n\n"
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: except json.JSONDecodeError:
print(f"⚠️ Ligne JSON invalide: {line}") yield "event: error\ndata: Invalid JSON chunk\n\n"
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" 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) # --- Endpoints API ---
print(f"🔹 Réponse complète: {full_response}") @app.post("/api/generate")
async def generate_endpoint(request_data: GenerateRequest = Body(...)):
try:
# Construction du prompt enrichi
enhanced_prompt = await build_augmented_prompt("generate",request_data.prompt)
# 7. Configuration de la réponse avec des headers spécifiques # Appel à Ollama
ollama_payload = {
"model": GENERATE_MODEL,
"prompt": enhanced_prompt,
"stream": request_data.stream,
"options": request_data.options or {}
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{OLLAMA_HOST}/api/generate",
json=ollama_payload,
timeout=QUERY_TIMEOUT
)
response.raise_for_status()
if not request_data.stream:
return response.json()
else:
# --- CORRECTION DU STREAMING ---
async def generate_stream():
"""Générateur pour le streaming des résultats"""
async for chunk in response.aiter_text():
# Transférer directement les chunks
yield chunk
# Fermeture propre du stream
# yield "data: [DONE]\n\n"
# Utilisez text/plain au lieu de text/event-stream
return StreamingResponse( return StreamingResponse(
generate(), generate_stream(),
media_type="text/event-stream", media_type="text/plain",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no" # Important pour Nginx "X-Accel-Buffering": "no"
} }
) )
except httpx.RequestError as e: except httpx.RequestError as e:
raise HTTPException(500, f"Erreur de connexion à Ollama: {str(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: except Exception as e:
raise HTTPException(500, f"Erreur interne: {str(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_endpoint(request_data: ChatRequest):
"""Endpoint /api/chat avec enrichissement du dernier message""" try:
# Copie profonde des messages messages = [msg.dict() for msg in request_data.messages]
processed_messages = [msg.dict() for msg in request.messages]
# Enrichissement uniquement du dernier message utilisateur if messages and messages[-1]["role"] == "user":
if processed_messages and processed_messages[-1]["role"] == "user": original_question = messages[-1]["content"]
last_msg = processed_messages[-1]["content"] try:
# Limiter la taille du contexte
augmented_prompt = await build_augmented_prompt("chat",original_question)
messages[-1]["content"] = augmented_prompt[-8000:] # Truncate to the last 8000 chars
print(f"🔹 Prompt enrichi ({len(augmented_prompt)} caractères)", file=sys.stderr)
except Exception as e:
print(f"⚠️ Erreur d'enrichissement: {str(e)}", file=sys.stderr)
messages[-1]["content"] = original_question # Fallback to original
rag_context = await perform_rag_search(last_msg) # Préparation du payload pour Ollama
web_context = await perform_web_search(last_msg) ollama_payload = {
"model": CHAT_MODEL,
"messages": messages,
"stream": request_data.stream,
"options": request_data.options or {}
}
enhanced_content = build_enhanced_prompt( if request_data.keep_alive is not None:
original_prompt=last_msg, if isinstance(request_data.keep_alive, int):
rag_context=rag_context, ollama_payload["keep_alive"] = f"{request_data.keep_alive}s"
web_context=web_context else:
) ollama_payload["keep_alive"] = request_data.keep_alive
processed_messages[-1]["content"] = enhanced_content sopload = json.dumps(ollama_payload, indent=2)
print(f" 🔹 Taille du Payload vers Ollama : {len(sopload)} octets...", file=sys.stderr)
print(f" 🔹 Début du payload : {sopload}...", file=sys.stderr)
# Appel au vrai Ollama # Appel à Ollama
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
response = await client.post( response = await client.post(
f"{OLLAMA_BASE_URL}/api/chat", f"{OLLAMA_HOST}/api/chat",
json={ json=ollama_payload,
"model": request.model, timeout=QUERY_TIMEOUT
"messages": processed_messages,
"format": request.format,
"options": request.options,
"stream": request.stream,
"keep_alive": request.keep_alive
},
timeout=120.0
) )
response.raise_for_status() response.raise_for_status()
if request.stream: # Gestion des réponses NON-STREAMING
return response.iter_lines() if not request_data.stream:
return response.json() return response.json()
# --- CORRECTION DU STREAMING ---
async def generate_stream():
"""Générateur pour le streaming des résultats"""
async for chunk in response.aiter_text():
# Transférer directement les chunks
yield chunk
# Fermeture propre du stream
# yield "data: [DONE]\n\n"
# Utilisez text/plain au lieu de text/event-stream
return StreamingResponse(
generate_stream(),
media_type="text/plain",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
# ... [gestion des erreurs existante] ...
except httpx.HTTPStatusError as e:
error_detail = e.response.text if e.response else str(e)
print(f"🚨 Erreur HTTP Ollama ({e.response.status_code}): {error_detail}", file=sys.stderr)
raise HTTPException(502, f"Erreur Ollama: {error_detail}")
except httpx.RequestError as e: except httpx.RequestError as e:
raise HTTPException(500, f"Erreur de connexion à Ollama: {str(e)}") print(f"🚨 Erreur réseau Ollama: {str(e)}", file=sys.stderr)
raise HTTPException(503, f"Ollama non disponible: {str(e)}")
except Exception as e:
print(f"🚨 Erreur interne: {traceback.format_exc()}", file=sys.stderr)
raise HTTPException(500, f"Erreur interne: {str(e)}")
@app.post("/api/embeddings") @app.post("/api/embeddings")
async def embeddings(request: EmbeddingRequest): async def embeddings(request: EmbeddingRequest):
@@ -447,7 +572,7 @@ async def embeddings(request: EmbeddingRequest):
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
response = await client.post( response = await client.post(
f"{OLLAMA_BASE_URL}/api/embeddings", f"{OLLAMA_HOST}/api/embeddings",
json=request.dict() json=request.dict()
) )
response.raise_for_status() response.raise_for_status()
@@ -461,7 +586,7 @@ async def list_models():
"""Proxy pour lister les modèles disponibles""" """Proxy pour lister les modèles disponibles"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
response = await client.get(f"{OLLAMA_BASE_URL}/api/tags") response = await client.get(f"{OLLAMA_HOST}/api/tags")
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()

View File

@@ -1,338 +0,0 @@
import os
import sys
import glob
import time
import hashlib
import textwrap
import requests
import httpx
from fastapi.responses import StreamingResponse
from fastapi import FastAPI, Query, HTTPException, Request
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
from duckduckgo_search import DDGS
from unstructured.cleaners.core import clean_extra_whitespace, clean_non_ascii_chars, replace_unicode_quotes
from datetime import datetime, timezone, timedelta
# --- 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")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
MODEL_NAME = os.environ.get("OLLAMA_MODEL", "llama3:13b")
os.makedirs(PERSIST_DIR, exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True)
# --- 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):
"""Wrapper automatique pour les préfixes Nomic"""
def _prefix_text(self, text: str, is_document: bool) -> str:
prefix = "search_document: " if is_document else "search_query: "
return prefix + text
def embed_documents(self, texts: List[str]) -> List[List[float]]:
prefixed_texts = [self._prefix_text(t, is_document=True) for t in texts]
return super().embed_documents(prefixed_texts)
def embed_query(self, text: str) -> List[float]:
return super().embed_query(self._prefix_text(text, is_document=False))
# --- FastAPI ---
app = FastAPI()
# --- Traitement des chemins ---
paths = sys.argv[1:] if len(sys.argv) > 1 else ["."]
if not paths:
paths = ["."]
# --- Initialisation ---
vectorstore = None
code_hash = ""
def build_vectorstore():
global vectorstore, code_hash
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},
max_files=500
)
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)
embedding = NomicEmbeddingsWrapper(model="nomic-embed-text", api_base=OLLAMA_URL)
# Créer ou recharger Chroma
vectorstore = Chroma.from_documents(
documents=splits,
embedding=embedding,
persist_directory=PERSIST_DIR,
collection_metadata={"hnsw:space": "cosine"}
)
vectorstore.persist()
print("🔹 Vectorstore créé et persisté", 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
def build_prompt(question: str,
k_rag: int,
k_web: int):
build_vectorstore()
rag_docs = vectorstore.similarity_search(question, k=k_rag)
context_str = format_context(rag_docs) if rag_docs else "Aucun contexte trouvé."
# Recherche web
web_info = ""
if k_web > 0:
try:
with DDGS(timeout=10) as ddgs:
results = list(ddgs.text(question, max_results=k_web))
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:
web_info = f"⚠️ Erreur recherche web: {str(e)}"
else:
web_info = "Recherche web désactivée."
prompt = f"""
Vous êtes un expert en programmation Go. Répondez à la question en utilisant le contexte fourni (extraits de code) et les informations web si disponibles.
**Contexte Code (extraits pertinents):**
{context_str}
**Informations Web:**
{web_info}
**Question:**
{question}
**Instructions:**
- Répondez de manière concise et précise
- Si la réponse se trouve dans le contexte code, citez le fichier et l'extrait correspondant
- Si vous utilisez les informations web, citez la source
- Si la question est en anglais, répondez en anglais. Sinon, en français
- Pour les extraits de code, conservez le formatage et l'indentation
"""
return prompt
@app.post("/api/chat")
async def chat(
question: str = Query(..., min_length=3),
history: list = Query(default=[]), # liste d'anciens messages [{role, content}]
k_rag: int = Query(4, ge=1, le=10),
k_web: int = Query(2, ge=0, le=5),
):
start_time = time.time()
# Construire le prompt enrichi (RAG + Web)
prompt = build_prompt(question=question, k_rag=k_rag, k_web=k_web)
# Construire la conversation pour Ollama
messages = history + [
{"role": "user", "content": prompt}
]
try:
r = requests.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": MODEL_NAME,
"messages": messages,
"options": {
"temperature": 0.3,
"num_predict": 1024,
"top_k": 50,
"top_p": 0.9
}
},
timeout=120
)
r.raise_for_status()
result = r.json()
answer = result.get("message", {}).get("content", "Pas de réponse générée.")
return {
"answer": answer,
"processing_time": f"{time.time() - start_time:.2f}s",
"model": MODEL_NAME,
"cached": False,
"history": messages + [{"role": "assistant", "content": answer}]
}
except requests.exceptions.RequestException as e:
detail = f"Erreur API Ollama: {str(e)}"
if hasattr(e, 'response') and e.response:
detail += f" | Status: {e.response.status_code} | Response: {e.response.text[:200]}"
raise HTTPException(status_code=500, detail=detail)
# --- Endpoint /ask ---
@app.get("/api/generate")
async def ask_question(
question: str = Query(..., min_length=3),
k_rag: int = Query(4, ge=1, le=10),
k_web: int = Query(2, ge=0, le=5),
use_cache: bool = Query(True)
):
start_time = time.time_ns()
cache_path = os.path.join(CACHE_DIR, f"{get_cache_key(question)}.txt")
if use_cache and os.path.exists(cache_path):
with open(cache_path, "r") as f:
return {"answer": f.read(), "cached": True}
prompt = build_prompt(question=question, k_rag=k_rag, k_web=k_web)
try:
r = requests.post(f"{OLLAMA_URL}/api/generate", json={
"model": MODEL_NAME,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.3, "num_predict": 1024, "top_k": 50, "top_p": 0.9}
}, timeout=120)
r.raise_for_status()
result = r.json()
answer = result.get("response") or result.get("text") or "Pas de réponse générée."
with open(cache_path, "w") as f:
f.write(answer)
return {
"model": MODEL_NAME,
"eval_duration": f"{time.time_ns() - start_time:.0f}",
"created_at": format_iso_time_with_ns(),
"response": answer,
"done": false
}
{"answer": answer,
"processing_time": f"{time.time() - start_time:.2f}s",
"model": MODEL_NAME,
"cached": False}
except requests.exceptions.RequestException as e:
detail = f"Erreur API Ollama: {str(e)}"
if hasattr(e, 'response') and e.response:
detail += f" | Status: {e.response.status_code} | Response: {e.response.text[:200]}"
raise HTTPException(status_code=500, detail=detail)
# --- Endpoint /status ---
@app.get("/status")
def status_check():
try:
count = vectorstore._collection.count() if vectorstore else 0
return {
"status": "OK",
"vectorstore_items": count,
"model": MODEL_NAME,
"persist_dir": PERSIST_DIR,
"cache_dir": CACHE_DIR
}
except Exception as e:
raise HTTPException(500, f"Erreur: {str(e)}")
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy(request: Request, path: str):
async with httpx.AsyncClient() as client:
url = f"{OLLAMA_URL}/{path}"
body = await request.body()
r = await client.request(
method=request.method,
url=url,
headers=request.headers,
content=body
)
return StreamingResponse(r.aiter_bytes(), status_code=r.status_code, headers=dict(r.headers))

View File

@@ -7,8 +7,9 @@ services:
- "8000:8000" - "8000:8000"
environment: environment:
# Variables Ollama / FastAPI # Variables Ollama / FastAPI
OLLAMA_API_URL: "http://localhost:11434/api/generate" OLLAMA_HOST: "http://host.docker.internal:11434"
OLLAMA_MODEL: "deepseek-coder:33b" GENERATE_MODEL: "deepseek-coder:6.7b-instruct"
CHAT_MODEL: "gpt-oss:20b"
CHROMA_PERSIST_DIR: "/chroma_db" CHROMA_PERSIST_DIR: "/chroma_db"
RESPONSE_CACHE_DIR: "/response_cache" RESPONSE_CACHE_DIR: "/response_cache"
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1

View File

@@ -1,10 +1,11 @@
fastapi==0.116.1 fastapi==0.116.1
uvicorn[standard]==0.23.2 uvicorn[standard]==0.23.2
chromadb==1.0.15 chromadb==1.0.15
duckduckgo-search==8.1.1 ddgs==9.5.4
langchain==0.3.27 langchain==0.3.27
langchain-community==0.3.27 langchain-community==0.3.27
sentence-transformers==5.1.0 sentence-transformers==5.1.0
langchain-chroma==0.2.5
unstructured==0.7.10 unstructured==0.7.10
langchain-ollama==0.3.6 langchain-ollama==0.3.6
requests==2.32.1 requests==2.32.1

View File

@@ -2,20 +2,22 @@
set -e set -e
# Lancer Ollama en arrière-plan # Lancer Ollama en arrière-plan
export OLLAMA_MODELS=/models # export OLLAMA_MODELS=/models
echo "🔹 Démarrage de Ollama..." 1>&2 # echo "🔹 Démarrage de Ollama..." 1>&2
ollama serve | sed 's/^/ 🔹[Ollama server] /' & # ollama serve 2>&1 \
# | grep -vF "decode: cannot decode batches with this context (use llama_encode() instead)" \
# | sed 's/^/ 🔹[Ollama server] /' 1>&2 &
sleep 10 # sleep 10
if [[ -n "$1" ]] ; then
eval $*
fi
echo "🔹 Preaload Ollama models: " echo "🔹 Preaload Ollama models: "
ollama ls | sed 's/^/ 🔹 /' 1>&2 ollama ls | sed 's/^/ 🔹 /' 1>&2
# Attendre Ollama # Vérifier / précharger le modèle d'embedding
sleep 5
# Vérifier / précharger le modèle Nomic Embed Text
EMBED_MODEL="nomic-embed-text:latest"
echo "🔹 Vérification du modèle d'embedding: $EMBED_MODEL" 1>&2 echo "🔹 Vérification du modèle d'embedding: $EMBED_MODEL" 1>&2
if ! ollama list | grep -q "$EMBED_MODEL"; then if ! ollama list | grep -q "$EMBED_MODEL"; then