hamba-ho commited on
Commit
c2f46ba
·
1 Parent(s): 83b4232

feat: Version finale de l'application full stack avec RAG amélioré

Browse files
PROCHAINES_ETAPES.md DELETED
@@ -1,50 +0,0 @@
1
- # Prochaines Étapes pour l'Assistant Web Éducatif
2
-
3
- Ce document détaille les prochaines étapes de développement pour faire évoluer le projet de son état actuel (backend fonctionnel) vers une application complète, en se basant sur le cahier des charges et la vision du produit.
4
-
5
- ---
6
- ### Phase 1 : Consolidation du Backend et de l'API
7
-
8
- L'objectif est de rendre le backend plus robuste et complet.
9
-
10
- 1. **Gestion des Téléversements de Fichiers (Uploads) :**
11
- * [cite_start]Modifier l'endpoint `POST /documents` pour accepter un **vrai téléversement de fichier PDF** au lieu d'un simple nom de fichier [cite: 244-247, 305-309].
12
- * Sauvegarder le fichier téléversé dans le dossier `data/documents`.
13
- * Déclencher automatiquement le processus d'extraction et de vectorisation juste après le téléversement.
14
-
15
- 2. **Affiner le Modèle de Réponse :**
16
- * [cite_start]Enrichir la réponse de l'API `/ask` pour inclure les **sources exactes** (nom du document, numéro de page, etc.) qui ont servi de contexte [cite: 269-277].
17
- * Cela implique de stocker plus de métadonnées (comme le numéro de page) lors du découpage du texte.
18
-
19
- 3. **Gestion des Utilisateurs :**
20
- * [cite_start]Créer des modèles de données et des tables pour les **utilisateurs** (Étudiant, Enseignant, Administrateur) [cite: 1236-1240, 1253-1255].
21
- * [cite_start]Mettre en place un système d'**authentification** (par exemple, avec JWT) pour sécuriser les endpoints[cite: 71, 132].
22
-
23
- ---
24
- ### Phase 2 : Développement du Frontend
25
-
26
- L'objectif est de créer une interface utilisateur pour interagir avec le backend.
27
-
28
- 1. **Interface de Questions-Réponses :**
29
- * [cite_start]Créer une page simple avec un champ de saisie pour poser une question et une zone pour afficher la réponse de l'IA [cite: 258-260, 1142].
30
- * Connecter cette interface à l'endpoint `/api/v1/ask`.
31
-
32
- 2. **Interface d'Administration :**
33
- * [cite_start]Développer une page sécurisée pour les enseignants et administrateurs[cite: 236].
34
- * [cite_start]Créer un formulaire pour le **téléversement des manuels PDF** [cite: 237-242].
35
- * Afficher la liste des documents déjà présents dans le système.
36
-
37
- ---
38
- ### Phase 3 : Améliorations et Déploiement
39
-
40
- L'objectif est de préparer le projet pour une utilisation réelle.
41
-
42
- 1. **Amélioration de la Pertinence :**
43
- * [cite_start]Explorer des **modèles de `sentence-transformers` multilingues** ou plus spécialisés en science pour améliorer la qualité de la recherche sémantique[cite: 80].
44
- * [cite_start]Permettre à l'utilisateur de noter la pertinence des réponses pour un apprentissage continu (auto-amélioration)[cite: 20, 1271].
45
-
46
- 2. **Mise en place du Cache :**
47
- * [cite_start]Intégrer **Redis** pour mettre en cache les questions fréquentes et accélérer les temps de réponse, comme spécifié dans l'architecture[cite: 25, 62, 1384].
48
-
49
- 3. **Conteneurisation Complète avec Docker Compose :**
50
- * [cite_start]Écrire un fichier `docker-compose.yml` pour lancer toute l'application (Backend, PostgreSQL, Redis, Ollama) avec une seule commande, simplifiant ainsi le déploiement [cite: 148-193].
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,6 +1,6 @@
1
  # Assistant Web Éducatif - Full Stack
2
 
3
- Ce dépôt contient le code source complet de l'Assistant Web Éducatif, une application full stack conçue pour aider les étudiants en chimie. Le système analyse des manuels PDF, permet de poser des questions en langage naturel et fournit des réponses sourcées grâce à un modèle de langage local.
4
 
5
  ## Fonctionnalités Principales ✨
6
  - **API Backend Robuste** : Construite avec FastAPI, elle gère la logique de traitement des documents, l'authentification des utilisateurs et la génération des réponses.
 
1
  # Assistant Web Éducatif - Full Stack
2
 
3
+ Ce dépôt contient le code source complet de l'Assistant Web Éducatif, une application full stack conçue pour aider les étudiants dans un domaine spécifique. Le système analyse des manuels PDF, permet de poser des questions en langage naturel et fournit des réponses sourcées grâce à un modèle de langage local.
4
 
5
  ## Fonctionnalités Principales ✨
6
  - **API Backend Robuste** : Construite avec FastAPI, elle gère la logique de traitement des documents, l'authentification des utilisateurs et la génération des réponses.
backend/Dockerfile CHANGED
@@ -3,9 +3,13 @@
3
  FROM python:3.12-slim
4
 
