1- """API FastAPI du portfolio : health check et chat RAG en streaming (SSE ).
1+ """API FastAPI du portfolio : health check et boucle vocale (`/voice` ).
22
3- `/chat` est en `def`, jamais `async def` : `search()` embarque un appel HTTP
4- bloquant vers Ollama (embedding forcé sur CPU). En `def`, FastAPI l'exécute
5- dans son threadpool ; en `async def`, chaque recherche gèlerait tous les flux
6- SSE en cours.
3+ Pas d'endpoint texte en production : pour interroger le RAG à l'écrit, voir
4+ `backend/tests/question_texte.py`, hors du code servi.
75
86Les erreurs renvoyées au client sont génériques : le détail part dans les logs,
97pas dans la réponse d'un visiteur anonyme.
1715
1816from fastapi import FastAPI , HTTPException , WebSocket , WebSocketDisconnect
1917from fastapi .middleware .cors import CORSMiddleware
20- from fastapi .responses import StreamingResponse
2118from ollama import Client
22- from pydantic import BaseModel , Field , field_validator
2319from qdrant_client .http .exceptions import ResponseHandlingException , UnexpectedResponse
2420from starlette .concurrency import run_in_threadpool
2521from starlette .websockets import WebSocketState
3228import httpx
3329
3430# torch et le STT (moshi) ne sont importes qu'au demarrage et dans /voice :
35- # l'API reste importable sans la pile GPU, ce qui permet de tester /chat et
36- # /health en CI sur un runner standard.
31+ # l'API reste importable sans la pile GPU, ce qui permet de tester /health
32+ # en CI sur un runner standard.
3733try : # importe comme paquet
3834 from backend .rag .main import EmbeddingModel , QdrantVectorStore , Settings
3935 from backend .api .prompts import CONSIGNE_ORALE , SYSTEME , construire_prompt_utilisateur
@@ -92,26 +88,30 @@ async def _lifespan(app: FastAPI):
9288app .add_middleware (
9389 CORSMiddleware ,
9490 allow_origins = ORIGINES_AUTORISEES ,
95- allow_methods = ["GET" , "POST" ],
91+ allow_methods = ["GET" ],
9692 allow_headers = ["Content-Type" ],
9793)
9894
95+ # 8 et non 5 : mesure sur le corpus, le chunk portant la reponse a
96+ # "ou a-t-elle fait ses etudes" arrive 6e. A k=5 il tombait hors contexte et
97+ # l'assistant refusait -- correctement, mais a tort.
98+ K_EXTRAITS = 8
9999
100- class ChatRequest (BaseModel ):
101- # Embedding sur CPU : sans borne haute, un corps de plusieurs Mo sature la
102- # file Ollama et le threadpool.
103- message : str = Field (min_length = 1 , max_length = 2000 )
104- # 8 et non 5 : mesure sur le corpus, le chunk portant la reponse a
105- # "ou a-t-elle fait ses etudes" arrive 6e. A k=5 il tombait hors
106- # contexte et l'assistant refusait -- correctement, mais a tort.
107- k : int = Field (default = 8 , ge = 1 , le = 20 )
100+ # Passage d'identité toujours transmis au LLM. Mesuré en prod : sur une question
101+ # vague (« dis-moi un peu plus sur ton parcours »), la recherche ne ramenait que
102+ # GitHub et Piloti, et le LLM inventait « développeur full-stack » 5 fois sur 5.
103+ IDENTITE_SOURCE = "parcours.md"
104+ IDENTITE_TITRE = "Mon parcours en bref"
108105
109- @field_validator ("message" )
110- @classmethod
111- def _non_vide (cls , valeur : str ) -> str :
112- if not valeur .strip ():
113- raise ValueError ("le message ne peut pas être vide" )
114- return valeur
106+
107+ def ajouter_identite (chunks : list [dict ], identite : dict | None ) -> list [dict ]:
108+ """Place le passage d'identité en tête des extraits s'il n'y est pas déjà.
109+ Sans passage (corpus de démonstration, section renommée), rien ne change."""
110+ if identite is None :
111+ return chunks
112+ if any (c ["source" ] == identite ["source" ] and c .get ("titre" ) == identite .get ("titre" ) for c in chunks ):
113+ return chunks
114+ return [{** identite , "score" : None }, * chunks ]
115115
116116
117117def _ollama (settings ) -> Client :
@@ -137,76 +137,6 @@ def health():
137137 return {"status" : "ok" }
138138
139139
140- @app .post ("/chat" )
141- def chat (body : ChatRequest ):
142- settings = Settings ()
143- qdrant_store = QdrantVectorStore (host = settings .qdrant_host , port = settings .qdrant_port )
144- embedding_model = EmbeddingModel (
145- model_name = settings .embedding_model , host = settings .ollama_host , port = settings .ollama_port
146- )
147-
148- # Dans le try : sinon un Qdrant arrêté donne un 500 opaque. Et vérifié avant
149- # d'ouvrir le flux, car après les en-têtes le statut ne peut plus changer.
150- try :
151- presente = qdrant_store .client .collection_exists (settings .qdrant_collection )
152- except Exception as exc :
153- logger .error ("qdrant injoignable (collection_exists) : %s" , exc )
154- raise HTTPException (status_code = 503 , detail = "Base vectorielle injoignable." ) from exc
155- if not presente :
156- logger .error ("collection '%s' absente" , settings .qdrant_collection )
157- raise HTTPException (status_code = 503 , detail = "Corpus non indexé." )
158-
159- # Aucun seuil de score : mesuré, les populations se chevauchent (question
160- # légitime à 0,296, hors sujet à 0,329). Aucune valeur ne sépare, donc
161- # aucune n'est posée. Le refus tient par SYSTEME et le délimiteur.
162- try :
163- chunks = qdrant_store .search (
164- collection_name = settings .qdrant_collection ,
165- query = body .message ,
166- embedding_model = embedding_model ,
167- k = body .k ,
168- )
169- except (UnexpectedResponse , ResponseHandlingException ) as exc :
170- logger .error ("qdrant en erreur pendant la recherche : %s" , exc )
171- raise HTTPException (status_code = 502 , detail = "Base vectorielle en erreur." ) from exc
172- except Exception as exc :
173- logger .error ("ollama en erreur pendant l'embedding : %s" , exc )
174- raise HTTPException (status_code = 502 , detail = "Service d'embedding injoignable." ) from exc
175-
176- messages = [
177- {"role" : "system" , "content" : SYSTEME },
178- {"role" : "user" , "content" : construire_prompt_utilisateur (body .message , chunks )},
179- ]
180- ollama_client = _ollama (settings )
181-
182- def flux ():
183- # Un octet avant l'appel Ollama : deux visiteurs simultanés sont
184- # sérialisés (un seul slot GPU), et un silence de plus de ~100 s fait
185- # couper le tunnel Cloudflare en 524.
186- yield ": ping\n \n "
187- try :
188- for part in ollama_client .chat (
189- model = settings .chat_model , messages = messages , stream = True , options = OPTIONS_LLM
190- ):
191- token = part .message .content
192- if token :
193- yield f"data: { json .dumps ({'token' : token }, ensure_ascii = False )} \n \n "
194- except Exception as exc :
195- logger .error ("ollama en erreur pendant la generation : %s" , exc )
196- yield f"event: error\n data: { json .dumps ({'error' : 'generation interrompue' }, ensure_ascii = False )} \n \n "
197- return
198- sources = [
199- {"source" : c ["source" ], "titre" : c .get ("titre" ), "score" : c ["score" ]} for c in chunks
200- ]
201- yield f"event: sources\n data: { json .dumps ({'sources' : sources }, ensure_ascii = False )} \n \n "
202-
203- return StreamingResponse (
204- flux (),
205- media_type = "text/event-stream" ,
206- headers = {"Cache-Control" : "no-cache" , "X-Accel-Buffering" : "no" , "Connection" : "keep-alive" },
207- )
208-
209-
210140# --- /voice : boucle vocale WebSocket -----------------------------------
211141
212142_FIN_ITERATION = object ()
@@ -325,7 +255,10 @@ async def _gerer_session_vocale(websocket: WebSocket):
325255 await websocket .send_json ({"type" : "error" , "message" : "Aucune parole detectee." })
326256 return
327257
328- # --- RAG : memes garde-fous que /chat ---
258+ # --- RAG ---
259+ # Aucun seuil de score : mesuré, les populations se chevauchent (question
260+ # légitime à 0,296, hors sujet à 0,329). Aucune valeur ne sépare, donc
261+ # aucune n'est posée. Le refus tient par SYSTEME et le délimiteur.
329262 qdrant_store = QdrantVectorStore (host = settings .qdrant_host , port = settings .qdrant_port )
330263 embedding_model = EmbeddingModel (
331264 model_name = settings .embedding_model , host = settings .ollama_host , port = settings .ollama_port
@@ -343,8 +276,12 @@ async def _gerer_session_vocale(websocket: WebSocket):
343276
344277 try :
345278 chunks = await run_in_threadpool (
346- qdrant_store .search , settings .qdrant_collection , question , embedding_model , 8
279+ qdrant_store .search , settings .qdrant_collection , question , embedding_model , K_EXTRAITS
280+ )
281+ identite = await run_in_threadpool (
282+ qdrant_store .get_chunk , settings .qdrant_collection , IDENTITE_SOURCE , IDENTITE_TITRE
347283 )
284+ chunks = ajouter_identite (chunks , identite )
348285 except (UnexpectedResponse , ResponseHandlingException ) as exc :
349286 logger .error ("qdrant en erreur pendant la recherche : %s" , exc )
350287 await websocket .send_json ({"type" : "error" , "message" : "Base vectorielle en erreur." })
0 commit comments