hamba-ho commited on
Commit
35b525b
·
1 Parent(s): 7826da3

Feat: Ajout des sources aux réponses de l'API

Browse files
PROCHAINES_ETAPES.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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].
backend/api/routes/documents.py CHANGED
@@ -1,46 +1,64 @@
1
- from fastapi import APIRouter, Depends
2
  from sqlalchemy.orm import Session
 
 
 
3
  from backend.models.document import Document
4
  from backend.api.dependencies import get_db
5
- from backend.services.document_processor import extract_text_from_pdf, split_text_into_chunks
 
6
  from backend.services.vector_store import VectorStore
7
 
8
  router = APIRouter()
9
 
 
 
 
10
  @router.post("/documents")
11
- def create_document(file_name: str, subject: str, level: str, db: Session = Depends(get_db)):
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- new_document = Document(file_name=file_name, subject=subject, level=level)
14
  db.add(new_document)
15
  db.commit()
16
  db.refresh(new_document)
17
- return new_document
18
-
19
- @router.get("/documents")
20
- def get_all_documents(db: Session = Depends(get_db)):
21
-
22
- documents = db.query(Document).all()
23
- return documents
24
 
25
- @router.get("/documents/{doc_id}/process")
26
- def process_document_text(doc_id: int, db: Session = Depends(get_db)):
27
- document = db.query(Document).filter(Document.id == doc_id).first()
28
- if not document:
29
- return {"error": "Document non trouvé"}
30
-
31
- file_path = f"data/{document.file_name}"
32
- text = extract_text_from_pdf(file_path)
33
- chunks = split_text_into_chunks(text)
34
 
35
  try:
36
  vector_store = VectorStore()
37
- vector_store.add_document_chunks(doc_id=doc_id, chunks=chunks)
 
 
38
 
39
- return {
40
- "document_id": doc_id,
41
- "file_name": document.file_name,
42
- "status": "Traitement et vectorisation réussis.",
43
- "total_chunks_added": len(chunks)
 
 
 
44
  }
45
- except Exception as e:
46
- return {"error": f"Erreur lors de la vectorisation : {e}"}
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
2
  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
+ # MODIFIÉ: Importe la nouvelle fonction extract_pages_from_pdf
9
+ from backend.services.document_processor import extract_pages_from_pdf, split_text_into_chunks
10
  from backend.services.vector_store import VectorStore
11
 
12
  router = APIRouter()
13
 
14
+ UPLOAD_DIRECTORY = "data/documents"
15
+ os.makedirs(UPLOAD_DIRECTORY, exist_ok=True)
16
+
17
  @router.post("/documents")
18
+ async def create_and_process_document(
19
+ db: Session = Depends(get_db),
20
+ subject: str = Form(...),
21
+ level: str = Form(...),
22
+ file: UploadFile = File(...)
23
+ ):
24
+ file_path = os.path.join(UPLOAD_DIRECTORY, file.filename)
25
+ try:
26
+ async with aiofiles.open(file_path, 'wb') as out_file:
27
+ content = await file.read()
28
+ await out_file.write(content)
29
+ except Exception as e:
30
+ raise HTTPException(status_code=500, detail=f"Erreur lors de la sauvegarde du fichier : {e}")
31
 
32
+ new_document = Document(file_name=file.filename, subject=subject, level=level)
33
  db.add(new_document)
34
  db.commit()
35
  db.refresh(new_document)
 
 
 
 
 
 
 
36
 
37
+ try:
38
+ # MODIFIÉ: Utilise les nouvelles fonctions pour le traitement
39
+ pages = extract_pages_from_pdf(file_path)
40
+ chunks = split_text_into_chunks(pages)
41
+ except Exception as e:
42
+ raise HTTPException(status_code=500, detail=f"Erreur lors de l'extraction du texte du PDF : {e}")
 
 
 
43
 
44
  try:
45
  vector_store = VectorStore()
46
+ vector_store.add_document_chunks(doc_id=new_document.id, chunks=chunks)
47
+ except Exception as e:
48
+ raise HTTPException(status_code=500, detail=f"Erreur lors de la vectorisation : {e}")
49
 
50
+ return {
51
+ "message": "Document téléversé et traité avec succès !",
52
+ "document_details": {
53
+ "id": new_document.id,
54
+ "file_name": new_document.file_name,
55
+ "subject": new_document.subject,
56
+ "level": new_document.level,
57
+ "chunks_added": len(chunks)
58
  }
59
+ }
60
+
61
+ @router.get("/documents")
62
+ def get_all_documents(db: Session = Depends(get_db)):
63
+ documents = db.query(Document).all()
64
+ return documents
backend/api/routes/questions.py CHANGED
@@ -1,14 +1,16 @@
1
  from fastapi import APIRouter
