ewannhugging commited on
Commit
bf61a7b
·
1 Parent(s): 50fe1f5

last change

Browse files
app.py CHANGED
@@ -16,6 +16,7 @@ Architecture:
16
  """
17
 
18
  import os
 
19
  import json
20
  import logging
21
  import time
@@ -37,6 +38,7 @@ from langchain_text_splitters import RecursiveCharacterTextSplitter
37
  from pypdf import PdfReader
38
 
39
  from sentence_transformers import CrossEncoder
 
40
 
41
  from llm import call_llm as call_llm_with_metrics
42
 
@@ -77,6 +79,28 @@ TOP_K_RERANKED = 2
77
  # Modèle cross-encoder multilingue (supporte le français)
78
  RERANKER_MODEL_NAME = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  # Settings loaded from data/config.json (runtime config).
81
  # Falls back to the root config.json which serves as the example/template.
82
  _CONFIG_PATH = DATA_DIR / "config.json"
@@ -145,6 +169,38 @@ if ENABLE_RERANKING:
145
  else:
146
  logger.info("Reranking disabled (ENABLE_RERANKING = False).")
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  # ---------------------------------------------------------------------------
149
  # Step 3: LLM Configuration (Azure Foundry GPT-5)
150
  # ---------------------------------------------------------------------------
@@ -257,49 +313,87 @@ def add_documents_to_vectorstore(documents: list[dict]) -> int:
257
  return len(documents)
258
 
259
 
 
 
 
 
 
260
  def retrieve_relevant_context(
261
  query: str,
262
  top_k: int = TOP_K_RETRIEVAL,
263
  enable_reranking: bool = ENABLE_RERANKING,
264
  top_k_reranked: int = TOP_K_RERANKED,
 
265
  ) -> list[dict]:
266
  """
267
  Retrieve the most relevant document chunks for a given query.
268
 
269
  Pipeline :
270
- 1. Recherche vectorielle : récupère top_k candidats
271
- 2. Si enable_reranking : score chaque paire (query, chunk) avec le cross-encoder
272
- et conserve les top_k_reranked meilleurs
 
273
  """
274
  if collection.count() == 0:
275
  return []
276
 
 
277
  query_embedding = generate_embeddings([query])[0]
 
278
 
279
- results = collection.query(
280
  query_embeddings=[query_embedding],
281
- n_results=min(top_k, collection.count()),
282
  include=["documents", "metadatas", "distances"],
283
  )
284
 
285
- contexts = []
286
- for i in range(len(results["documents"][0])):
287
- contexts.append({
288
- "text": results["documents"][0][i],
289
- "source": results["metadatas"][0][i]["source"],
290
- "similarity_score": 1 - results["distances"][0][i],
291
- })
 
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  if not enable_reranking or reranker is None:
294
  return contexts
295
 
296
- # Étape 2 : reranking avec le cross-encoder
297
- pairs = [[query, ctx["text"]] for ctx in contexts]
298
  rerank_scores = reranker.predict(pairs)
299
-
300
  for ctx, score in zip(contexts, rerank_scores):
301
  ctx["rerank_score"] = float(score)
302
-
303
  contexts.sort(key=lambda x: x["rerank_score"], reverse=True)
304
 
305
  return contexts[:top_k_reranked]
@@ -324,27 +418,43 @@ def build_rag_prompt(query: str, contexts: list[dict]) -> str:
324
 
325
  def rag_query(
326
  query: str,
327
- top_k: int = TOP_K_RETRIEVAL,
328
  *,
329
  temperature: float = LLM_TEMPERATURE,
330
  max_tokens: int = LLM_MAX_TOKENS,
331
  enable_reranking: bool = ENABLE_RERANKING,
332
- top_k_reranked: int = TOP_K_RERANKED,
 
 
333
  ) -> dict:
334
  """
335
  End-to-end RAG pipeline: Query → Retrieve → Generate.
336
 
337
- Tous les paramètres ont des valeurs par défaut issues de la configuration globale
338
- et peuvent être surchargés lors de l'appel (ex : sensitivity analysis).
339
  """
340
  start_time = time.perf_counter()
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  # Step 1: Retrieve relevant document chunks
343
  contexts = retrieve_relevant_context(
344
  query,
345
  top_k=top_k,
346
  enable_reranking=enable_reranking,
347
  top_k_reranked=top_k_reranked,
 
348
  )
349
 
350
  if not contexts:
@@ -370,7 +480,7 @@ def rag_query(
370
  prompt,
371
  endpoint_url=LLM_ENDPOINT_URL,
372
  api_key=AZURE_API_KEY,
373
- model=LLM_MODEL_NAME,
374
  max_completion_tokens=max_tokens,
375
  temperature=temperature,
376
  top_p=LLM_TOP_P,
@@ -400,6 +510,9 @@ def rag_query(
400
  "answer": answer,
401
  "sources": [{"source": ctx["source"], "score": ctx["similarity_score"], "ref_text": ctx["text"]} for ctx in contexts],
402
  "explanation": explanation,
 
 
 
403
  "total_token": total_token,
404
  "prompt_tokens": tokens["prompt"],
405
  "completion_tokens": tokens["completion"],
 
16
  """
17
 
18
  import os
19
+ import re
20
  import json
21
  import logging
22
  import time
 
38
  from pypdf import PdfReader
39
 
40
  from sentence_transformers import CrossEncoder
41
+ from rank_bm25 import BM25Okapi
42
 
43
  from llm import call_llm as call_llm_with_metrics
44
 
 
79
  # Modèle cross-encoder multilingue (supporte le français)
80
  RERANKER_MODEL_NAME = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
81
 
82
+ # ---------------------------------------------------------------------------
83
+ # BM25 configuration (Hybrid Search)
84
+ # ---------------------------------------------------------------------------
85
+ # Mettre à True pour activer la recherche hybride BM25 + vectorielle (Reciprocal Rank Fusion)
86
+ ENABLE_BM25 = True
87
+
88
+ # Constante RRF : valeur standard = 60 (plus k est grand, moins les rangs élevés sont pénalisés)
89
+ RRF_K = 60
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Configuration par type de question (auto-détectée à chaque requête)
93
+ # ---------------------------------------------------------------------------
94
+ # QCM : la réponse est l'une des options → peu de contexte suffit
95
+ QCM_TOP_K_RETRIEVAL = 10
96
+ QCM_TOP_K_RERANKED = 2
97
+ QCM_LLM_MODEL = "gpt-5.mini" # None = utiliser LLM_MODEL_NAME (défini après chargement config)
98
+
99
+ # OPEN : réponse développée → plus de contexte et modèle plus puissant si disponible
100
+ OPEN_TOP_K_RETRIEVAL = 20
101
+ OPEN_TOP_K_RERANKED = 6
102
+ OPEN_LLM_MODEL = "gpt-5.1" # None = utiliser LLM_MODEL_NAME
103
+
104
  # Settings loaded from data/config.json (runtime config).
105
  # Falls back to the root config.json which serves as the example/template.
106
  _CONFIG_PATH = DATA_DIR / "config.json"
 
169
  else:
170
  logger.info("Reranking disabled (ENABLE_RERANKING = False).")
171
 
172
+ # ---------------------------------------------------------------------------
173
+ # Step 2c: Build BM25 Index (Hybrid Search)
174
+ # ---------------------------------------------------------------------------
175
+ _bm25_index: Optional[BM25Okapi] = None
176
+ _bm25_ids: list[str] = []
177
+ _bm25_texts: list[str] = []
178
+ _bm25_sources: list[str] = []
179
+
180
+
181
+ def _build_bm25_index() -> None:
182
+ """Construit l'index BM25 à partir de tous les documents de ChromaDB."""
183
+ global _bm25_index, _bm25_ids, _bm25_texts, _bm25_sources
184
+ n = collection.count()
185
+ if n == 0:
186
+ logger.warning("BM25: collection vide, index non construit.")
187
+ return
188
+ logger.info(f"Construction de l'index BM25 ({n} documents)...")
189
+ t0 = time.perf_counter()
190
+ all_docs = collection.get(limit=n, include=["documents", "metadatas"])
191
+ _bm25_ids = all_docs["ids"]
192
+ _bm25_texts = all_docs["documents"]
193
+ _bm25_sources = [m["source"] for m in all_docs["metadatas"]]
194
+ tokenized = [re.sub(r"[^\w\s]", " ", txt.lower()).split() for txt in _bm25_texts]
195
+ _bm25_index = BM25Okapi(tokenized)
196
+ logger.info(f"Index BM25 prêt en {time.perf_counter() - t0:.1f}s.")
197
+
198
+
199
+ if ENABLE_BM25:
200
+ _build_bm25_index()
201
+ else:
202
+ logger.info("BM25 désactivé (ENABLE_BM25 = False).")
203
+
204
  # ---------------------------------------------------------------------------