5
  WORKDIR /app
 
6
  COPY backend/requirements.txt .
7
  RUN pip install --no-cache-dir -r requirements.txt
8
- COPY backend/ ./backend/
9
  COPY scripts/ ./scripts/
 
 
 
10
  #ENV PYTHONPATH="/app"
11
- CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
 
3
  FROM python:3.12-slim
4
 
5
  WORKDIR /app
6
+ RUN apt-get update && apt-get install -y netcat-openbsd && rm -rf /var/lib/apt/lists/*
7
  COPY backend/requirements.txt .
8
  RUN pip install --no-cache-dir -r requirements.txt
9
+ COPY backend/ .
10
  COPY scripts/ ./scripts/
11
+
12
+ COPY backend/entrypoint.sh .
13
+ RUN chmod +x ./entrypoint.sh
14
  #ENV PYTHONPATH="/app"
15
+ ENTRYPOINT ["./entrypoint.sh"]
backend/api/dependencies.py CHANGED
@@ -5,9 +5,9 @@ from fastapi.security import OAuth2PasswordBearer
5
  from sqlalchemy.orm import Session
6
  from jose import JWTError, jwt
7
 
8
- from backend.models.user import User
9
- from backend.utils.security import SECRET_KEY, ALGORITHM
10
- from backend.config.database import SessionLocal
11
 
12
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token")
13
 
 
5
  from sqlalchemy.orm import Session
6
  from jose import JWTError, jwt
7
 
8
+ from models.user import User
9
+ from utils.security import SECRET_KEY, ALGORITHM
10
+ from config.database import SessionLocal
11
 
12
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token")
13
 
backend/api/routes/documents.py CHANGED
@@ -3,12 +3,12 @@ from sqlalchemy.orm import Session
3
  import aiofiles
4
  import os
5
 
6
- from backend.models.document import Document
7
- from backend.api.dependencies import get_db
8
- from backend.services.document_processor import extract_pages_from_pdf, split_text_into_chunks
9
- from backend.services.vector_store import VectorStore
10
- from backend.models.user import User as UserModel, RoleEnum
11
- from backend.api.dependencies import get_current_user
12
 
13
 
14
  router = APIRouter()
 
3
  import aiofiles
4
  import os
5
 
6
+ from models.document import Document
7
+ from api.dependencies import get_db
8
+ from services.document_processor import extract_pages_from_pdf, split_text_into_chunks
9
+ from services.vector_store import VectorStore
10
+ from models.user import User as UserModel, RoleEnum
11
+ from api.dependencies import get_current_user
12
 
13
 
14
  router = APIRouter()
backend/api/routes/feedback.py CHANGED
@@ -3,9 +3,9 @@ from fastapi import APIRouter, Depends, HTTPException, status
3
  from sqlalchemy.orm import Session
4
  from pydantic import BaseModel
5
 
6
- from backend.api.dependencies import get_db, get_current_user
7
- from backend.models.feedback import Feedback as FeedbackModel
8
- from backend.models.user import User as UserModel
9
 
10
  router = APIRouter()
11
 
 
3
  from sqlalchemy.orm import Session
4
  from pydantic import BaseModel
5
 
6
+ from api.dependencies import get_db, get_current_user
7
+ from models.feedback import Feedback as FeedbackModel
8
+ from models.user import User as UserModel
9
 
10
  router = APIRouter()
11
 
backend/api/routes/questions.py CHANGED
@@ -1,14 +1,15 @@
1
- from fastapi import APIRouter
2
- # MODIFIÉ: Import des nouveaux modèles
3
- from backend.models.question import QuestionRequest, QuestionResponse
4
- from backend.services.question_handler import QuestionHandler
 
5
 
6
  router = APIRouter()
7
  handler = QuestionHandler()
8
 
9
  # MODIFIÉ: Utilisation du response_model pour garantir le format de sortie
10
  @router.post("/ask", response_model=QuestionResponse)
11
- def ask_question(request: QuestionRequest):
12
  """
13
  Receives a question, finds context, and returns an AI-generated answer.
14
  """
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from api.dependencies import get_db
4
+ from models.question import QuestionRequest, QuestionResponse
5
+ from services.question_handler import QuestionHandler
6
 
7
  router = APIRouter()
8
  handler = QuestionHandler()
9
 
10
  # MODIFIÉ: Utilisation du response_model pour garantir le format de sortie
11
  @router.post("/ask", response_model=QuestionResponse)
12
+ def ask_question(request: QuestionRequest, db: Session = Depends(get_db)):
13
  """
14
  Receives a question, finds context, and returns an AI-generated answer.
15
  """
backend/api/routes/users.py CHANGED
@@ -5,9 +5,9 @@ from fastapi.security import OAuth2PasswordRequestForm
5
  from sqlalchemy.orm import Session
6
  from datetime import timedelta
7
 
8
- from backend.models.user import UserCreate, UserResponse, Token, User as UserModel
9
- from backend.api.dependencies import get_db
10
- from backend.utils.security import get_password_hash, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
11
 
12
  router = APIRouter()
13
 
 
5
  from sqlalchemy.orm import Session
6
  from datetime import timedelta
7
 
8
+ from models.user import UserCreate, UserResponse, Token, User as UserModel
9
+ from api.dependencies import get_db
10
+ from utils.security import get_password_hash, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
11
 
12
  router = APIRouter()
13
 
backend/config/settings.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from pydantic_settings import BaseSettings
2
 
3
  class Settings(BaseSettings):
@@ -6,13 +7,14 @@ class Settings(BaseSettings):
6
  """
7
  PROJECT_NAME: str = "Assistant Web Éducatif"
8
  API_V1_STR: str = "/api/v1"
9
-
10
- # Base de données (PostgreSQL)
11
  DATABASE_URL: str = "postgresql://postgres:postgres@postgres/assistantWed_db"
12
-
 
 
 
13
  class Config:
14
  case_sensitive = True
15
- # Permet de lire les variables depuis un fichier .env
16
  env_file = ".env"
17
 
18
- settings = Settings()
 
1
+ # backend/config/settings.py
2
  from pydantic_settings import BaseSettings
3
 
4
  class Settings(BaseSettings):
 
7
  """
8
  PROJECT_NAME: str = "Assistant Web Éducatif"
9
  API_V1_STR: str = "/api/v1"
10
+
 
11
  DATABASE_URL: str = "postgresql://postgres:postgres@postgres/assistantWed_db"
12
+
13
+ OLLAMA_HOST: str = "http://ollama:11434"
14
+ OLLAMA_MODEL: str = "tinyllama"
15
+
16
  class Config:
17
  case_sensitive = True
 
18
  env_file = ".env"
19
 
20
+ settings = Settings()
backend/entrypoint.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+
3
+ echo "En attente de PostgreSQL..."
4
+ while ! nc -z postgres 5432; do
5
+ sleep 1
6
+ done
7
+ echo "PostgreSQL démarré."
8
+
9
+ # Créer les tables de la base de données
10
+ echo "Initialisation de la base de données..."
11
+ python -m scripts.init_db
12
+
13
+ # Lancer le serveur Uvicorn
14
+ exec uvicorn main:app --host 0.0.0.0 --port 8000
backend/main.py CHANGED
@@ -1,8 +1,9 @@
 
 
1
  from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
3
- from backend.config.settings import settings
4
- from backend.models import document
5
- from backend.api.routes import questions, documents, users,feedback
6
 
7
  app = FastAPI(
8
  title=settings.PROJECT_NAME,
@@ -10,24 +11,25 @@ app = FastAPI(
10
  version="0.1.0"
11
  )
12
 
13
- app.include_router(users.router, prefix="/api/v1", tags=["Users"])
14
-
15
  app.add_middleware(
16
  CORSMiddleware,
17
- allow_origins=["*"], # Permet toutes les origines (à changer pour la production)
18
  allow_credentials=True,
19
- allow_methods=["*"], # Permet toutes les méthodes (GET, POST, etc.)
20
- allow_headers=["*"], # Permet tous les en-têtes
21
  )
22
 
 
 
23
  app.include_router(questions.router, prefix="/api/v1", tags=["Questions"])
24
  app.include_router(documents.router, prefix="/api/v1", tags=["Documents"])
25
  app.include_router(feedback.router, prefix="/api/v1", tags=["Feedback"])
26
 
 
27
  @app.get("/")
28
  def read_root():
29
  return {"message": "Bienvenue sur l'API de l'Assistant Web Éducatif"}
30
 
31
  @app.get("/health")
32
  def health_check():
33
- return {"status": "ok"}
 
1
+ # backend/main.py
2
+
3
  from fastapi import FastAPI
4
  from fastapi.middleware.cors import CORSMiddleware
5
+ from api.routes import questions, documents, users, feedback
6
+ from config.settings import settings
 
7
 
8
  app = FastAPI(
9
  title=settings.PROJECT_NAME,
 
11
  version="0.1.0"
12
  )
13
 
 
 
14
  app.add_middleware(
15
  CORSMiddleware,
16
+ allow_origins=["*"],
17
  allow_credentials=True,
18
+ allow_methods=["*"],
19
+ allow_headers=["*"],
20
  )
21
 
22
+ # Les inclusions de routeurs sont correctes
23
+ app.include_router(users.router, prefix="/api/v1", tags=["Users"])
24
  app.include_router(questions.router, prefix="/api/v1", tags=["Questions"])
25
  app.include_router(documents.router, prefix="/api/v1", tags=["Documents"])
26
  app.include_router(feedback.router, prefix="/api/v1", tags=["Feedback"])
27
 
28
+
29
  @app.get("/")
30
  def read_root():
31
  return {"message": "Bienvenue sur l'API de l'Assistant Web Éducatif"}
32
 
33
  @app.get("/health")
34
  def health_check():
35
+ return {"status": "ok"}
backend/models/document.py CHANGED
@@ -1,6 +1,6 @@
1
  from sqlalchemy import Column, Integer, String, DateTime
2
  from sqlalchemy.sql import func
3
- from backend.config.database import Base
4
 
5
  class Document(Base):
6
  __tablename__ = "documents"
 
1
  from sqlalchemy import Column, Integer, String, DateTime
2
  from sqlalchemy.sql import func
3
+ from config.database import Base
4
 
5
  class Document(Base):
6
  __tablename__ = "documents"
backend/models/feedback.py CHANGED
@@ -1,7 +1,7 @@
1
  # backend/models/feedback.py
2
  from sqlalchemy import Column, Integer, String, Text, DateTime
3
  from sqlalchemy.sql import func
4
- from backend.config.database import Base
5
 
6
  class Feedback(Base):
7
  __tablename__ = "feedback"
 
1
  # backend/models/feedback.py
2
  from sqlalchemy import Column, Integer, String, Text, DateTime
3
  from sqlalchemy.sql import func
4
+ from config.database import Base
5
 
6
  class Feedback(Base):
7
  __tablename__ = "feedback"
backend/models/question.py CHANGED
@@ -1,17 +1,14 @@
1
- # backend/models/question.py
2
-
3
  from pydantic import BaseModel, Field
4
  from typing import Optional, List
5
 
6
  class QuestionRequest(BaseModel):
7
  question: str = Field(..., min_length=5, max_length=500)
8
-
9
  class Source(BaseModel):
10
- document_id: Optional[int]
11
  page: Optional[int]
12
 
13
  class QuestionResponse(BaseModel):
14
  question: str
15
  answer: str
16
  sources: List[Source]
17
- cached: bool = False
 
 
 
1
  from pydantic import BaseModel, Field
2
  from typing import Optional, List
3
 
4
  class QuestionRequest(BaseModel):
5
  question: str = Field(..., min_length=5, max_length=500)
 
6
  class Source(BaseModel):
7
+ document: str
8
  page: Optional[int]
9
 
10
  class QuestionResponse(BaseModel):
11
  question: str
12
  answer: str
13
  sources: List[Source]
14
+ cached: bool = False
backend/models/user.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import enum
4
  from sqlalchemy import Column, Integer, String, Enum
5
- from backend.config.database import Base
6
  from pydantic import BaseModel
7
 
8
 
 
2
 
3
  import enum
4
  from sqlalchemy import Column, Integer, String, Enum
5
+ from config.database import Base
6
  from pydantic import BaseModel
7
 
8
 
backend/services/document_processor.py CHANGED
@@ -1,35 +1,86 @@
1
  import fitz # PyMuPDF
2
  from langchain.text_splitter import RecursiveCharacterTextSplitter
3
  from typing import List, Dict
 
4
 
 
 
 
 
 
 
5
 
6
  def extract_pages_from_pdf(file_path: str) -> List[Dict]:
7
  """Extrait le contenu de chaque page et son numéro."""
8
  doc = fitz.open(file_path)
9
  pages_content = []
 
10
  for page_num, page in enumerate(doc):
11
- pages_content.append({
12
- "page_number": page_num + 1,
13
- "content": page.get_text()
14
- })
 
 
 
 
 
 
 
15
  return pages_content
16
 
17
-
18
  def split_text_into_chunks(pages: List[Dict]) -> List[Dict]:
19
  """Découpe le texte de chaque page en morceaux en conservant les métadonnées."""
20
  text_splitter = RecursiveCharacterTextSplitter(
21
- chunk_size=1000,
22
- chunk_overlap=200,
23
- length_function=len
 
 
 
 
 
 
 
 
24
  )
25
 
26
  all_chunks = []
 
 
27
  for page in pages:
 
28
  chunks_on_page = text_splitter.split_text(page["content"])
29
- for chunk in chunks_on_page:
 
 
 
 
30
  all_chunks.append({
31
- "text": chunk,
32
- "metadata": {"page": page["page_number"]}
 
 
 
 
 
33
  })
34
-
35
- return all_chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import fitz # PyMuPDF
2
  from langchain.text_splitter import RecursiveCharacterTextSplitter
3
  from typing import List, Dict
4
+ import re
5
 
6
+ def clean_text(text: str) -> str:
7
+ """Nettoie le texte extrait du PDF."""
8
+ text = re.sub(r' +', ' ', text)
9
+ text = re.sub(r'\n{3,}', '\n\n', text)
10
+ text = '\n'.join(line.strip() for line in text.split('\n'))
11
+ return text.strip()
12
 
13
  def extract_pages_from_pdf(file_path: str) -> List[Dict]:
14
  """Extrait le contenu de chaque page et son numéro."""
15
  doc = fitz.open(file_path)
16
  pages_content = []
17
+
18
  for page_num, page in enumerate(doc):
19
+ raw_text = page.get_text()
20
+ cleaned_text = clean_text(raw_text)
21
+ if len(cleaned_text.strip()) > 50:
22
+ pages_content.append({
23
+ "page_number": page_num + 1,
24
+ "content": cleaned_text
25
+ })
26
+ else:
27
+ print(f"⚠️ Page {page_num + 1} ignorée (trop courte)")
28
+
29
+ print(f"✅ {len(pages_content)} pages extraites du PDF")
30
  return pages_content
31
 
 
32
  def split_text_into_chunks(pages: List[Dict]) -> List[Dict]:
33
  """Découpe le texte de chaque page en morceaux en conservant les métadonnées."""
34
  text_splitter = RecursiveCharacterTextSplitter(
35
+ chunk_size=1500,
36
+ chunk_overlap=300,
37
+ length_function=len,
38
+ separators=[
39
+ "\n\n\n",
40
+ "\n\n",
41
+ "\n",
42
+ ". ",
43
+ " ",
44
+ ""
45
+ ]
46
  )
47
 
48
  all_chunks = []
49
+ chunk_global_index = 0
50
+
51
  for page in pages:
52
+ page_num = page["page_number"]
53
  chunks_on_page = text_splitter.split_text(page["content"])
54
+
55
+ for local_idx, chunk_text in enumerate(chunks_on_page):
56
+ if len(chunk_text.strip()) < 100:
57
+ print(f"⚠️ Chunk ignoré (trop court) - Page {page_num}")
58
+ continue
59
  all_chunks.append({
60
+ "text": chunk_text.strip(),
61
+ "metadata": {
62
+ "page": page_num,
63
+ "chunk_index_on_page": local_idx,
64
+ "global_chunk_index": chunk_global_index,
65
+ "chunk_length": len(chunk_text)
66
+ }
67
  })
68
+ chunk_global_index += 1
69
+
70
+ print(f"✅ {len(all_chunks)} chunks créés au total")
71
+ if all_chunks:
72
+ avg_length = sum(c["metadata"]["chunk_length"] for c in all_chunks) / len(all_chunks)
73
+ print(f"📊 Longueur moyenne des chunks : {avg_length:.0f} caractères")
74
+ return all_chunks
75
+ def preview_chunks(chunks: List[Dict], n: int = 3):
76
+ """Affiche les n premiers chunks pour vérifier la qualité du découpage."""
77
+ print(f"\n🔍 Aperçu des {min(n, len(chunks))} premiers chunks :\n")
78
+
79
+ for i, chunk in enumerate(chunks[:n]):
80
+ text = chunk["text"]
81
+ meta = chunk["metadata"]
82
+
83
+ print(f"--- Chunk #{i+1} (Page {meta['page']}) ---")
84
+ print(f"Longueur : {meta['chunk_length']} caractères")
85
+ print(f"Texte (100 premiers caractères) : {text[:100]}...")
86
+ print()
backend/services/ollama_client.py CHANGED
@@ -1,4 +1,3 @@
1
- # backend/services/ollama_client.py
2
  import requests
3
  import json
4
  import os
@@ -6,32 +5,54 @@ import os
6
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
7
 
8
  def generate_response(question: str, context: str) -> str:
9
- """
10
- Génère une réponse en utilisant Ollama avec un contexte.
11
- """
12
- prompt = f"""
13
- En te basant uniquement sur le contexte suivant, réponds à la question.
14
- Contexte :
15
- ---
16
- {context}
17
- ---
18
- Question : {question}
19
- """
 
 
 
 
 
20
 
 
21
  try:
22
- response = requests.post(
 
23
  f"{OLLAMA_HOST}/api/generate",
24
  json={
25
- "model": "mistral",
26
  "prompt": prompt,
27
- "stream": False
 
 
 
 
28
  },
29
- timeout=60
30
- )
31
- response.raise_for_status()
32
- full_response = response.text.strip().split('\n')[-1]
33
- return json.loads(full_response).get("response", "Aucune réponse générée.")
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  except requests.exceptions.RequestException as e:
36
- print(f"Error calling Ollama: {e}")
37
  return "Désolé, une erreur est survenue lors de la génération de la réponse."
 
 
1
  import requests
2
  import json
3
  import os
 
5
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
6
 
7
  def generate_response(question: str, context: str) -> str:
8
+ prompt = f"""Tu es un assistant éducatif spécialisé en Recherche Opérationnelle.
9
+
10
+ CONTEXTE DU DOCUMENT :
11
+ {context}
12
+
13
+ QUESTION DE L'ÉTUDIANT :
14
+ {question}
15
+
16
+ INSTRUCTIONS IMPORTANTES :
17
+ 1. Réponds UNIQUEMENT en utilisant les informations du contexte ci-dessus
18
+ 2. Si l'information n'existe PAS dans le contexte, réponds EXACTEMENT : "L'information n'est pas disponible dans le document fourni."
19
+ 3. Sois précis, pédagogique et structuré
20
+ 4. Utilise des exemples du contexte si disponibles
21
+ 5. Ne cite JAMAIS d'informations externes au contexte
22
+
23
+ RÉPONSE :"""
24
 
25
+ full_response_text = ""
26
  try:
27
+ print(f"Envoi de la requête en streaming à Ollama sur l'hôte : {OLLAMA_HOST}")
28
+ with requests.post(
29
  f"{OLLAMA_HOST}/api/generate",
30
  json={
31
+ "model": "mistral",
32
  "prompt": prompt,
33
+ "stream": True,
34
+ "options": {
35
+ "temperature": 0.1,
36
+ "num_predict": 500
37
+ }
38
  },
39
+ stream=True,
40
+ timeout=300
41
+ ) as response:
42
+ response.raise_for_status()
43
+ for line in response.iter_lines():
44
+ if line:
45
+ try:
46
+ chunk = json.loads(line)
47
+ full_response_text += chunk.get("response", "")
48
+ if chunk.get("done"):
49
+ break
50
+ except json.JSONDecodeError:
51
+ print(f"Ligne JSON invalide reçue d'Ollama: {line}")
52
+
53
+ print("Réponse complète reçue d'Ollama.")
54
+ return full_response_text.strip()
55
 
56
  except requests.exceptions.RequestException as e:
57
+ print(f"Erreur lors de l'appel à Ollama : {e}")
58
  return "Désolé, une erreur est survenue lors de la génération de la réponse."
backend/services/question_handler.py CHANGED
@@ -1,35 +1,51 @@
1
- # backend/services/question_handler.py
2
-
3
- from backend.services.vector_store import VectorStore
4
- from backend.services.ollama_client import generate_response
5
- from backend.services.cache_manager import CacheManager
6
-
7
  class QuestionHandler:
8
  def __init__(self):
9
  self.vector_store = VectorStore()
10
- self.cache = CacheManager()
11
 
12
- def get_answer(self, question: str):
13
  cached_answer = self.cache.get(question)
14
  if cached_answer:
15
- print(f"Réponse trouvée dans le cache pour : '{question}'")
16
  return {**cached_answer, "cached": True}
17
- print(f"Réponse non trouvée dans le cache pour : '{question}'. Génération en cours...")
18
- search_results = self.vector_store.find_similar_chunks(question)
 
 
 
 
 
 
 
 
 
19
  context_texts = search_results["documents"][0]
20
- sources_metadata = search_results["metadatas"][0]
21
  context = "\n---\n".join(context_texts)
 
22
  answer = generate_response(question, context)
 
 
 
 
 
23
  sources = []
24
  for meta in sources_metadata:
 
25
  sources.append({
26
- "document_id": meta.get("document_id"),
27
  "page": meta.get("page")
28
  })
 
29
  final_response = {
30
  "question": question,
31
  "answer": answer,
32
  "sources": sources
33
  }
 
34
  self.cache.set(question, final_response)
35
  return {**final_response, "cached": False}
 
1
+ from sqlalchemy.orm import Session
2
+ from services.vector_store import VectorStore
3
+ from services.ollama_client import generate_response
4
+ from services.cache_manager import CacheManager
5
+ from models.document import Document
 
6
  class QuestionHandler:
7
  def __init__(self):
8
  self.vector_store = VectorStore()
9
+ self.cache = CacheManager()
10
 
11
+ def get_answer(self, question: str, db: Session):
12
  cached_answer = self.cache.get(question)
13
  if cached_answer:
 
14
  return {**cached_answer, "cached": True}
15
+
16
+ search_results = self.vector_store.find_similar_chunks(question, n_results=5)
17
+
18
+ if not search_results or not search_results.get("documents") or not search_results["documents"][0]:
19
+ return {
20
+ "question": question,
21
+ "answer": "Désolé, je n'ai trouvé aucune information pertinente dans les documents fournis pour répondre à cette question.",
22
+ "sources": [],
23
+ "cached": False
24
+ }
25
+
26
  context_texts = search_results["documents"][0]
27
+ sources_metadata = search_results["metadatas"][0]
28
  context = "\n---\n".join(context_texts)
29
+
30
  answer = generate_response(question, context)
31
+
32
+ doc_ids = {meta.get("document_id") for meta in sources_metadata if meta.get("document_id") is not None}
33
+ documents = db.query(Document).filter(Document.id.in_(doc_ids)).all()
34
+ doc_titles = {doc.id: doc.file_name for doc in documents}
35
+
36
  sources = []
37
  for meta in sources_metadata:
38
+ doc_id = meta.get("document_id")
39
  sources.append({
40
+ "document": doc_titles.get(doc_id, "Titre inconnu"),
41
  "page": meta.get("page")
42
  })
43
+
44
  final_response = {
45
  "question": question,
46
  "answer": answer,
47
  "sources": sources
48
  }
49
+
50
  self.cache.set(question, final_response)
51
  return {**final_response, "cached": False}
backend/services/vector_store.py CHANGED
@@ -5,19 +5,25 @@ from typing import List, Dict
5
  class VectorStore:
6
  def __init__(self):
7
  self.client = chromadb.PersistentClient(path="data/chroma_db")
8
- self.embedding_model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
9
- self.collection = self.client.get_or_create_collection(name="documents")
 
 
 
 
 
10
 
11
  def add_document_chunks(self, doc_id: int, chunks: List[Dict]):
12
  if not chunks:
13
  return
14
 
15
  texts = [chunk["text"] for chunk in chunks]
16
- embeddings = self.embedding_model.encode(texts)
 
17
 
18
  metadatas = []
19
  for i, chunk in enumerate(chunks):
20
- meta = chunk["metadata"]
21
  meta["document_id"] = doc_id
22
  meta["chunk_index"] = i
23
  metadatas.append(meta)
@@ -25,21 +31,30 @@ class VectorStore:
25
  ids = [f"doc_{doc_id}_chunk_{i}" for i, _ in enumerate(chunks)]
26
 
27
  self.collection.add(
28
- embeddings=embeddings,
29
  metadatas=metadatas,
30
- documents=texts,
31
  ids=ids
32
  )
33
- print(f"Ajout de {len(chunks)} chunks pour le document {doc_id} à ChromaDB.")
34
 
35
- def find_similar_chunks(self, question: str, n_results: int = 3) -> Dict:
36
  """Trouve les chunks pertinents et retourne leur contenu et métadonnées."""
37
- query_embedding = self.embedding_model.encode(question)
 
 
38
 
39
  results = self.collection.query(
40
  query_embeddings=[query_embedding.tolist()],
41
  n_results=n_results,
42
- include=["documents", "metadatas"]
43
  )
44
 
 
 
 
 
 
 
 
45
  return results
 
5
  class VectorStore:
6
  def __init__(self):
7
  self.client = chromadb.PersistentClient(path="data/chroma_db")
8
+
9
+ self.embedding_model = SentenceTransformer('intfloat/multilingual-e5-large')
10
+
11
+ self.collection = self.client.get_or_create_collection(
12
+ name="documents",
13
+ metadata={"hnsw:space": "cosine"}
14
+ )
15
 
16
  def add_document_chunks(self, doc_id: int, chunks: List[Dict]):
17
  if not chunks:
18
  return
19
 
20
  texts = [chunk["text"] for chunk in chunks]
21
+ prefixed_texts = [f"passage: {text}" for text in texts]
22
+ embeddings = self.embedding_model.encode(prefixed_texts, normalize_embeddings=True)
23
 
24
  metadatas = []
25
  for i, chunk in enumerate(chunks):
26
+ meta = chunk["metadata"].copy()
27
  meta["document_id"] = doc_id
28
  meta["chunk_index"] = i
29
  metadatas.append(meta)
 
31
  ids = [f"doc_{doc_id}_chunk_{i}" for i, _ in enumerate(chunks)]
32
 
33
  self.collection.add(
34
+ embeddings=embeddings.tolist(),
35
  metadatas=metadatas,
36
+ documents=texts,
37
  ids=ids
38
  )
39
+ print(f"✅ Ajout de {len(chunks)} chunks pour le document {doc_id} à ChromaDB.")
40
 
41
+ def find_similar_chunks(self, question: str, n_results: int = 5) -> Dict:
42
  """Trouve les chunks pertinents et retourne leur contenu et métadonnées."""
43
+
44
+ prefixed_question = f"query: {question}"
45
+ query_embedding = self.embedding_model.encode(prefixed_question, normalize_embeddings=True)
46
 
47
  results = self.collection.query(
48
  query_embeddings=[query_embedding.tolist()],
49
  n_results=n_results,
50
+ include=["documents", "metadatas", "distances"]
51
  )
52
 
53
+ if results.get("distances"):
54
+ distances = results["distances"][0]
55
+ print(f"\n📊 Scores de similarité pour '{question}':")
56
+ for i, dist in enumerate(distances[:3]):
57
+ similarity = 1 - dist
58
+ print(f" Chunk {i+1}: {similarity:.3f}")
59
+
60
  return results
docker-compose.yml CHANGED
@@ -22,6 +22,8 @@ services:
22
  - ollama_data:/root/.ollama
23
  ports:
24
  - "11434:11434"
 
 
25
 
26
 
27
  redis:
 
22
  - ollama_data:/root/.ollama
23
  ports:
24
  - "11434:11434"
25
+ environment:
26
+ - OLLAMA_KEEP_ALIVE=-1
27
 
28
 
29
  redis:
frontend/src/App.jsx CHANGED
@@ -15,6 +15,18 @@ function App() {
15
  return;
16
  }
17
  try {
 
 
 
 
 
 
 
 
 
 
 
 
18
  await fetch('http://localhost:8000/api/v1/feedback', {
19
  method: 'POST',
20
  headers: {
@@ -22,7 +34,7 @@ function App() {
22
  'Authorization': `Bearer ${token}`
23
  },
24
  body: JSON.stringify({
25
- question: messages[messages.indexOf(message) - 1].text, // Trouve la question précédente
26
  answer: message.text,
27
  rating: rating
28
  })
@@ -44,13 +56,10 @@ function App() {
44
  setPrompt('');
45
  setIsLoading(true);
46
 
47
- // --- NOUVEAU : Appel à l'API Backend ---
48
  try {
49
  const response = await fetch('http://localhost:8000/api/v1/ask', {
50
  method: 'POST',
51
- headers: {
52
- 'Content-Type': 'application/json',
53
- },
54
  body: JSON.stringify({ question: currentPrompt }),
55
  });
56
 
@@ -60,10 +69,10 @@ function App() {
60
 
61
  const data = await response.json();
62
 
63
- const apiResponse = {
64
- sender: 'bot',
65
  text: data.answer,
66
- sources: data.sources // On sauvegarde les sources
67
  };
68
  setMessages(prev => [...prev, apiResponse]);
69
 
@@ -77,34 +86,34 @@ function App() {
77
  } finally {
78
  setIsLoading(false);
79
  }
80
- // --- FIN DU NOUVEAU BLOC ---
81
  };
82
 
83
  return (
84
  <div className="app-container">
85
  <header className="app-header">
86
- <h1>Assistant Web Educatif</h1>
87
  </header>
88
 
89
  <div className="chat-window">
90
  {messages.map((msg, index) => (
91
  <div key={index} className={`message ${msg.sender}`}>
92
  <p>{msg.text}</p>
93
- {/* Affiche les sources si elles existent */}
94
- {msg.sender === 'bot' && msg.sources && (
95
  <div className="sources">
96
  <strong>Sources:</strong>
97
  <ul>
98
  {msg.sources.map((source, i) => (
99
  <li key={i}>
100
- Document ID: {source.document_id}, Page: {source.page}
 
101
  </li>
102
  ))}
103
  </ul>
104
  </div>
105
  )}