2
- from backend.models.question import QuestionRequest
 
3
  from backend.services.question_handler import QuestionHandler
4
 
5
  router = APIRouter()
6
  handler = QuestionHandler()
7
 
8
- @router.post("/ask")
 
9
  def ask_question(request: QuestionRequest):
10
  """
11
  Receives a question, finds context, and returns an AI-generated answer.
12
  """
13
  answer_data = handler.get_answer(request.question)
14
- return answer_data
 
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
  """
15
  answer_data = handler.get_answer(request.question)
16
+ return answer_data
backend/models/question.py CHANGED
@@ -1,7 +1,16 @@
1
  from pydantic import BaseModel, Field
2
- from typing import Optional
3
 
4
  class QuestionRequest(BaseModel):
5
  question: str = Field(..., min_length=5, max_length=500)
6
- context: Optional[str] = None
7
- user_id: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
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
+
7
+ # NOUVEAU: Modèle pour une source unique
8
+ class Source(BaseModel):
9
+ document_id: Optional[int]
10
+ page: Optional[int]
11
+
12
+ # NOUVEAU: Modèle pour la réponse complète
13
+ class QuestionResponse(BaseModel):
14
+ question: str
15
+ answer: str
16
+ sources: List[Source]
backend/services/document_processor.py CHANGED
@@ -1,26 +1,35 @@
1
  import fitz # PyMuPDF
2
  from langchain.text_splitter import RecursiveCharacterTextSplitter
 
3
 
4
- def extract_text_from_pdf(file_path: str) -> str:
5
- # ... (cette fonction ne change pas)
6
- try:
7
- doc = fitz.open(file_path)
8
- text = ""
9
- for page in doc:
10
- text += page.get_text()
11
- return text
12
- except Exception as e:
13
- print(f"Erreur lors de l'extraction du PDF {file_path}: {e}")
14
- return ""
15
 
16
- def split_text_into_chunks(text: str) -> list[str]:
17
- """
18
- Découpe un long texte en morceaux plus petits.
19
- """
 
 
 
 
 
 
 
 
 
 
20
  text_splitter = RecursiveCharacterTextSplitter(
21
- chunk_size=1000, # La taille de chaque morceau
22
- chunk_overlap=200, # Le nombre de caractères de chevauchement
23
  length_function=len
24
  )
25
- chunks = text_splitter.split_text(text)
26
- return chunks
 
 
 
 
 
 
 
 
 
 
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
backend/services/question_handler.py CHANGED
@@ -6,14 +6,28 @@ class QuestionHandler:
6
  self.vector_store = VectorStore()
7
 
8
  def get_answer(self, question: str):
9
- # 1. Find relevant context in the vector database
10
- context = self.vector_store.find_similar_chunks(question)
11
 
12
- # 2. Generate an answer using the context and the question
 
 
 
 
 
 
13
  answer = generate_response(question, context)
14
 
 
 
 
 
 
 
 
 
15
  return {
16
  "question": question,
17
  "answer": answer,
18
- "context": context
19
- }
 
6
  self.vector_store = VectorStore()
7
 
8
  def get_answer(self, question: str):
9
+ # 1. Trouver les chunks pertinents avec leurs métadonnées
10
+ search_results = self.vector_store.find_similar_chunks(question)
11
 
12
+ # Extraire le contexte et les sources
13
+ context_texts = search_results["documents"][0]
14
+ sources_metadata = search_results["metadatas"][0]
15
+
16
+ context = "\n---\n".join(context_texts)
17
+
18
+ # 2. Générer une réponse en utilisant le contexte
19
  answer = generate_response(question, context)
20
 
21
+ # 3. Formater les sources pour la réponse finale
22
+ sources = []
23
+ for meta in sources_metadata:
24
+ sources.append({
25
+ "document_id": meta.get("document_id"),
26
+ "page": meta.get("page")
27
+ })
28
+
29
  return {
30
  "question": question,
31
  "answer": answer,
32
+ "sources": sources
33
+ }
backend/services/vector_store.py CHANGED
@@ -1,43 +1,48 @@
1
  import chromadb
2
  from sentence_transformers import SentenceTransformer
 
3
 
4
  class VectorStore:
5
  def __init__(self):
6
- # ... (le début de la classe ne change pas)
7
  self.client = chromadb.PersistentClient(path="data/chroma_db")
8
  self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
9
  self.collection = self.client.get_or_create_collection(name="documents")
10
 
11
- def add_document_chunks(self, doc_id: int, chunks: list[str]):
12
- # ... (cette fonction ne change pas)
13
  if not chunks:
14
  return
15
 
16
- embeddings = self.embedding_model.encode(chunks)
17
- metadatas = [{"document_id": doc_id, "chunk_index": i} for i, _ in enumerate(chunks)]
 
 
 
 
 
 
 
 
 
18
  ids = [f"doc_{doc_id}_chunk_{i}" for i, _ in enumerate(chunks)]
19
 
20
  self.collection.add(
21
  embeddings=embeddings,
22
  metadatas=metadatas,
23
- documents=chunks,
24
  ids=ids
25
  )
26
  print(f"Ajout de {len(chunks)} chunks pour le document {doc_id} à ChromaDB.")
27
 
28
- # === AJOUTEZ CETTE NOUVELLE FONCTION CI-DESSOUS ===
29
- def find_similar_chunks(self, question: str, n_results: int = 3) -> list[str]:
30
- """
31
- Trouve les morceaux de texte les plus pertinents pour une question donnée.
32
- """
33
- # Transforme la question en vecteur.
34
  query_embedding = self.embedding_model.encode(question)
35
-
36
- # Interroge la collection ChromaDB.
37
  results = self.collection.query(
38
  query_embeddings=[query_embedding.tolist()],
39
- n_results=n_results
 
40
  )
41
-
42
- # Retourne uniquement le texte des documents trouvés.
43
- return results['documents'][0]
 
1
  import chromadb
2
  from sentence_transformers import SentenceTransformer
3
+ from typing import List, Dict
4
 
5
  class VectorStore:
6
  def __init__(self):
 
7
  self.client = chromadb.PersistentClient(path="data/chroma_db")
8
  self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
9
  self.collection = self.client.get_or_create_collection(name="documents")
10
 
11
+ # MODIFIÉ: La fonction accepte maintenant une liste de dictionnaires
12
+ def add_document_chunks(self, doc_id: int, chunks: List[Dict]):
13
  if not chunks:
14
  return
15
 
16
+ texts = [chunk["text"] for chunk in chunks]
17
+ embeddings = self.embedding_model.encode(texts)
18
+
19
+ # MODIFIÉ: Les métadonnées incluent maintenant le numéro de page
20
+ metadatas = []
21
+ for i, chunk in enumerate(chunks):
22
+ meta = chunk["metadata"]
23
+ meta["document_id"] = doc_id
24
+ meta["chunk_index"] = i
25
+ metadatas.append(meta)
26
+
27
  ids = [f"doc_{doc_id}_chunk_{i}" for i, _ in enumerate(chunks)]
28
 
29
  self.collection.add(
30
  embeddings=embeddings,
31
  metadatas=metadatas,
32
+ documents=texts,
33
  ids=ids
34
  )
35
  print(f"Ajout de {len(chunks)} chunks pour le document {doc_id} à ChromaDB.")
36
 
37
+ # MODIFIÉ: La fonction retourne maintenant les documents ET leurs métadonnées
38
+ def find_similar_chunks(self, question: str, n_results: int = 3) -> Dict:
39
+ """Trouve les chunks pertinents et retourne leur contenu et métadonnées."""
 
 
 
40
  query_embedding = self.embedding_model.encode(question)
41
+
 
42
  results = self.collection.query(
43
  query_embeddings=[query_embedding.tolist()],
44
+ n_results=n_results,
45
+ include=["documents", "metadatas"] # On demande explicitement les métadonnées
46
  )
47
+
48
+ return results
 
requirements.txt CHANGED
@@ -1,10 +1,131 @@
1
- fastapi
2
- uvicorn[standard]
3
- pydantic-settings
4
- SQLAlchemy
5
- psycopg2-binary
6
- PyMuPDF
7
- langchain
8
- sentence-transformers
9
- chromadb
10
- requests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiofiles==24.1.0
2
+ annotated-types==0.7.0
3
+ anyio==4.10.0
4
+ attrs==25.3.0
5
+ backoff==2.2.1
6
+ bcrypt==4.3.0
7
+ build==1.3.0
8
+ cachetools==5.5.2
9
+ certifi==2025.8.3
10
+ charset-normalizer==3.4.3
11
+ chromadb==1.0.20
12
+ click==8.2.1
13
+ coloredlogs==15.0.1
14
+ distro==1.9.0
15
+ durationpy==0.10
16
+ fastapi==0.116.1
17
+ filelock==3.19.1
18
+ flatbuffers==25.2.10
19
+ fsspec==2025.7.0
20
+ google-auth==2.40.3
21
+ googleapis-common-protos==1.70.0
22
+ greenlet==3.2.4
23
+ grpcio==1.74.0
24
+ h11==0.16.0
25
+ hf-xet==1.1.8
26
+ httpcore==1.0.9
27
+ httptools==0.6.4
28
+ httpx==0.28.1
29
+ huggingface-hub==0.34.4
30
+ humanfriendly==10.0
31
+ idna==3.10
32
+ importlib_metadata==8.7.0
33
+ importlib_resources==6.5.2
34
+ Jinja2==3.1.6
35
+ joblib==1.5.1
36
+ jsonpatch==1.33
37
+ jsonpointer==3.0.0
38
+ jsonschema==4.25.1
39
+ jsonschema-specifications==2025.4.1
40
+ kubernetes==33.1.0
41
+ langchain==0.3.27
42
+ langchain-core==0.3.74
43
+ langchain-text-splitters==0.3.9
44
+ langsmith==0.4.16
45
+ markdown-it-py==4.0.0
46
+ MarkupSafe==3.0.2
47
+ mdurl==0.1.2
48
+ mmh3==5.2.0
49
+ mpmath==1.3.0
50
+ networkx==3.5
51
+ numpy==2.3.2
52
+ nvidia-cublas-cu12==12.8.4.1
53
+ nvidia-cuda-cupti-cu12==12.8.90
54
+ nvidia-cuda-nvrtc-cu12==12.8.93
55
+ nvidia-cuda-runtime-cu12==12.8.90
56
+ nvidia-cudnn-cu12==9.10.2.21
57
+ nvidia-cufft-cu12==11.3.3.83
58
+ nvidia-cufile-cu12==1.13.1.3
59
+ nvidia-curand-cu12==10.3.9.90
60
+ nvidia-cusolver-cu12==11.7.3.90
61
+ nvidia-cusparse-cu12==12.5.8.93
62
+ nvidia-cusparselt-cu12==0.7.1
63
+ nvidia-nccl-cu12==2.27.3
64
+ nvidia-nvjitlink-cu12==12.8.93
65
+ nvidia-nvtx-cu12==12.8.90
66
+ oauthlib==3.3.1
67
+ onnxruntime==1.22.1
68
+ opentelemetry-api==1.36.0
69
+ opentelemetry-exporter-otlp-proto-common==1.36.0
70
+ opentelemetry-exporter-otlp-proto-grpc==1.36.0
71
+ opentelemetry-proto==1.36.0
72
+ opentelemetry-sdk==1.36.0
73
+ opentelemetry-semantic-conventions==0.57b0
74
+ orjson==3.11.2
75
+ overrides==7.7.0
76
+ packaging==25.0
77
+ pillow==11.3.0
78
+ posthog==5.4.0
79
+ protobuf==6.32.0
80
+ psycopg2-binary==2.9.10
81
+ pyasn1==0.6.1
82
+ pyasn1_modules==0.4.2
83
+ pybase64==1.4.2
84
+ pydantic==2.11.7
85
+ pydantic-settings==2.10.1
86
+ pydantic_core==2.33.2
87
+ Pygments==2.19.2
88
+ PyMuPDF==1.26.3
89
+ PyPika==0.48.9
90
+ pyproject_hooks==1.2.0
91
+ python-dateutil==2.9.0.post0
92
+ python-dotenv==1.1.1
93
+ python-multipart==0.0.20
94
+ PyYAML==6.0.2
95
+ referencing==0.36.2
96
+ regex==2025.7.34
97
+ requests==2.32.5
98
+ requests-oauthlib==2.0.0
99
+ requests-toolbelt==1.0.0
100
+ rich==14.1.0
101
+ rpds-py==0.27.0
102
+ rsa==4.9.1
103
+ safetensors==0.6.2
104
+ scikit-learn==1.7.1
105
+ scipy==1.16.1
106
+ sentence-transformers==5.1.0
107
+ setuptools==80.9.0
108
+ shellingham==1.5.4
109
+ six==1.17.0
110
+ sniffio==1.3.1
111
+ SQLAlchemy==2.0.43
112
+ starlette==0.47.2
113
+ sympy==1.14.0
114
+ tenacity==9.1.2
115
+ threadpoolctl==3.6.0
116
+ tokenizers==0.21.4
117
+ torch==2.8.0
118
+ tqdm==4.67.1
119
+ transformers==4.55.4
120
+ triton==3.4.0
121
+ typer==0.16.1
122
+ typing-inspection==0.4.1
123
+ typing_extensions==4.14.1
124
+ urllib3==2.5.0
125
+ uvicorn==0.35.0
126
+ uvloop==0.21.0
127
+ watchfiles==1.1.0
128
+ websocket-client==1.8.0
129
+ websockets==15.0.1
130
+ zipp==3.23.0
131
+ zstandard==0.24.0