205
  # Step 3: LLM Configuration (Azure Foundry GPT-5)
206
  # ---------------------------------------------------------------------------
 
313
  return len(documents)
314
 
315
 
316
+ def detect_qcm(query: str) -> bool:
317
+ """Retourne True si la question contient des options de type A) B) C) D)."""
318
+ return bool(re.search(r'\b[A-D][).]\s', query))
319
+
320
+
321
  def retrieve_relevant_context(
322
  query: str,
323
  top_k: int = TOP_K_RETRIEVAL,
324
  enable_reranking: bool = ENABLE_RERANKING,
325
  top_k_reranked: int = TOP_K_RERANKED,
326
+ enable_bm25: bool = ENABLE_BM25,
327
  ) -> list[dict]:
328
  """
329
  Retrieve the most relevant document chunks for a given query.
330
 
331
  Pipeline :
332
+ 1a. Recherche vectorielle dense (ChromaDB)
333
+ 1b. Recherche BM25 sparse (mots-clés exacts) — si enable_bm25
334
+ Fusion des deux via Reciprocal Rank Fusion (RRF)
335
+ 2. Reranking cross-encoder sur les candidats fusionnés — si enable_reranking
336
  """
337
  if collection.count() == 0:
338
  return []
339
 
340
+ # ── Étape 1a : recherche vectorielle ─────────────────────────────────────
341
  query_embedding = generate_embeddings([query])[0]
342
+ n_candidates = min(top_k, collection.count())
343
 
344
+ dense_results = collection.query(
345
  query_embeddings=[query_embedding],
346
+ n_results=n_candidates,
347
  include=["documents", "metadatas", "distances"],
348
  )
349
 
350
+ dense_ids = dense_results["ids"][0]
351
+ dense_id_to_ctx: dict[str, dict] = {}
352
+ for i, doc_id in enumerate(dense_ids):
353
+ dense_id_to_ctx[doc_id] = {
354
+ "text": dense_results["documents"][0][i],
355
+ "source": dense_results["metadatas"][0][i]["source"],
356
+ "similarity_score": 1 - dense_results["distances"][0][i],
357
+ }
358
 
359
+ if not enable_bm25 or _bm25_index is None:
360
+ contexts = list(dense_id_to_ctx.values())
361
+ else:
362
+ # ── Étape 1b : recherche BM25 ─────────────────────────────────────────
363
+ tokens = re.sub(r"[^\w\s]", " ", query.lower()).split()
364
+ bm25_scores = _bm25_index.get_scores(tokens)
365
+ bm25_top_idx = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:top_k]
366
+
367
+ bm25_id_to_ctx: dict[str, dict] = {}
368
+ for idx in bm25_top_idx:
369
+ doc_id = _bm25_ids[idx]
370
+ if doc_id not in dense_id_to_ctx:
371
+ bm25_id_to_ctx[doc_id] = {
372
+ "text": _bm25_texts[idx],
373
+ "source": _bm25_sources[idx],
374
+ "similarity_score": float(bm25_scores[idx]),
375
+ }
376
+
377
+ # ── Fusion RRF ────────────────────────────────────────────────────────
378
+ rrf_scores: dict[str, float] = {}
379
+ for rank, doc_id in enumerate(dense_ids):
380
+ rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + 1.0 / (RRF_K + rank + 1)
381
+ for rank, idx in enumerate(bm25_top_idx):
382
+ doc_id = _bm25_ids[idx]
383
+ rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + 1.0 / (RRF_K + rank + 1)
384
+
385
+ all_ctx = {**dense_id_to_ctx, **bm25_id_to_ctx}
386
+ sorted_ids = sorted(rrf_scores, key=lambda d: rrf_scores[d], reverse=True)[:top_k]
387
+ contexts = [all_ctx[d] for d in sorted_ids if d in all_ctx]
388
+
389
+ # ── Étape 2 : reranking cross-encoder ────────────────────────────────────
390
  if not enable_reranking or reranker is None:
391
  return contexts
392
 
393
+ pairs = [[query, ctx["text"]] for ctx in contexts]
 
394
  rerank_scores = reranker.predict(pairs)
 
395
  for ctx, score in zip(contexts, rerank_scores):
396
  ctx["rerank_score"] = float(score)
 
397
  contexts.sort(key=lambda x: x["rerank_score"], reverse=True)
398
 
399
  return contexts[:top_k_reranked]
 
418
 
419
  def rag_query(
420
  query: str,
421
+ top_k: Optional[int] = None, # None → auto-détecté selon le type de question
422
  *,
423
  temperature: float = LLM_TEMPERATURE,
424
  max_tokens: int = LLM_MAX_TOKENS,
425
  enable_reranking: bool = ENABLE_RERANKING,
426
+ top_k_reranked: Optional[int] = None, # None → auto-détecté selon le type de question
427
+ model: Optional[str] = None, # None → auto-détecté selon le type de question
428
+ enable_bm25: bool = ENABLE_BM25,
429
  ) -> dict:
430
  """
431
  End-to-end RAG pipeline: Query → Retrieve → Generate.
432
 
433
+ top_k, top_k_reranked et model sont auto-détectés selon le type de question (QCM vs OPEN)
434
+ si non fournis explicitement.
435
  """
436
  start_time = time.perf_counter()
437
 
438
+ # Auto-détection du type de question et application des paramètres adaptés
439
+ is_qcm = detect_qcm(query)
440
+ question_type = "QCM" if is_qcm else "OPEN"
441
+
442
+ if top_k is None:
443
+ top_k = QCM_TOP_K_RETRIEVAL if is_qcm else OPEN_TOP_K_RETRIEVAL
444
+ if top_k_reranked is None:
445
+ top_k_reranked = QCM_TOP_K_RERANKED if is_qcm else OPEN_TOP_K_RERANKED
446
+ if model is None:
447
+ model = (QCM_LLM_MODEL or LLM_MODEL_NAME) if is_qcm else (OPEN_LLM_MODEL or LLM_MODEL_NAME)
448
+
449
+ logger.info(f"Question type: {question_type} → top_k={top_k}, top_k_reranked={top_k_reranked}, model={model}")
450
+
451
  # Step 1: Retrieve relevant document chunks
452
  contexts = retrieve_relevant_context(
453
  query,
454
  top_k=top_k,
455
  enable_reranking=enable_reranking,
456
  top_k_reranked=top_k_reranked,
457
+ enable_bm25=enable_bm25,
458
  )
459
 
460
  if not contexts:
 
480
  prompt,
481
  endpoint_url=LLM_ENDPOINT_URL,
482
  api_key=AZURE_API_KEY,
483
+ model=model,
484
  max_completion_tokens=max_tokens,
485
  temperature=temperature,
486
  top_p=LLM_TOP_P,
 
510
  "answer": answer,
511
  "sources": [{"source": ctx["source"], "score": ctx["similarity_score"], "ref_text": ctx["text"]} for ctx in contexts],
512
  "explanation": explanation,
513
+ "question_type": question_type,
514
+ "model_used": model,
515
+ "top_k_reranked_used": top_k_reranked,
516
  "total_token": total_token,
517
  "prompt_tokens": tokens["prompt"],
518
  "completion_tokens": tokens["completion"],
config.json CHANGED
@@ -6,8 +6,8 @@
6
  "llm": {
7
  "endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/gpt-5.1/chat/completions?api-version=2024-12-01-preview",
8
  "model": "gpt-5.1",
9
- "max_completion_tokens": 512,
10
- "temperature": 0.7,
11
  "top_p": 0.95
12
  }
13
 
 
6
  "llm": {
7
  "endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/gpt-5.1/chat/completions?api-version=2024-12-01-preview",
8
  "model": "gpt-5.1",
9
+ "max_completion_tokens": 1024,
10
+ "temperature": 0.1,
11
  "top_p": 0.95
12
  }
13
 
