From b81c9760e185942acff9bef0939e32b967285667 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 17 Aug 2025 21:24:53 +0200 Subject: [PATCH] push llm --- LLM/Docker/Dockerfile | 49 +++++ LLM/Docker/README.md | 3 + LLM/Docker/app.py | 387 ++++++++++++++++++++++++++++++++++ LLM/Docker/app_old.py | 338 +++++++++++++++++++++++++++++ LLM/Docker/docker-compose.yml | 23 ++ LLM/Docker/requirements.txt | 14 ++ LLM/Docker/start.sh | 39 ++++ 7 files changed, 853 insertions(+) create mode 100644 LLM/Docker/Dockerfile create mode 100644 LLM/Docker/README.md create mode 100644 LLM/Docker/app.py create mode 100644 LLM/Docker/app_old.py create mode 100644 LLM/Docker/docker-compose.yml create mode 100644 LLM/Docker/requirements.txt create mode 100644 LLM/Docker/start.sh diff --git a/LLM/Docker/Dockerfile b/LLM/Docker/Dockerfile new file mode 100644 index 00000000..d17cb6ee --- /dev/null +++ b/LLM/Docker/Dockerfile @@ -0,0 +1,49 @@ +FROM python:3.11-slim + +# Dépendances système +RUN apt-get update \ + && apt-get install -y \ + curl \ + libmagic1 \ + libxml2-dev \ + libxslt-dev \ + libjpeg-dev \ + zlib1g-dev \ + poppler-utils \ + tesseract-ocr \ + bash \ + && rm -rf /var/lib/apt/lists/* + +# Installer Ollama +RUN curl -fsSL https://ollama.com/install.sh | sh + +# Répertoires +RUN mkdir -p /app /chroma_db /response_cache /code +WORKDIR /app + +# Copier le code +COPY requirements.txt . + +# Installer Python requirements +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +# Copier le code +COPY app.py . +COPY start.sh . +RUN chmod +x start.sh + +# Variables d'environnement par défaut +ENV OLLAMA_API_URL="http://localhost:11434/api/generate" +ENV OLLAMA_MODEL="deepseek-coder:33b" +ENV CHROMA_PERSIST_DIR="/chroma_db" +ENV RESPONSE_CACHE_DIR="/response_cache" + +# Exposer FastAPI +EXPOSE 8000 + +# Volumes persistants +VOLUME ["/models", "/code", "/chroma_db", "/response_cache"] + +# Entrypoint +ENTRYPOINT ["/app/start.sh"] diff --git a/LLM/Docker/README.md b/LLM/Docker/README.md new file mode 100644 index 00000000..2d7bd9c3 --- /dev/null +++ b/LLM/Docker/README.md @@ -0,0 +1,3 @@ +```bash +docker build -t llmpmo:latest . +``` \ No newline at end of file diff --git a/LLM/Docker/app.py b/LLM/Docker/app.py new file mode 100644 index 00000000..346ffa08 --- /dev/null +++ b/LLM/Docker/app.py @@ -0,0 +1,387 @@ +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 +from pydantic import BaseModel +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") +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) + +# --- 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 = 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) + + embedding = NomicEmbeddingsWrapper(model="nomic-embed-text", base_url=OLLAMA_BASE_URL) + + # 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 + context: Optional[List[int]] = None + stream: bool = False + raw: bool = False + format: Optional[str] = None + 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} +""" + +# Endpoints compatibles Ollama +@app.post("/api/generate") +async def generate(request: GenerateRequest): + """Endpoint /api/generate avec enrichissement RAG""" + start_time = time.time() + + # Récupération des contextes + rag_context = await perform_rag_search(request.prompt) + web_context = await perform_web_search(request.prompt) + + # Construction du prompt enrichi + enhanced_prompt = build_enhanced_prompt( + original_prompt=request.prompt, + rag_context=rag_context, + web_context=web_context + ) + + # Préparation de la requête pour le vrai Ollama + ollama_payload = { + "model": request.model, + "prompt": enhanced_prompt, + "system": request.system, + "template": request.template, + "context": request.context, + "stream": request.stream, + "raw": request.raw, + "format": request.format, + "options": request.options + } + + # Appel au vrai serveur Ollama + async with httpx.AsyncClient() as client: + try: + response = await client.post( + f"{OLLAMA_BASE_URL}/api/generate", + json=ollama_payload, + timeout=120.0 + ) + response.raise_for_status() + + # Si streaming, retourner le flux directement + if request.stream: + return response.iter_lines() + + # Pour les réponses non-streamées + result = response.json() + result["context"] = None # Reset du contexte pour éviter les fuites + return result + + except httpx.RequestError as e: + raise HTTPException(500, f"Erreur de connexion à Ollama: {str(e)}") + +@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 + return {"status": "success", "web_search_enabled": enabled} \ No newline at end of file diff --git a/LLM/Docker/app_old.py b/LLM/Docker/app_old.py new file mode 100644 index 00000000..52e342d9 --- /dev/null +++ b/LLM/Docker/app_old.py @@ -0,0 +1,338 @@ +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)) diff --git a/LLM/Docker/docker-compose.yml b/LLM/Docker/docker-compose.yml new file mode 100644 index 00000000..d611d4ca --- /dev/null +++ b/LLM/Docker/docker-compose.yml @@ -0,0 +1,23 @@ +services: + code-llm: + build: . + container_name: code-llm + restart: unless-stopped + ports: + - "8000:8000" + environment: + # Variables Ollama / FastAPI + OLLAMA_API_URL: "http://localhost:11434/api/generate" + OLLAMA_MODEL: "deepseek-coder:33b" + CHROMA_PERSIST_DIR: "/chroma_db" + RESPONSE_CACHE_DIR: "/response_cache" + PYTHONUNBUFFERED: 1 + OLLAMA_NUM_PARALLEL: 6 + volumes: + - "/Users/coissac/.ollama/models:/app/ollama:/models" # modèles persistants + - "/Users/coissac/Sync/maison/Petite_maisons/src/pmomusic:/code" # code Go à indexer + - "./app.py:/app/app.py" +# - ./chroma_db:/chroma_db +# - ./response_cache:/response_cache + stdin_open: true + tty: true diff --git a/LLM/Docker/requirements.txt b/LLM/Docker/requirements.txt new file mode 100644 index 00000000..4a2db986 --- /dev/null +++ b/LLM/Docker/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.116.1 +uvicorn[standard]==0.23.2 +chromadb==1.0.15 +duckduckgo-search==8.1.1 +langchain==0.3.27 +langchain-community==0.3.27 +sentence-transformers==5.1.0 +unstructured==0.7.10 +langchain-ollama==0.3.6 +requests==2.32.1 +diskcache==5.6.1 +httpx==0.27.2 +pydantic==2.11.7 +typing==3.7.4.3 \ No newline at end of file diff --git a/LLM/Docker/start.sh b/LLM/Docker/start.sh new file mode 100644 index 00000000..610773a2 --- /dev/null +++ b/LLM/Docker/start.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +# Lancer Ollama en arrière-plan +export OLLAMA_MODELS=/models +echo "🔹 Démarrage de Ollama..." 1>&2 +ollama serve | sed 's/^/ 🔹[Ollama server] /' & + +sleep 10 + +echo "🔹 Preaload Ollama models: " +ollama ls | sed 's/^/ 🔹 /' 1>&2 + +# Attendre Ollama +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 +if ! ollama list | grep -q "$EMBED_MODEL"; then + echo " 🔹 Modèle $EMBED_MODEL non trouvé, téléchargement..." 1>&2 + ollama pull "$EMBED_MODEL" +else + echo " 🔹 Modèle $EMBED_MODEL déjà présent" 1>&2 +fi + +echo "🔹 Vérification du modèle LLM: $OLLAMA_MODEL" 1>&2 +if ! ollama list | grep -q "$OLLAMA_MODEL"; then + echo " 🔹 Modèle $OLLAMA_MODEL non trouvé, téléchargement..." 1>&2 + ollama pull "$OLLAMA_MODEL" +else + echo " 🔹 Modèle $OLLAMA_MODEL déjà présent" 1>&2 +fi + + +# Lancer FastAPI +echo "🔹 Démarrage de FastAPI..." 1>&2 +exec uvicorn app:app --host 0.0.0.0 --port 8000 --reload