Skip to content

Commit fb887a8

Browse files
authored
Merge pull request #6 from mathiscapart/mathiscapart/maj-corpus
Mise à jour du corpus, retrait de /chat et identité épinglée dans /voice
2 parents 45e463a + a2235f8 commit fb887a8

19 files changed

Lines changed: 665 additions & 472 deletions

‎.env.example‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
QDRANT_VERSION=v1.15.5
2-
QDRANT_HOST=localhost
2+
QDRANT_HOST=127.0.0.1
33
QDRANT_PORT=6333
44
QDRANT_COLLECTION=portfolio
55
OLLAMA_EMBEDDING_MODEL=qwen3-embedding:0.6b
66
PYTHON_VERSION=3.12
77
ROCM_PYTORCH_VERSION=x
8-
OLLAMA_HOST=localhost
8+
OLLAMA_HOST=127.0.0.1
99
OLLAMA_PORT=11434
1010
OLLAMA_CHAT_MODEL=qwen3:8b
1111
OLLAMA_TIMEOUT=60

‎CLAUDE.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,15 @@ se lancent par `deploy/start-natif.ps1` (logs dans `deploy/logs/`).
102102
- **`num_ctx` 8192 obligatoire** (`OPTIONS_LLM`) : par defaut Ollama prend 32768,
103103
`qwen3:8b` monte a 9,16 Go, deborde a cote du STT et tombe a 10,5 t/s.
104104
- **`think=False` sur `/voice`** : le raisonnement de qwen3 coutait 9,2 s de
105-
silence avant le premier token. `/chat` le garde (non recalibre).
105+
silence avant le premier token.
106+
- **Passage d'identite epingle** : `/voice` ajoute toujours la section
107+
`parcours.md > Mon parcours en bref` aux extraits (`ajouter_identite`). Sans
108+
elle, une question vague ne ramenait que GitHub et Piloti, et qwen3 inventait
109+
« developpeur full-stack » 5 fois sur 5. Renommer cette section impose de
110+
mettre a jour `IDENTITE_TITRE` (verrouille par `test_pertinence.py`).
111+
- **Pas d'endpoint texte en production** : `/chat` a ete retire de l'API (le
112+
front ne passe que par `/voice`). Pour interroger le RAG a l'ecrit :
113+
`python -m backend.tests.question_texte "question" --sources`.
106114
- Mesure publique (`wss://<domaine>/api/voice`) : premier token 1,3 s, premier
107115
son 2,3 a 2,6 s apres la fin de la question.
108116
- Une seule session vocale a la fois ; question plafonnee a 30 s, socket muet

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Windows, exposé par Traefik et un tunnel Cloudflare, sans API d'inférence exte
1212

1313
| Dossier | Contenu |
1414
|---|---|
15-
| `backend/api/` | API FastAPI : `/health`, `/chat` (SSE), `/voice` (WebSocket) |
15+
| `backend/api/` | API FastAPI : `/health`, `/voice` (WebSocket) |
1616
| `backend/stt/` | reconnaissance vocale Kyutai en streaming |
1717
| `backend/tts/` | service de synthèse vocale (voix clonée dans `voix/`, non versionné) |
1818
| `backend/rag/` | découpage, ingestion et recherche ; `corpus/` = le parcours |

‎backend/api/main.py‎

Lines changed: 33 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
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
86
Les erreurs renvoyées au client sont génériques : le détail part dans les logs,
97
pas dans la réponse d'un visiteur anonyme.
@@ -17,9 +15,7 @@
1715

1816
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
1917
from fastapi.middleware.cors import CORSMiddleware
20-
from fastapi.responses import StreamingResponse
2118
from ollama import Client
22-
from pydantic import BaseModel, Field, field_validator
2319
from qdrant_client.http.exceptions import ResponseHandlingException, UnexpectedResponse
2420
from starlette.concurrency import run_in_threadpool
2521
from starlette.websockets import WebSocketState
@@ -32,8 +28,8 @@
3228
import 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.
3733
try: # 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):
9288
app.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

