update the LLM Docker description
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user