create_sample_questions.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Script one-shot pour générer le fichier questions_sample.xlsx."""
2
+ import pandas as pd
3
+ from openpyxl.styles import Alignment, PatternFill, Font
4
+
5
+ questions = [
6
+ # ── QCM ─────────────────────────────────────────────────────────────────
7
+ {
8
+ "question": (
9
+ "Sous Solvabilité II, le SCR correspond à la Value-at-Risk (VaR) des fonds propres à :\n"
10
+ "A) 95% sur un horizon d'un mois\n"
11
+ "B) 99% sur un horizon d'un an\n"
12
+ "C) 99,5% sur un horizon d'un an\n"
13
+ "D) 95% sur un horizon de cinq ans"
14
+ ),
15
+ "correct_answer": "C",
16
+ "type": "QCM",
17
+ },
18
+ {
19
+ "question": (
20
+ "Sous IFRS 17, quel est le nom de la marge représentant le bénéfice non encore gagné sur un contrat ?\n"
21
+ "A) Risk Adjustment (RA)\n"
22
+ "B) Contractual Service Margin (CSM)\n"
23
+ "C) Best Estimate Liability (BEL)\n"
24
+ "D) Loss Component (LC)"
25
+ ),
26
+ "correct_answer": "B",
27
+ "type": "QCM",
28
+ },
29
+ {
30
+ "question": (
31
+ "Dans un traité de réassurance en excédent de plein, que couvre le réassureur ?\n"
32
+ "A) L'intégralité des sinistres\n"
33
+ "B) Les sinistres en dessous d'une franchise\n"
34
+ "C) La part du sinistre excédant la rétention de l'assureur\n"
35
+ "D) Uniquement les sinistres catastrophiques"
36
+ ),
37
+ "correct_answer": "C",
38
+ "type": "QCM",
39
+ },
40
+ {
41
+ "question": (
42
+ "Qu'est-ce que la méthode Chain-Ladder permet d'estimer ?\n"
43
+ "A) La probabilité de survenance d'un sinistre\n"
44
+ "B) Le développement ultime des sinistres (provisions IBNR)\n"
45
+ "C) Le tarif optimal d'un contrat d'assurance\n"
46
+ "D) La valeur en risque (VaR) d'un portefeuille"
47
+ ),
48
+ "correct_answer": "B",
49
+ "type": "QCM",
50
+ },
51
+ {
52
+ "question": (
53
+ "En assurance MRH, le risque de sécheresse est principalement lié à :\n"
54
+ "A) La submersion des caves par les eaux pluviales\n"
55
+ "B) Le retrait-gonflement des argiles causant des dommages aux fondations\n"
56
+ "C) Les tempêtes et vents violents\n"
57
+ "D) La grêle sur les toitures"
58
+ ),
59
+ "correct_answer": "B",
60
+ "type": "QCM",
61
+ },
62
+ {
63
+ "question": (
64
+ "En détection de fraude en assurance automobile, quelle approche est la plus adaptée aux données fortement déséquilibrées ?\n"
65
+ "A) La régression linéaire simple\n"
66
+ "B) L'analyse en composantes principales (ACP)\n"
67
+ "C) Les méthodes d'oversampling (SMOTE) combinées à des algorithmes d'ensemble\n"
68
+ "D) Le clustering K-means"
69
+ ),
70
+ "correct_answer": "C",
71
+ "type": "QCM",
72
+ },
73
+ {
74
+ "question": (
75
+ "En tarification automobile, pourquoi le Generalized Linear Model (GLM) est-il couramment utilisé ?\n"
76
+ "A) Il modélise fréquence et sévérité des sinistres avec des distributions adaptées (Poisson, Gamma)\n"
77
+ "B) Il ne nécessite aucune hypothèse sur la distribution des données\n"
78
+ "C) Il est systématiquement plus précis que tous les modèles de machine learning\n"
79
+ "D) Il permet de modéliser directement des données textuelles"
80
+ ),
81
+ "correct_answer": "A",
82
+ "type": "QCM",
83
+ },
84
+ {
85
+ "question": (
86
+ "Le ratio combiné en assurance non-vie est défini comme :\n"
87
+ "A) Primes acquises divisées par les sinistres payés\n"
88
+ "B) Somme des sinistres et des frais divisée par les primes acquises nettes\n"
89
+ "C) Résultat technique divisé par les primes émises\n"
90
+ "D) Provisions techniques divisées par les fonds propres"
91
+ ),
92
+ "correct_answer": "B",
93
+ "type": "QCM",
94
+ },
95
+ {
96
+ "question": (
97
+ "En assurance vie, la Value In Force (VIF) représente :\n"
98
+ "A) La valeur de marché des actifs du portefeuille\n"
99
+ "B) La valeur actualisée des profits futurs attendus sur le portefeuille en vigueur\n"
100
+ "C) Le taux de rendement interne du portefeuille\n"
101
+ "D) La provision mathématique brute"
102
+ ),
103
+ "correct_answer": "B",
104
+ "type": "QCM",
105
+ },
106
+ {
107
+ "question": (
108
+ "En assurance prévoyance collective, la garantie incapacité de travail couvre :\n"
109
+ "A) Le décès de l'assuré uniquement\n"
110
+ "B) L'invalidité permanente uniquement\n"
111
+ "C) L'arrêt de travail temporaire entraînant une perte de revenus\n"
112
+ "D) La perte d'emploi involontaire"
113
+ ),
114
+ "correct_answer": "C",
115
+ "type": "QCM",
116
+ },
117
+ {
118
+ "question": (
119
+ "La réassurance finite se caractérise principalement par :\n"
120
+ "A) Un transfert illimité du risque technique à l'identique de la réassurance classique\n"
121
+ "B) Un transfert limité du risque avec une forte composante de financement\n"
122
+ "C) La couverture exclusive des risques catastrophiques\n"
123
+ "D) L'absence totale de prime de réassurance"
124
+ ),
125
+ "correct_answer": "B",
126
+ "type": "QCM",
127
+ },
128
+ {
129
+ "question": (
130
+ "Concernant la sinistralité des véhicules électriques vs. thermiques, quelle affirmation est la plus souvent observée ?\n"
131
+ "A) Fréquence de sinistres matériels plus élevée, notamment en raison du poids des batteries\n"
132
+ "B) Sinistralité systématiquement inférieure sur toutes les garanties\n"
133
+ "C) Aucune différence statistiquement significative constatée\n"
134
+ "D) Sinistralité corporelle plus élevée mais matérielle identique"
135
+ ),
136
+ "correct_answer": "A",
137
+ "type": "QCM",
138
+ },
139
+ {
140
+ "question": (
141
+ "En modélisation du risque climatique, les scénarios RCP (Representative Concentration Pathways) permettent de :\n"
142
+ "A) Calculer automatiquement le tarif des contrats d'assurance climatique\n"
143
+ "B) Projeter l'évolution des émissions de gaz à effet de serre et leurs impacts sur le climat\n"
144
+ "C) Mesurer la solvabilité réglementaire des compagnies d'assurance\n"
145
+ "D) Définir les franchises applicables aux garanties climatiques"
146
+ ),
147
+ "correct_answer": "B",
148
+ "type": "QCM",
149
+ },
150
+ {
151
+ "question": (
152
+ "La méthode Bornhuetter-Ferguson pour le calcul des provisions IBNR consiste à :\n"
153
+ "A) Appliquer uniquement les coefficients de développement Chain-Ladder\n"
154
+ "B) Combiner la méthode Chain-Ladder avec une sinistralité a priori pour une estimation plus stable\n"
155
+ "C) Utiliser une régression sur les primes acquises\n"
156
+ "D) Modéliser la dépendance entre sinistres par une fonction copule"
157
+ ),
158
+ "correct_answer": "B",
159
+ "type": "QCM",
160
+ },
161
+ {
162
+ "question": (
163
+ "L'ajustement pour risque (Risk Adjustment) sous IFRS 17 représente :\n"
164
+ "A) La valeur actualisée des flux de trésorerie futurs\n"
165
+ "B) La rémunération exigée pour supporter l'incertitude sur les flux non financiers\n"
166
+ "C) La marge de service contractuelle (CSM)\n"
167
+ "D) Le coût du capital réglementaire Solvabilité II"
168
+ ),
169
+ "correct_answer": "B",
170
+ "type": "QCM",
171
+ },
172
+ {
173
+ "question": (
174
+ "En réassurance, la distinction IBNeR / IBNyR correspond à :\n"
175
+ "A) IBNeR = sinistres déclarés en cours de développement ; IBNyR = sinistres survenus non encore déclarés\n"
176
+ "B) IBNeR = sinistres graves ; IBNyR = sinistres fréquents\n"
177
+ "C) IBNeR = sinistres vie ; IBNyR = sinistres non-vie\n"
178
+ "D) IBNeR = sinistres annulés ; IBNyR = sinistres réouverts"
179
+ ),
180
+ "correct_answer": "A",
181
+ "type": "QCM",
182
+ },
183
+ {
184
+ "question": (
185
+ "Dans une optimisation de l'allocation stratégique d'actifs sous contrainte de SCR, l'objectif principal est :\n"
186
+ "A) Maximiser uniquement le rendement espéré sans contrainte\n"
187
+ "B) Maximiser le rendement tout en maintenant le SCR en dessous d'un seuil acceptable\n"
188
+ "C) Minimiser les provisions mathématiques\n"
189
+ "D) Réduire les actifs risqués à zéro pour maximiser le ratio de solvabilité"
190
+ ),
191
+ "correct_answer": "B",
192
+ "type": "QCM",
193
+ },
194
+ # ── QCM supplémentaires ──────────────────────────────────────────────────
195
+ {
196
+ "question": (
197
+ "En assurance MRH, le zonier grêle permet principalement de :\n"
198
+ "A) Calculer les provisions pour sinistres en attente\n"
199
+ "B) Différencier les primes selon l'exposition géographique au risque de grêle\n"
200
+ "C) Définir les franchises légales applicables aux catastrophes naturelles\n"
201
+ "D) Estimer la valeur de reconstruction des biens assurés"
202
+ ),
203
+ "correct_answer": "B",
204
+ "type": "QCM",
205
+ },
206
+ {
207
+ "question": (
208
+ "La méthode de Pareto généralisée est utilisée en réassurance pour :\n"
209
+ "A) Calculer les provisions mathématiques des contrats vie\n"
210
+ "B) Modéliser la queue de distribution des sinistres extrêmes\n"
211
+ "C) Tarifer les garanties de prévoyance collective\n"
212
+ "D) Estimer la fréquence des sinistres automobiles"
213
+ ),
214
+ "correct_answer": "B",
215
+ "type": "QCM",
216
+ },
217
+ {
218
+ "question": (
219
+ "Dans le cadre de la réforme 100% Santé, l'objectif principal est :\n"
220
+ "A) Supprimer les tickets modérateurs pour tous les actes médicaux\n"
221
+ "B) Permettre l'accès à des soins optiques, dentaires et auditifs sans reste à charge\n"
222
+ "C) Généraliser la complémentaire santé obligatoire dans les entreprises\n"
223
+ "D) Plafonner les cotisations des mutuelles santé individuelles"
224
+ ),
225
+ "correct_answer": "B",
226
+ "type": "QCM",
227
+ },
228
+ {
229
+ "question": (
230
+ "En prévoyance collective, le taux d'incidence en incapacité mesure :\n"
231
+ "A) Le pourcentage d'assurés en arrêt de travail à une date donnée\n"
232
+ "B) La probabilité pour un assuré valide d'entrer en incapacité sur une période\n"
233
+ "C) Le coût moyen d'un sinistre incapacité\n"
234
+ "D) La durée moyenne d'un arrêt de travail"
235
+ ),
236
+ "correct_answer": "B",
237
+ "type": "QCM",
238
+ },
239
+ {
240
+ "question": (
241
+ "La stratégie de collar dynamique en assurance vie consiste à :\n"
242
+ "A) Investir exclusivement dans des obligations souveraines\n"
243
+ "B) Combiner l'achat d'un put et la vente d'un call pour encadrer la valeur du portefeuille\n"
244
+ "C) Réduire progressivement la duration du portefeuille obligataire\n"
245
+ "D) Augmenter la part actions en période de hausse des taux"
246
+ ),
247
+ "correct_answer": "B",
248
+ "type": "QCM",
249
+ },
250
+ {
251
+ "question": (
252
+ "En gestion actif-passif (ALM) d'un assureur vie, l'immunisation consiste à :\n"
253
+ "A) Maximiser le rendement des actifs sans contrainte\n"
254
+ "B) Faire correspondre la duration des actifs à celle des passifs pour neutraliser le risque de taux\n"
255
+ "C) Investir uniquement dans des actifs sans risque de crédit\n"
256
+ "D) Minimiser le ratio de solvabilité pour optimiser le rendement"
257
+ ),
258
+ "correct_answer": "B",
259
+ "type": "QCM",
260
+ },
261
+ {
262
+ "question": (
263
+ "Dans la modélisation de la sévérité des sinistres MRH, quelle distribution est la plus souvent retenue pour les sinistres graves ?\n"
264
+ "A) La loi normale\n"
265
+ "B) La loi de Poisson\n"
266
+ "C) La loi de Pareto ou log-normale pour la queue épaisse\n"
267
+ "D) La loi uniforme"
268
+ ),
269
+ "correct_answer": "C",
270
+ "type": "QCM",
271
+ },
272
+ {
273
+ "question": (
274
+ "Les données DSN (Déclaration Sociale Nominative) apportent en prévoyance :\n"
275
+ "A) Des informations sur la sinistralité passée des entreprises\n"
276
+ "B) Des données individuelles sur les salaires et les arrêts de travail permettant d'améliorer la tarification\n"
277
+ "C) Les montants de provisions réglementaires à constituer\n"
278
+ "D) Les tarifs de référence fixés par les organismes paritaires"
279
+ ),
280
+ "correct_answer": "B",
281
+ "type": "QCM",
282
+ },
283
+ {
284
+ "question": (
285
+ "En assurance automobile, le véhiculier désigne :\n"
286
+ "A) Le fichier central des sinistres automobiles\n"
287
+ "B) La base de données regroupant les caractéristiques techniques des véhicules utilisée en tarification\n"
288
+ "C) Le réseau de garages agréés par l'assureur\n"
289
+ "D) L'outil de gestion des flottes d'entreprise"
290
+ ),
291
+ "correct_answer": "B",
292
+ "type": "QCM",
293
+ },
294
+ {
295
+ "question": (
296
+ "Le risque de transition climatique en assurance se réfère à :\n"
297
+ "A) Le risque lié aux événements météorologiques extrêmes\n"
298
+ "B) Les pertes financières issues du passage à une économie bas-carbone (régulation, dépréciation d'actifs)\n"
299
+ "C) Le risque de réassurance lié au changement de portefeuille\n"
300
+ "D) La migration des assurés vers d'autres compagnies"
301
+ ),
302
+ "correct_answer": "B",
303
+ "type": "QCM",
304
+ },
305
+ {
306
+ "question": (
307
+ "En assurance emprunteur, la garantie PTI (Perte Totale et Irréversible d'Autonomie) se déclenche lorsque :\n"
308
+ "A) L'assuré est en arrêt de travail depuis plus de 90 jours\n"
309
+ "B) L'assuré se trouve dans l'impossibilité définitive d'exercer toute activité et nécessite l'assistance d'une tierce personne\n"
310
+ "C) L'assuré atteint l'âge de la retraite\n"
311
+ "D) Le contrat de prêt arrive à échéance"
312
+ ),
313
+ "correct_answer": "B",
314
+ "type": "QCM",
315
+ },
316
+ {
317
+ "question": (
318
+ "La provision pour risques croissants (PRC) en assurance santé est constituée pour :\n"
319
+ "A) Couvrir les sinistres survenus mais non encore déclarés\n"
320
+ "B) Faire face à l'aggravation du risque morbidité avec l'âge sur des contrats à prime nivelée\n"
321
+ "C) Financer les frais de gestion futurs du portefeuille\n"
322
+ "D) Garantir le paiement des réassureurs en cas de défaut"
323
+ ),
324
+ "correct_answer": "B",
325
+ "type": "QCM",
326
+ },
327
+ {
328
+ "question": (
329
+ "Le modèle de Lee-Carter est principalement utilisé pour :\n"
330
+ "A) Tarifer les contrats d'assurance automobile\n"
331
+ "B) Projeter l'évolution future des taux de mortalité\n"
332
+ "C) Calculer les provisions IBNR en non-vie\n"
333
+ "D) Modéliser la fréquence des catastrophes naturelles"
334
+ ),
335
+ "correct_answer": "B",
336
+ "type": "QCM",
337
+ },
338
+ {
339
+ "question": (
340
+ "En Solvabilité II, le MCR (Minimum Capital Requirement) est :\n"
341
+ "A) Égal au SCR multiplié par un coefficient de 1,5\n"
342
+ "B) Le seuil minimal de fonds propres en dessous duquel l'autorité de contrôle intervient immédiatement\n"
343
+ "C) La valeur de marché des actifs du portefeuille\n"
344
+ "D) La provision best estimate augmentée du risk adjustment"
345
+ ),
346
+ "correct_answer": "B",
347
+ "type": "QCM",
348
+ },
349
+ {
350
+ "question": (
351
+ "La méthode d'agrégation du passif par Data Mining en prévoyance-santé collective vise à :\n"
352
+ "A) Détecter les fraudes dans les remboursements de soins\n"
353
+ "B) Regrouper les contrats en classes homogènes pour simplifier le calcul des provisions\n"
354
+ "C) Segmenter les assurés selon leur risque individuel pour la tarification\n"
355
+ "D) Optimiser le placement des actifs du fonds de prévoyance"
356
+ ),
357
+ "correct_answer": "B",
358
+ "type": "QCM",
359
+ },
360
+ {
361
+ "question": (
362
+ "En réassurance vie, l'introduction du risque de défaut du réassureur dans la tarification vise à :\n"
363
+ "A) Augmenter le taux de cession pour réduire la rétention\n"
364
+ "B) Ajuster la valeur des créances sur réassureurs en tenant compte de la probabilité de non-paiement\n"
365
+ "C) Définir les franchises applicables aux traités proportionnels\n"
366
+ "D) Calculer les provisions pour sinistres en attente"
367
+ ),
368
+ "correct_answer": "B",
369
+ "type": "QCM",
370
+ },
371
+ {
372
+ "question": (
373
+ "Le modèle de Cramér-Lundberg en théorie de la ruine permet d'estimer :\n"
374
+ "A) Le tarif optimal d'un contrat d'assurance non-vie\n"
375
+ "B) La probabilité de ruine d'une compagnie d'assurance sur un horizon infini\n"
376
+ "C) Le capital de solvabilité requis sous Solvabilité II\n"
377
+ "D) La valeur de rachat d'un contrat d'assurance vie"
378
+ ),
379
+ "correct_answer": "B",
380
+ "type": "QCM",
381
+ },
382
+ {
383
+ "question": (
384
+ "En tarification MRH, la modélisation du risque géographique (zonier) par méthode non paramétrique permet de :\n"
385
+ "A) Supprimer l'effet des variables individuelles dans le modèle\n"
386
+ "B) Capturer des effets spatiaux locaux sans imposer de structure fonctionnelle rigide\n"
387
+ "C) Réduire le nombre de variables explicatives dans le GLM\n"
388
+ "D) Standardiser les primes indépendamment de la localisation du bien"
389
+ ),
390
+ "correct_answer": "B",
391
+ "type": "QCM",
392
+ },
393
+ {
394
+ "question": (
395
+ "L'ORSA (Own Risk and Solvency Assessment) est :\n"
396
+ "A) Un ratio réglementaire calculé trimestriellement par l'ACPR\n"
397
+ "B) Une évaluation interne par l'assureur de ses risques et de ses besoins en capital dans le cadre de Solvabilité II\n"
398
+ "C) Un modèle standard de calcul du SCR imposé par la directive européenne\n"
399
+ "D) Un outil de tarification des risques de catastrophe naturelle"
400
+ ),
401
+ "correct_answer": "B",
402
+ "type": "QCM",
403
+ },
404
+ {
405
+ "question": (
406
+ "En assurance automobile, la stratégie de réparation en garages partenaires permet à l'assureur de :\n"
407
+ "A) Transférer intégralement le risque de coût des réparations au garage\n"
408
+ "B) Maîtriser les coûts de sinistres matériels via des accords tarifaires et un contrôle qualité\n"
409
+ "C) Supprimer la franchise applicable aux sinistres matériels\n"
410
+ "D) Augmenter la fréquence de sinistres pour fidéliser les assurés"
411
+ ),
412
+ "correct_answer": "B",
413
+ "type": "QCM",
414
+ },
415
+ # ── OPEN ─────────────────────────────────────────────────────────────────
416
+ {
417
+ "question": (
418
+ "Expliquez le rôle de la Contractual Service Margin (CSM) dans le cadre d'IFRS 17 "
419
+ "et comment elle participe à la reconnaissance du résultat dans le temps."
420
+ ),
421
+ "correct_answer": "CSM, bénéfice, amortissement, services, contrat",
422
+ "type": "OPEN",
423
+ },
424
+ {
425
+ "question": (
426
+ "Quels sont les principaux avantages et inconvénients des modèles de gradient boosting "
427
+ "(XGBoost, LightGBM) par rapport aux GLM classiques pour la tarification en assurance non-vie ?"
428
+ ),
429
+ "correct_answer": "interprétabilité, performance, surapprentissage, non-linéaire, GLM",
430
+ "type": "OPEN",
431
+ },
432
+ {
433
+ "question": (
434
+ "Décrivez les grandes étapes de la mise en place d'un ORSA climatique pour un assureur non-vie "
435
+ "et les principaux défis méthodologiques associés."
436
+ ),
437
+ "correct_answer": "scénario, stress test, risque physique, transition, horizon",
438
+ "type": "OPEN",
439
+ },
440
+ ]
441
+
442
+ df = pd.DataFrame(questions)
443
+ df.index = range(1, len(df) + 1)
444
+ df.index.name = "id"
445
+
446
+ output_path = "questions_sample.xlsx"
447
+ with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
448
+ df.to_excel(writer, sheet_name="Questions", index=True)
449
+ ws = writer.sheets["Questions"]
450
+
451
+ ws.column_dimensions["A"].width = 5
452
+ ws.column_dimensions["B"].width = 88
453
+ ws.column_dimensions["C"].width = 22
454
+ ws.column_dimensions["D"].width = 8
455
+
456
+ header_fill = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid")
457
+ header_font = Font(color="FFFFFF", bold=True)
458
+ for cell in ws[1]:
459
+ cell.fill = header_fill
460
+ cell.font = header_font
461
+ cell.alignment = Alignment(horizontal="center", vertical="center")
462
+
463
+ for row in ws.iter_rows(min_row=2):
464
+ for cell in row:
465
+ cell.alignment = Alignment(wrap_text=True, vertical="top")
466
+
467
+ for i in range(2, len(df) + 2):
468
+ ws.row_dimensions[i].height = 90
469
+
470
+ n_qcm = sum(1 for q in questions if q["type"] == "QCM")
471
+ n_open = sum(1 for q in questions if q["type"] == "OPEN")
472
+ print(f"Fichier cree : {output_path}")
473
+ print(f" QCM : {n_qcm}")
474
+ print(f" OPEN : {n_open}")
475
+ print(f" Total: {len(questions)}")
ingest_train_data.py CHANGED
@@ -21,11 +21,14 @@ import argparse
21
  import json
22
  import logging
23
  import os
 
24
  import sys
25
  import time
26
  from concurrent.futures import ThreadPoolExecutor, as_completed
27
  from pathlib import Path
28
 
 
 
29
  os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")
30
 
31
  import requests as http_requests
@@ -42,15 +45,26 @@ logger = logging.getLogger(__name__)
42
  # ──────────────────────────────────────────────────────────────────────────────
43
 
44
  PROJECT_ROOT = Path(__file__).parent
45
- DATA_DIR = Path("/data") if Path("/data").is_dir() else PROJECT_ROOT / "data"
46
  TRAIN_DATA_DIR = Path("/train_data") if Path("/train_data").is_dir() else PROJECT_ROOT / "train_data"
47
 
48
  CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
49
  COLLECTION_NAME = "rag_documents"
50
 
51
- # Chunking — mis à jour pour de meilleures performances RAG
52
- CHUNK_SIZE = 1000 # était 512
53
- CHUNK_OVERLAP = 150 # était 50
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  # Parallélisme
56
  EMBEDDING_BATCH_SIZE = 64 # chunks par requête API (était 16)
@@ -99,13 +113,127 @@ def extract_text_from_pdf(pdf_path: Path) -> str:
99
 
100
 
101
  def chunk_text(text: str, source: str) -> list[dict]:
 
 
 
 
 
 
102
  splitter = RecursiveCharacterTextSplitter(
103
  chunk_size=CHUNK_SIZE,
104
  chunk_overlap=CHUNK_OVERLAP,
105
  separators=["\n\n", "\n", ". ", " ", ""],
106
  )
107
  chunks = splitter.split_text(text)
108
- return [{"text": chunk, "source": source, "chunk_index": i} for i, chunk in enumerate(chunks)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  def generate_embeddings(texts: list[str]) -> list[list[float]]:
@@ -193,8 +321,14 @@ def main():
193
 
194
  t_start = time.perf_counter()
195
  logger.info(f"{'='*60}")
196
- logger.info(f"Fichiers trouvés : {len(files)}")
197
- logger.info(f"Chunk size / overlap: {CHUNK_SIZE} / {CHUNK_OVERLAP}")
 
 
 
 
 
 
198
  logger.info(f"Batch size embedding: {EMBEDDING_BATCH_SIZE}")
199
  logger.info(f"Workers extraction : {EXTRACTION_WORKERS}")
200
  logger.info(f"Workers embedding : {EMBEDDING_WORKERS}")
 
21
  import json
22
  import logging
23
  import os
24
+ import re
25
  import sys
26
  import time
27
  from concurrent.futures import ThreadPoolExecutor, as_completed
28
  from pathlib import Path
29
 
30
+ import numpy as np
31
+
32
  os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")
33
 
34
  import requests as http_requests
 
45
  # ──────────────────────────────────────────────────────────────────────────────
46
 
47
  PROJECT_ROOT = Path(__file__).parent
48
+ DATA_DIR = Path("/data_sementic") if Path("/data_sementic").is_dir() else PROJECT_ROOT / "data_sementic"
49
  TRAIN_DATA_DIR = Path("/train_data") if Path("/train_data").is_dir() else PROJECT_ROOT / "train_data"
50
 
51
  CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
52
  COLLECTION_NAME = "rag_documents"
53
 
54
+ # ── Méthode de chunking ────────────────────────────────────────────────────────
55
+ # "semantic" : coupe là où le sens change (recommandé, nécessite des appels embedding)
56
+ # "recursive" : coupe par taille fixe (fallback, sans appel API supplémentaire)
57
+ CHUNKING_METHOD = "semantic"
58
+
59
+ # Paramètres du semantic chunking
60
+ SEMANTIC_BREAKPOINT_PERCENTILE = 95 # coupe où la distance dépasse le 95e percentile
61
+ SEMANTIC_BUFFER_SIZE = 1 # phrases de contexte de chaque côté pour l'embedding
62
+ SEMANTIC_MIN_CHUNK_CHARS = 150 # fusionne les chunks trop petits
63
+ SEMANTIC_MAX_CHUNK_CHARS = 2000 # re-découpe les chunks trop grands
64
+
65
+ # Paramètres du chunking récursif (fallback ou méthode choisie)
66
+ CHUNK_SIZE = 1000
67
+ CHUNK_OVERLAP = 150
68
 
69
  # Parallélisme
70
  EMBEDDING_BATCH_SIZE = 64 # chunks par requête API (était 16)
 
113
 
114
 
115
  def chunk_text(text: str, source: str) -> list[dict]:
116
+ if CHUNKING_METHOD == "semantic":
117
+ return _semantic_chunk(text, source)
118
+ return _recursive_chunk(text, source)
119
+
120
+
121
+ def _recursive_chunk(text: str, source: str) -> list[dict]:
122
  splitter = RecursiveCharacterTextSplitter(
123
  chunk_size=CHUNK_SIZE,
124
  chunk_overlap=CHUNK_OVERLAP,
125
  separators=["\n\n", "\n", ". ", " ", ""],
126
  )
127
  chunks = splitter.split_text(text)
128
+ return [{"text": c, "source": source, "chunk_index": i} for i, c in enumerate(chunks)]
129
+
130
+
131
+ def _split_sentences(text: str) -> list[str]:
132
+ """Découpe le texte en phrases sur la ponctuation de fin."""
133
+ parts = re.split(r'(?<=[.!?])\s+', text.strip())
134
+ return [p.strip() for p in parts if len(p.strip()) > 10]
135
+
136
+
137
+ def _embed_in_batches(texts: list[str]) -> list[list[float]]:
138
+ """Embedding par batches avec retry, compatible avec les gros documents."""
139
+ all_embeddings: list = []
140
+ for i in range(0, len(texts), EMBEDDING_BATCH_SIZE):
141
+ batch = texts[i : i + EMBEDDING_BATCH_SIZE]
142
+ for attempt in range(1, EMBEDDING_MAX_RETRIES + 1):
143
+ try:
144
+ all_embeddings.extend(generate_embeddings(batch))
145
+ break
146
+ except Exception as e:
147
+ if attempt == EMBEDDING_MAX_RETRIES:
148
+ raise
149
+ time.sleep(EMBEDDING_RETRY_DELAY)
150
+ return all_embeddings
151
+
152
+
153
+ def _cosine_distance(a: list[float], b: list[float]) -> float:
154
+ va, vb = np.array(a, dtype=np.float32), np.array(b, dtype=np.float32)
155
+ return 1.0 - float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb) + 1e-10))
156
+
157
+
158
+ def _enforce_size_limits(chunks: list[str]) -> list[str]:
159
+ """Fusionne les chunks trop courts, re-découpe les trop longs."""
160
+ # Fusion des chunks sous le minimum
161
+ merged: list[str] = []
162
+ buf = ""
163
+ for chunk in chunks:
164
+ if buf and len(buf) + len(chunk) < SEMANTIC_MIN_CHUNK_CHARS:
165
+ buf += " " + chunk
166
+ else:
167
+ if buf:
168
+ merged.append(buf)
169
+ buf = chunk
170
+ if buf:
171
+ merged.append(buf)
172
+
173
+ # Re-découpage des chunks dépassant le maximum
174
+ splitter = RecursiveCharacterTextSplitter(
175
+ chunk_size=SEMANTIC_MAX_CHUNK_CHARS,
176
+ chunk_overlap=50,
177
+ separators=["\n\n", "\n", ". ", " ", ""],
178
+ )
179
+ result: list[str] = []
180
+ for chunk in merged:
181
+ if len(chunk) > SEMANTIC_MAX_CHUNK_CHARS:
182
+ result.extend(splitter.split_text(chunk))
183
+ else:
184
+ result.append(chunk)
185
+ return result
186
+
187
+
188
+ def _semantic_chunk(text: str, source: str) -> list[dict]:
189
+ """
190
+ Semantic chunking :
191
+ 1. Découpe en phrases
192
+ 2. Crée des fenêtres de contexte (buffer de phrases adjacentes)
193
+ 3. Embed les fenêtres pour mesurer les ruptures sémantiques
194
+ 4. Coupe aux distances > percentile de seuil
195
+ """
196
+ sentences = _split_sentences(text)
197
+ if len(sentences) <= 2:
198
+ return _recursive_chunk(text, source)
199
+
200
+ # Fenêtres : chaque phrase + BUFFER phrases de contexte de chaque côté
201
+ windows = []
202
+ for i in range(len(sentences)):
203
+ start = max(0, i - SEMANTIC_BUFFER_SIZE)
204
+ end = min(len(sentences), i + SEMANTIC_BUFFER_SIZE + 1)
205
+ windows.append(" ".join(sentences[start:end]))
206
+
207
+ try:
208
+ embeddings = _embed_in_batches(windows)
209
+ except Exception as e:
210
+ logger.warning(f"Semantic chunking échoué pour {source} → fallback récursif : {e}")
211
+ return _recursive_chunk(text, source)
212
+
213
+ # Distance cosine entre fenêtres consécutives
214
+ distances = [
215
+ _cosine_distance(embeddings[i], embeddings[i + 1])
216
+ for i in range(len(embeddings) - 1)
217
+ ]
218
+
219
+ # Points de coupure au-dessus du seuil percentile
220
+ threshold = float(np.percentile(distances, SEMANTIC_BREAKPOINT_PERCENTILE))
221
+ breakpoints = {i + 1 for i, d in enumerate(distances) if d > threshold}
222
+
223
+ # Regroupement des phrases en chunks
224
+ raw_chunks: list[str] = []
225
+ current: list[str] = []
226
+ for i, sentence in enumerate(sentences):
227
+ if i in breakpoints and current:
228
+ raw_chunks.append(" ".join(current))
229
+ current = [sentence]
230
+ else:
231
+ current.append(sentence)
232
+ if current:
233
+ raw_chunks.append(" ".join(current))
234
+
235
+ final_chunks = _enforce_size_limits(raw_chunks)
236
+ return [{"text": c, "source": source, "chunk_index": i} for i, c in enumerate(final_chunks)]
237
 
238
 
239
  def generate_embeddings(texts: list[str]) -> list[list[float]]:
 
321
 
322
  t_start = time.perf_counter()
323
  logger.info(f"{'='*60}")
324
+ logger.info(f"Fichiers trouvés : {len(files)}")
325
+ logger.info(f"Méthode de chunking : {CHUNKING_METHOD.upper()}")
326
+ if CHUNKING_METHOD == "semantic":
327
+ logger.info(f" Seuil percentile : {SEMANTIC_BREAKPOINT_PERCENTILE}")
328
+ logger.info(f" Buffer phrases : {SEMANTIC_BUFFER_SIZE}")
329
+ logger.info(f" Taille min/max : {SEMANTIC_MIN_CHUNK_CHARS} / {SEMANTIC_MAX_CHUNK_CHARS} chars")
330
+ else:
331
+ logger.info(f" Chunk size/overlap: {CHUNK_SIZE} / {CHUNK_OVERLAP}")
332
  logger.info(f"Batch size embedding: {EMBEDDING_BATCH_SIZE}")
333
  logger.info(f"Workers extraction : {EXTRACTION_WORKERS}")
334
  logger.info(f"Workers embedding : {EMBEDDING_WORKERS}")
prompts/rag_prompt.txt CHANGED
@@ -1,12 +1,19 @@
1
- You are a helpful assistant. Answer the user's question based ONLY on the provided context.
2
- If the context does not contain enough information to answer, say "I don't have enough information to answer this question."
3
- Always be concise and factual.
4
 
5
- You MUST respond in valid JSON with exactly two fields:
6
- - "answer": your concise factual answer to the question
7
- - "explanation": a brief explanation of your reasoning, describing which parts of the context you used and why they led to your answer
8
 
9
- Do NOT include any text outside the JSON object.
 
 
 
 
 
 
 
 
10
 
11
  Context:
12
  {context}
 
1
+ # C — Context
2
+ Expert actuarial AI. Source: corpus of actuarial dissertations (mémoires d'actuariat).
 
3
 
4
+ # O — Objective
5
+ Answer using ONLY the provided context. Synthesize and reason across all relevant excerpts.
6
+ Only if the context truly contains no relevant information, output: "I don't have enough information to answer this question."
7
 
8
+ # S/T — Style & Tone
9
+ Senior actuary: formal, precise, no filler.
10
+
11
+ # A — Audience
12
+ Actuaries and risk managers. High technical level required.
13
+
14
+ # R — Response
15
+ Valid JSON only, no markdown fences:
16
+ {{"answer": "...", "explanation": "..."}}
17
 
18
  Context:
19
  {context}
requirements.txt CHANGED
@@ -13,3 +13,7 @@ python-multipart==0.0.9
13
  pydantic==2.9.0
14
  requests>=2.31.0
15
  ecologits>=0.5.0
 
 
 
 
 
13
  pydantic==2.9.0
14
  requests>=2.31.0
15
  ecologits>=0.5.0
16
+ sentence-transformers>=3.0.0
17
+ rank_bm25>=0.2.2
18
+ pandas>=2.0.0
19
+ openpyxl>=3.1.0
sensitivity_analysis.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sensitivity Analysis for RAG Parameters
3
+ ========================================
4
+
5
+ Teste l'accuracy du RAG sur différentes valeurs de paramètres, une à la fois,
6
+ en gardant les autres paramètres à la valeur baseline.
7
+
8
+ Usage:
9
+ python sensitivity_analysis.py --input questions.xlsx
10
+ python sensitivity_analysis.py --input questions.xlsx --output results.xlsx
11
+ python sensitivity_analysis.py --input questions.xlsx --max-questions 20 # test rapide
12
+
13
+ Format du fichier Excel d'entrée (colonnes requises):
14
+ question : texte de la question
15
+ correct_answer : lettre attendue (A/B/C/D) pour les QCM, mots-clés séparés par des virgules pour les questions ouvertes
16
+ type : "QCM" ou "OPEN" (optionnel, auto-détecté si absent)
17
+ """
18
+
19
+ import argparse
20
+ import re
21
+ import time
22
+ import logging
23
+ from pathlib import Path
24
+
25
+ import pandas as pd
26
+
27
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # ──────────────────────────────────────────────────────────────────────────────
31
+ # Grille de sensibilité — modifier ces valeurs pour changer les tests
32
+ # ──────────────────────────────────────────────────────────────────────────────
33
+
34
+ # Configuration de référence (première valeur de chaque liste)
35
+ BASELINE = {
36
+ "temperature": 0.1,
37
+ "max_tokens": 1024,
38
+ "enable_reranking": True,
39
+ "top_k_retrieval": 20,
40
+ "top_k_reranked": 5,
41
+ "enable_bm25": True,
42
+ }
43
+
44
+ # Pour chaque paramètre, liste des valeurs à tester
45
+ # La valeur baseline ne sera pas re-testée séparément (elle est déjà dans le run "baseline")
46
+ SENSITIVITY_GRID = {
47
+ "temperature": [0.05, 0.1, 0.3, 0.7, 1.5],
48
+ "max_tokens": [512, 1024, 2048],
49
+ "enable_reranking": [False, True],
50
+ "top_k_retrieval": [5, 10, 15, 20],
51
+ "top_k_reranked": [1,2,3,5,7,10],
52
+ "enable_bm25": [False, True],
53
+ }
54
+
55
+ DELAY_BETWEEN_CALLS = 0.3 # secondes entre chaque appel API (évite le rate limiting)
56
+
57
+
58
+ # ──────────────────────────────────────────────────────────────────────────────
59
+ # Helpers pour évaluer les réponses
60
+ # ──────────────────────────────────────────────────────────────────────────────
61
+
62
+ def detect_qcm(question: str) -> bool:
63
+ """Détecte si une question est un QCM en cherchant des options A), B), C)..."""
64
+ return bool(re.search(r'\b[A-D][).]\s', question))
65
+
66
+
67
+ def extract_letter(text: str) -> str:
68
+ """Extrait la lettre de réponse (A/B/C/D) depuis le texte de réponse du LLM."""
69
+ if not text:
70
+ return ""
71
+ u = text.strip().upper()
72
+ if u in ("A", "B", "C", "D"):
73
+ return u
74
+ # Lettre en début : "A.", "A)", "A:"
75
+ m = re.match(r'^([A-D])[.)\s:,]', u)
76
+ if m:
77
+ return m.group(1)
78
+ # "la réponse est A", "réponse: A", "answer: A"
79
+ m = re.search(r'(?:RÉPONSE|ANSWER|OPTION|CORRECT|LETTRE|CHOIX)\s*[:\s]\s*([A-D])\b', u)
80
+ if m:
81
+ return m.group(1)
82
+ # Premier token standalone trouvé
83
+ m = re.search(r'\b([A-D])\b', u)
84
+ return m.group(1) if m else ""
85
+
86
+
87
+ def check_answer(got: str, expected: str, q_type: str) -> bool:
88
+ """
89
+ Retourne True si la réponse est correcte.
90
+ QCM : compare la lettre extraite à la lettre attendue.
91
+ OPEN : vérifie que tous les mots-clés attendus (séparés par virgule) apparaissent dans la réponse.
92
+ """
93
+ if q_type == "QCM":
94
+ return extract_letter(got) == expected.strip().upper()
95
+ # OPEN : matching par mots-clés
96
+ keywords = [k.strip().lower() for k in expected.split(",") if k.strip()]
97
+ if not keywords:
98
+ return False # pas de mots-clés → révision manuelle nécessaire
99
+ return all(k in got.lower() for k in keywords)
100
+
101
+
102
+ # ──────────────────────────────────────────────────────────────────────────────
103
+ # Exécution d'un experiment (une config × toutes les questions)
104
+ # ──────────────────────────────────────────────────────────────────────────────
105
+
106
+ def run_experiment(df: pd.DataFrame, config: dict) -> pd.DataFrame:
107
+ from app import rag_query # noqa: PLC0415 — import tardif voulu (init ChromaDB + reranker)
108
+
109
+ rows = []
110
+ n = len(df)
111
+ for i, row in enumerate(df.itertuples(index=False), 1):
112
+ question = str(row.question)
113
+ expected = str(row.correct_answer)
114
+ q_type = str(row.type).upper() if hasattr(row, "type") else (
115
+ "QCM" if detect_qcm(question) else "OPEN"
116
+ )
117
+
118
+ logger.info(f" [{i}/{n}] {question[:70]}...")
119
+ t0 = time.perf_counter()
120
+ try:
121
+ result = rag_query(
122
+ question,
123
+ top_k=config["top_k_retrieval"],
124
+ temperature=config["temperature"],
125
+ max_tokens=config["max_tokens"],
126
+ enable_reranking=config["enable_reranking"],
127
+ top_k_reranked=config["top_k_reranked"],
128
+ enable_bm25=config["enable_bm25"],
129
+ )
130
+ answer = result.get("answer", "")
131
+ correct = check_answer(answer, expected, q_type)
132
+ total_token = result.get("total_token", 0) or 0
133
+ co2_grams = result.get("co2_grams")
134
+ error = ""
135
+ except Exception as e:
136
+ answer = ""
137
+ correct = False
138
+ total_token = 0
139
+ co2_grams = None
140
+ error = str(e)
141
+ logger.error(f" Erreur sur la question {i}: {e}")
142
+
143
+ elapsed_ms = round((time.perf_counter() - t0) * 1000)
144
+ rows.append({
145
+ "question": question[:150],
146
+ "type": q_type,
147
+ "expected": expected,
148
+ "got": answer[:300] if answer else "",
149
+ "got_letter": extract_letter(answer) if q_type == "QCM" else "-",
150
+ "correct": correct,
151
+ "time_ms": elapsed_ms,
152
+ "total_token": total_token,
153
+ "co2_grams": co2_grams,
154
+ "error": error,
155
+ })
156
+ time.sleep(DELAY_BETWEEN_CALLS)
157
+
158
+ return pd.DataFrame(rows)
159
+
160
+
161
+ # ──────────────────────────────────────────────────────────────────────────────
162
+ # Calcul des métriques d'un experiment
163
+ # ──────────────────────────────────────────────────────────────────────────────
164
+
165
+ def compute_metrics(detail_df: pd.DataFrame) -> dict:
166
+ qcm_mask = detail_df["type"] == "QCM"
167
+ overall = detail_df["correct"].mean() * 100
168
+ qcm_acc = detail_df.loc[qcm_mask, "correct"].mean() * 100 if qcm_mask.any() else float("nan")
169
+ open_acc = detail_df.loc[~qcm_mask, "correct"].mean() * 100 if (~qcm_mask).any() else float("nan")
170
+
171
+ total_tokens = int(detail_df["total_token"].sum())
172
+ avg_tokens = round(detail_df["total_token"].mean(), 1)
173
+
174
+ co2_values = detail_df["co2_grams"].dropna()
175
+ total_co2 = round(co2_values.sum(), 6) if not co2_values.empty else "n/a"
176
+ avg_co2 = round(co2_values.mean(), 6) if not co2_values.empty else "n/a"
177
+
178
+ return {
179
+ "accuracy_overall_%": round(overall, 1),
180
+ "accuracy_qcm_%": round(qcm_acc, 1) if not pd.isna(qcm_acc) else "n/a",
181
+ "accuracy_open_%": round(open_acc, 1) if not pd.isna(open_acc) else "n/a",
182
+ "correct": int(detail_df["correct"].sum()),
183
+ "total": len(detail_df),
184
+ "avg_time_ms": int(detail_df["time_ms"].mean()),
185
+ "total_tokens": total_tokens,
186
+ "avg_tokens_q": avg_tokens,
187
+ "total_co2_g": total_co2,
188
+ "avg_co2_g_q": avg_co2,
189
+ }
190
+
191
+
192
+ # ──────────────────────────────────────────────────────────────────────────────
193
+ # Main
194
+ # ──────────────────────────────────────────────────────────────────────────────
195
+
196
+ def main():
197
+ parser = argparse.ArgumentParser(description="RAG Sensitivity Analysis")
198
+ parser.add_argument("--input", required=True, help="Fichier Excel d'entrée")
199
+ parser.add_argument("--output", default="sensitivity_results.xlsx", help="Fichier Excel de sortie")
200
+ parser.add_argument("--sheet", default=0, help="Nom ou index de la feuille d'entrée")
201
+ parser.add_argument("--max-questions", type=int, default=None, help="Limite le nombre de questions (test rapide)")
202
+ parser.add_argument("--baseline-only", action="store_true", help="Exécute uniquement la configuration baseline (pas de variations)")
203
+ args = parser.parse_args()
204
+
205
+ # ── Lecture de l'Excel d'entrée ─────��────────────────────────────────────
206
+ df = pd.read_excel(args.input, sheet_name=args.sheet)
207
+ df.columns = df.columns.str.lower().str.strip()
208
+
209
+ required_cols = {"question", "correct_answer"}
210
+ missing = required_cols - set(df.columns)
211
+ if missing:
212
+ raise ValueError(f"Colonnes manquantes dans l'Excel : {missing}")
213
+
214
+ if "type" not in df.columns:
215
+ df["type"] = df["question"].apply(lambda q: "QCM" if detect_qcm(str(q)) else "OPEN")
216
+
217
+ if args.max_questions:
218
+ df = df.head(args.max_questions)
219
+ logger.info(f"Mode test rapide : {args.max_questions} questions seulement.")
220
+
221
+ counts = df["type"].value_counts().to_dict()
222
+ logger.info(f"{len(df)} questions chargées — {counts}")
223
+
224
+ # ── Construction de la liste des experiments ─────────────────────────────
225
+ # Format : (nom_param, valeur_affichée, config_complète)
226
+ experiments = [("baseline", "baseline", BASELINE)]
227
+
228
+ if not args.baseline_only:
229
+ for param, values in SENSITIVITY_GRID.items():
230
+ for v in values:
231
+ if v == BASELINE.get(param):
232
+ continue # déjà couvert par le baseline
233
+ config = {**BASELINE, param: v}
234
+ # Comparaison équitable pour enable_reranking=False :
235
+ # on envoie le même nombre de chunks au LLM qu'avec reranking
236
+ if param == "enable_reranking" and v is False:
237
+ config["top_k_retrieval"] = BASELINE["top_k_reranked"]
238
+ experiments.append((param, str(v), config))
239
+
240
+ total = len(experiments)
241
+ estimated_min = round(total * len(df) * (1 + DELAY_BETWEEN_CALLS) / 60, 1)
242
+ logger.info(f"{total} experiments × {len(df)} questions ≈ {estimated_min} min estimées")
243
+
244
+ # ── Exécution : collecte tous les résultats en mémoire ───────────────────
245
+ # On collecte d'abord TOUT, puis on écrit l'Excel une seule fois.
246
+ # Ainsi un crash en cours ne laisse pas un fichier Excel vide/corrompu.
247
+ summary_rows = []
248
+ detail_sheets: dict[str, pd.DataFrame] = {}
249
+
250
+ for idx, (param, value, config) in enumerate(experiments, 1):
251
+ label = "baseline" if param == "baseline" else f"{param}={value}"
252
+ logger.info(f"\n[{idx}/{total}] {label} | config={config}")
253
+
254
+ try:
255
+ detail_df = run_experiment(df, config)
256
+ metrics = compute_metrics(detail_df)
257
+ except Exception as e:
258
+ logger.error(f" Experiment échoué ({label}): {e}")
259
+ continue
260
+
261
+ summary_rows.append({
262
+ "parameter": param,
263
+ "value": value,
264
+ **metrics,
265
+ **{f"cfg_{k}": v for k, v in config.items()},
266
+ })
267
+ detail_sheets[label[:31]] = detail_df
268
+ logger.info(f" → accuracy={metrics['accuracy_overall_%']}% ({metrics['correct']}/{metrics['total']})")
269
+
270
+ if not summary_rows:
271
+ logger.error("Aucun experiment n'a abouti. Vérifiez les dépendances (pip install -r requirements.txt).")
272
+ return
273
+
274
+ # ── Écriture de l'Excel ───────────────────────────────────────────────────
275
+ summary_df = pd.DataFrame(summary_rows)
276
+
277
+ with pd.ExcelWriter(args.output, engine="openpyxl") as writer:
278
+ # Summary en premier
279
+ summary_df.to_excel(writer, sheet_name="Summary", index=False)
280
+ # Détails par experiment
281
+ for sheet_name, detail_df in detail_sheets.items():
282
+ detail_df.to_excel(writer, sheet_name=sheet_name, index=False)
283
+
284
+ logger.info(f"\nRésultats sauvegardés dans : {args.output}")
285
+ logger.info("\n" + summary_df[
286
+ ["parameter", "value", "accuracy_overall_%", "accuracy_qcm_%", "correct", "total"]
287
+ ].to_string(index=False))
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()