106
- {/* boutons de feedback pour les messages du bot */}
107
- {msg.sender === 'bot' && !msg.sources && (
108
  <div className="feedback-buttons">
109
  <button onClick={() => handleFeedback(msg, 1)}>👍</button>
110
  <button onClick={() => handleFeedback(msg, -1)}>👎</button>
@@ -120,7 +129,7 @@ function App() {
120
  type="text"
121
  value={prompt}
122
  onChange={(e) => setPrompt(e.target.value)}
123
- placeholder="Posez votre question ..."
124
  disabled={isLoading}
125
  />
126
  <button type="submit" disabled={isLoading}>Envoyer</button>
 
15
  return;
16
  }
17
  try {
18
+ let questionText = '';
19
+ const messageIndex = messages.indexOf(message);
20
+ for (let i = messageIndex - 1; i >= 0; i--) {
21
+ if (messages[i].sender === 'user') {
22
+ questionText = messages[i].text;
23
+ break;
24
+ }
25
+ }
26
+ if (!questionText) {
27
+ alert("Impossible de trouver la question originale.");
28
+ return;
29
+ }
30
  await fetch('http://localhost:8000/api/v1/feedback', {
31
  method: 'POST',
32
  headers: {
 
34
  'Authorization': `Bearer ${token}`
35
  },
36
  body: JSON.stringify({
37
+ question: questionText,
38
  answer: message.text,
39
  rating: rating
40
  })
 
56
  setPrompt('');
57
  setIsLoading(true);
58
 
 
59
  try {
60
  const response = await fetch('http://localhost:8000/api/v1/ask', {
61
  method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
 
 
63
  body: JSON.stringify({ question: currentPrompt }),
64
  });
65
 
 
69
 
70
  const data = await response.json();
71
 
72
+ const apiResponse = {
73
+ sender: 'bot',
74
  text: data.answer,
75
+ sources: data.sources
76
  };
77
  setMessages(prev => [...prev, apiResponse]);
78
 
 
86
  } finally {
87
  setIsLoading(false);
88
  }
 
89
  };
90
 
91
  return (
92
  <div className="app-container">
93
  <header className="app-header">
94
+ <h1>Assistant Web Éducatif</h1>
95
  </header>
96
 
97
  <div className="chat-window">
98
  {messages.map((msg, index) => (
99
  <div key={index} className={`message ${msg.sender}`}>
100
  <p>{msg.text}</p>
101
+
102
+ {msg.sender === 'bot' && msg.sources && msg.sources.length > 0 && (
103
  <div className="sources">
104
  <strong>Sources:</strong>
105
  <ul>
106
  {msg.sources.map((source, i) => (
107
  <li key={i}>
108
+ {/* LIGNE CORRIGÉE CI-DESSOUS */}
109
+ {source.document}, Page: {source.page}
110
  </li>
111
  ))}
112
  </ul>
113
  </div>
114
  )}
115
+
116
+ {msg.sender === 'bot' && msg.text !== "Désolé, une erreur est survenue. Veuillez réessayer." && (
117
  <div className="feedback-buttons">
118
  <button onClick={() => handleFeedback(msg, 1)}>👍</button>
119
  <button onClick={() => handleFeedback(msg, -1)}>👎</button>
 
129
  type="text"
130
  value={prompt}
131
  onChange={(e) => setPrompt(e.target.value)}
132
+ placeholder="Posez votre question..."
133
  disabled={isLoading}
134
  />
135
  <button type="submit" disabled={isLoading}>Envoyer</button>
scripts/init_db.py CHANGED
@@ -1,6 +1,6 @@
1
- from backend.config.database import Base, engine
2
- from backend.models import document
3
- from backend.models import user
4
 
5
  def init_db():
6
  print("Création des tables de la base de données...")
 
1
+ from config.database import Base, engine
2
+ from models import document
3
+ from models import user
4
 
5
  def init_db():
6
  print("Création des tables de la base de données...")
scripts/set_user_role.py CHANGED
@@ -1,8 +1,8 @@
1
  # scripts/set_user_role.py
2
  import sys
3
  from sqlalchemy.orm import Session
4
- from backend.config.database import SessionLocal
5
- from backend.models.user import User, RoleEnum
6
 
7
  def set_user_role(email: str, role_input: str):
8
  db: Session = SessionLocal()
 
1
  # scripts/set_user_role.py
2
  import sys
3
  from sqlalchemy.orm import Session
4
+ from config.database import SessionLocal
5
+ from models.user import User, RoleEnum
6
 
7
  def set_user_role(email: str, role_input: str):
8
  db: Session = SessionLocal()