117117
def _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\ndata: {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\ndata: {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."})

‎backend/api/requirements.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55
# Le reste des dependances vient de backend/rag/requirements.txt.
66
fastapi==0.141.1
77
uvicorn==0.46.0
8+
# Sans bibliotheque WebSocket, uvicorn refuse l'upgrade de /voice
9+
# ("No supported WebSocket library detected") : la boucle vocale est morte.
10+
websockets==17.1

‎backend/rag/corpus/competences.md‎

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,63 @@ source: competences.md
55

66
# Compétences
77

8-
## Cybersécurité
8+
## Intelligence artificielle
99

10-
SIEM, EDR et XDR, tests d'intrusion, audit web selon l'OWASP, cryptographie et
11-
PKI, gouvernance avec EBIOS RM et ISO 27001, réponse à incident et forensique.
10+
Modèles de langage (LLM), RAG, embeddings et bases vectorielles, agents IA et
11+
chatbots, déploiement de solutions d'IA en entreprise avec Ollama et n8n,
12+
PyTorch, synthèse et reconnaissance vocales, modèles vision-langage, traitement
13+
de données.
1214

13-
## Cloud et infrastructure
15+
## Cybersécurité
1416

15-
Microsoft 365, Azure, Entra ID, Active Directory hybride, VMware vSphere 8,
16-
Proxmox, XCP-ng et Traefik.
17+
SIEM, EDR et XDR, tests d'intrusion, audit web selon l'OWASP, cryptographie et
18+
PKI, gouvernance avec EBIOS RM et ISO 27001, réponse à incident et forensique,
19+
sécurité réseau.
1720

18-
## IA et automatisation
21+
## Cloud, systèmes et virtualisation
1922

20-
Modèles de langage, RAG, n8n, PyTorch, synthèse et reconnaissance vocales,
21-
modèles vision-langage, bases vectorielles et agents IA.
23+
Microsoft 365, Azure, Entra ID, Intune, SharePoint, Active Directory hybride et
24+
stratégies de groupe. Administration de serveurs Linux et Windows.
25+
Virtualisation avec VMware vSphere 8, Proxmox et XCP-ng. Traefik. GLPI pour la
26+
gestion du parc et des demandes.
2227

23-
## DevOps et observabilité
28+
## DevOps, orchestration et observabilité
2429

25-
Docker, Kubernetes, Ansible, CI/CD avec GitLab et GitHub, Prometheus, Grafana
26-
et la suite ELK.
30+
Docker, Kubernetes et k3s, Helm, Ansible, CI/CD et intégration continue avec
31+
GitLab, GitHub et Azure DevOps Server. Supervision et monitoring avec
32+
Prometheus, Grafana et la suite ELK. Automatisation des systèmes et des
33+
installations de systèmes d'exploitation.
2734

2835
## Réseau
2936

3037
Équipements Cisco, Aruba et Palo Alto.
3138

32-
## Scripting et bases de données
39+
## Scripting pour l'automatisation
40+
41+
Python, Bash et PowerShell, au service du DevOps, de l'infrastructure et de
42+
l'automatisation.
43+
44+
## Développement, sur mon temps personnel
45+
46+
Le développement n'est pas mon métier : je le pratique sur des projets
47+
personnels. J'y utilise Go, PHP, C, C++, React, Next.js, TypeScript, Laravel,
48+
Symfony et des API REST, ainsi que WordPress et PrestaShop.
49+
50+
## Bases de données
51+
52+
SQL, MySQL, PostgreSQL, SQLite, administration de bases de données avec
53+
phpMyAdmin.
54+
55+
## Automatisation des processus
56+
57+
Microsoft Power Automate et n8n pour automatiser les processus internes.
58+
59+
## Pilotage et accompagnement
3360

34-
Python, Bash, PowerShell et SQL.
61+
Gestion de projet, reporting, budget IT, management d'équipe en méthode agile,
62+
encadrement d'un alternant, formation des salariés, support et assistance aux
63+
utilisateurs, documentation.
3564

36-
## Pilotage
65+
## Évaluations LinkedIn réussies
3766

38-
Reporting, budget IT et management d'équipe en méthode agile.
67+
Python et développement front-end.

‎backend/rag/corpus/formation.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ source: formation.md
77

88
## Diplômes
99

10-
### École IT, Valenciennes, depuis 2022
10+
### SUPINFO, en distanciel, cinquième année
1111

12-
Cursus Architecte en système d'information, en cours : je suis en cinquième
13-
année, en alternance chez Doublet.
12+
Je suis en cinquième année d'informatique à SUPINFO, en distanciel, tout en
13+
restant en alternance chez Doublet. J'y ai rejoint ma cinquième année parce que
14+
l'École IT, où j'avais fait mes quatre premières années, a fait faillite.
15+
16+
### École IT, Valenciennes, de 2022 à 2026
17+
18+
Cursus Architecte en système d'information, suivi de la première à la quatrième
19+
année, en alternance chez Doublet. L'école a fait faillite, d'où ma poursuite
20+
d'études à SUPINFO.
1421

1522
### Baccalauréat général, Lycée Notre-Dame de Valenciennes, 2022
1623

0 commit comments

Comments
 (0)