FabIndy commited on
Commit
b4a2740
·
1 Parent(s): 6a852cd

Fix LIST retrieval with robust FAISS fallback and lexical normalization

Browse files
Files changed (1) hide show
  1. src/rag_core.py +85 -97
src/rag_core.py CHANGED
@@ -3,9 +3,9 @@
3
 
4
  """
5
  rag_core.py – Modes :
6
- - LIST : rapide (FAISS, pas de LLM) — corrigé : score + seuil + garde lexical + refus si non pertinent
7
  - FULLTEXT : rapide (texte exact depuis JSONL, pas de LLM)
8
- - EXPLAIN : rapide -> en réalité une SYNTHÈSE extractive (text mining), pas une explication
9
  - QA : présent, mais accéléré (moins de garde-fous, avertissement utilisateur)
10
 
11
  Notes produit :
@@ -34,16 +34,21 @@ EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
34
  SNIPPET_CHARS = 260
35
 
36
  # --- LIST (FIABILITÉ) ---
37
- # On récupère large puis on filtre : score + lexical + dédup
38
  LIST_K = int(os.environ.get("LIST_K", "30"))
39
  LIST_MAX_ARTICLES = int(os.environ.get("LIST_MAX_ARTICLES", "8"))
40
- # NOTE: Avec FAISS LangChain, le "score" est généralement une distance (plus petit = meilleur).
41
- # À ajuster sur ton corpus. 0.45–0.75 sont des valeurs usuelles selon l’index.
42
- LIST_SCORE_THRESHOLD = float(os.environ.get("LIST_SCORE_THRESHOLD", "0.60"))
43
- # Garde lexical : au moins 1 mot-clé significatif doit apparaître dans le doc
 
 
44
  LIST_REQUIRE_LEXICAL_MATCH = os.environ.get("LIST_REQUIRE_LEXICAL_MATCH", "1") == "1"
45
  LIST_MIN_KEYWORDS = int(os.environ.get("LIST_MIN_KEYWORDS", "1"))
46
 
 
 
 
 
47
  # --- EXPLAIN (synthèse extractive) ---
48
  EXTRACT_MAX_SEGMENTS = 5
49
  EXTRACT_MAX_CHARS_TOTAL = 900
@@ -51,10 +56,10 @@ EXTRACT_MIN_SEG_LEN = 30
51
  EXTRACT_MAX_SEG_LEN = 420
52
 
53
  # --- QA : accélération ---
54
- QA_TOP_K_FINAL = int(os.environ.get("QA_TOP_K_FINAL", "2")) # 1 ou 2 conseillé sur CPU
55
- QA_DOC_MAX_CHARS = int(os.environ.get("QA_DOC_MAX_CHARS", "700")) # tronque le contexte envoyé au LLM
56
- QA_MAX_TOKENS = int(os.environ.get("QA_MAX_TOKENS", "140")) # court
57
- QA_TEMPERATURE = float(os.environ.get("QA_TEMPERATURE", "0.1")) # stable
58
 
59
  ARTICLE_ID_RE = re.compile(
60
  r"\b(?:article\s+)?([LDR]\s?\d{1,4}(?:[.-]\d+){0,4})\b",
@@ -67,12 +72,6 @@ EXPLAIN_TRIGGERS = [
67
  "extraits", "extrait", "résumé extractif", "resume extractif",
68
  ]
69
 
70
- EXPLAINISH_WORDS = [
71
- "explique", "expliquer", "explication",
72
- "résume", "resume", "résumé", "reformule", "simplifie",
73
- "en termes simples", "vulgarise", "clarifie",
74
- ]
75
-
76
  LIST_TRIGGERS = [
77
  "quels articles", "quelles dispositions", "articles parlent",
78
  "articles qui parlent", "articles sur", "donne les articles",
@@ -100,7 +99,7 @@ _QA_WARNING = (
100
 
101
 
102
  # ==================== LLM INIT ====================
103
- # n_ctx réduit pour accélérer QA sur CPU.
104
  llm = Llama(
105
  model_path="models/model.gguf",
106
  n_ctx=1024,
@@ -130,11 +129,6 @@ def extract_article_id(q: str) -> Optional[str]:
130
  return normalize_article_id(m.group(1)) if m else None
131
 
132
 
133
- def safe_snippet(text: str, n: int) -> str:
134
- t = " ".join((text or "").split())
135
- return t if len(t) <= n else t[:n].rstrip() + "…"
136
-
137
-
138
  def load_article_text(article_id: str) -> Optional[str]:
139
  if not CHUNKS_PATH.exists():
140
  raise FileNotFoundError(f"Fichier chunks introuvable : {CHUNKS_PATH}")
@@ -161,10 +155,6 @@ def is_fulltext_request(q: str) -> bool:
161
 
162
 
163
  def is_explain_synthesis_request(q: str) -> bool:
164
- """
165
- EXPLAIN = synthèse extractive si le texte contient des marqueurs explicites de synthèse.
166
- (Un ID d'article sera exigé dans le routing.)
167
- """
168
  ql = (q or "").lower()
169
  return any(t in ql for t in EXPLAIN_TRIGGERS)
170
 
@@ -186,7 +176,7 @@ def get_vectorstore() -> FAISS:
186
  return _VS
187
 
188
 
189
- # ==================== LIST: KEYWORDS GUARD (FAST) ====================
190
 
191
  _STOPWORDS_FR = {
192
  "de", "des", "du", "la", "le", "les", "un", "une", "et", "ou", "a", "à",
@@ -196,34 +186,29 @@ _STOPWORDS_FR = {
196
  "code", "education", "éducation", "l'", "d'", "du", "des"
197
  }
198
 
 
 
 
 
 
199
 
200
  def _extract_keywords_for_list(q: str) -> List[str]:
201
- """
202
- Extraction très simple de mots-clés (sans NLP lourd) :
203
- - on retire les triggers usuels de LIST
204
- - on garde des tokens alpha-num >= 3
205
- - on retire stopwords
206
- """
207
  ql = (q or "").lower()
208
-
209
- # enlever quelques formulations fréquentes
210
  for t in LIST_TRIGGERS:
211
  ql = ql.replace(t, " ")
212
 
213
- # tokens (lettres + chiffres + -)
214
  toks = re.findall(r"[a-z0-9àâäçéèêëîïôöùûüÿ\-]{3,}", ql, flags=re.IGNORECASE)
215
  toks = [t.strip("-") for t in toks if t.strip("-")]
216
 
217
- # filtre stopwords
218
  out = []
219
  for t in toks:
 
220
  if t in _STOPWORDS_FR:
221
  continue
222
  if len(t) < 3:
223
  continue
224
  out.append(t)
225
 
226
- # dédup en conservant l’ordre
227
  seen = set()
228
  uniq = []
229
  for t in out:
@@ -234,7 +219,7 @@ def _extract_keywords_for_list(q: str) -> List[str]:
234
  return uniq
235
 
236
 
237
- def _lexical_match(doc_text: str, keywords: List[str]) -> bool:
238
  if not keywords:
239
  return False
240
  low = (doc_text or "").lower()
@@ -242,12 +227,12 @@ def _lexical_match(doc_text: str, keywords: List[str]) -> bool:
242
  for kw in keywords:
243
  if kw in low:
244
  hits += 1
245
- if hits >= LIST_MIN_KEYWORDS:
246
  return True
247
  return False
248
 
249
 
250
- # ==================== EXTRACTIVE SUMMARY (FAST) ====================
251
 
252
  _NORMATIVE_PATTERNS = [
253
  r"\bdoit\b", r"\bdoivent\b", r"\best\b", r"\bsont\b",
@@ -260,7 +245,6 @@ _NORMATIVE_PATTERNS = [
260
  r"\bI\.\b", r"\bII\.\b", r"\bIII\.\b", r"\b1°\b", r"\b2°\b", r"\b3°\b",
261
  ]
262
 
263
-
264
  def _split_into_segments(text: str) -> List[str]:
265
  if not text:
266
  return []
@@ -274,7 +258,6 @@ def _split_into_segments(text: str) -> List[str]:
274
  segs.append(ln)
275
  return segs
276
 
277
-
278
  def _score_segment(seg: str) -> int:
279
  s = 0
280
  low = seg.lower()
@@ -287,13 +270,7 @@ def _score_segment(seg: str) -> int:
287
  s -= 1
288
  return s
289
 
290
-
291
  def extractive_summary(article_id: str, article_text: str) -> str:
292
- """
293
- SYNTHÈSE extractive (rapide) :
294
- - sélection de segments clés (extraction)
295
- - aucune génération => zéro hallucination
296
- """
297
  segs = _split_into_segments(article_text)
298
  cleaned: List[str] = []
299
  for s in segs:
@@ -328,7 +305,7 @@ def extractive_summary(article_id: str, article_text: str) -> str:
328
  return f"{body}\n\nArticles cités : {article_id}"
329
 
330
 
331
- # ==================== QA PROMPT (FAST) ====================
332
 
333
  def _truncate(s: str, n: int) -> str:
334
  if not s:
@@ -336,7 +313,6 @@ def _truncate(s: str, n: int) -> str:
336
  s = s.strip()
337
  return s if len(s) <= n else s[:n].rstrip() + "\n[...]\n"
338
 
339
-
340
  def build_qa_prompt_fast(question: str, context: str, sources: List[str]) -> str:
341
  src = ", ".join(sources)
342
  return f"""Tu es un assistant qui aide à comprendre le Code de l'éducation (France).
@@ -344,7 +320,7 @@ def build_qa_prompt_fast(question: str, context: str, sources: List[str]) -> str
344
  CONTRAINTE :
345
  - Appuie-toi en priorité sur le CONTEXTE fourni.
346
  - Si l'information n'est pas dans le contexte, dis-le simplement.
347
- - Réponse courte, pratique, 5-8 phrases max.
348
 
349
  QUESTION :
350
  {question}
@@ -358,73 +334,86 @@ Indique à la fin : "Sources (articles) : {src}"
358
 
359
  # ==================== CORE ====================
360
 
361
- def answer_query(q: str) -> Dict[str, Any]:
362
- q = (q or "").strip()
363
- if not q:
364
- return {"mode": "QA", "answer": _REFUSAL, "articles": []}
365
-
366
- article_id = extract_article_id(q)
367
-
368
- # ---------- FULLTEXT ----------
369
- if article_id and is_fulltext_request(q):
370
- text = load_article_text(article_id)
371
- return {"mode": "FULLTEXT", "answer": text or _REFUSAL, "articles": [article_id]}
372
-
373
- # ---------- LIST (CORRIGÉ) ----------
374
- if is_list_request(q):
375
- vs = get_vectorstore()
376
-
377
- # Keywords pour garde lexical (très rapide)
378
- keywords = _extract_keywords_for_list(q)
379
 
380
- # Petit enrichissement "léger" pour stabiliser les embeddings
381
- # (souvent utile sur corpus juridique)
382
- list_query = f"articles sur {q}"
383
 
384
- # Récupération large + score (distance FAISS)
385
- scored_docs: List[Tuple[Any, float]] = vs.similarity_search_with_score(list_query, k=LIST_K)
386
 
 
387
  kept: List[Tuple[str, float]] = []
388
  for d, score in scored_docs:
389
  aid = normalize_article_id(d.metadata.get("article_id", ""))
390
  if not aid:
391
  continue
392
 
393
- # Filtre score : on garde seulement si c'est suffisamment proche
394
- if score > LIST_SCORE_THRESHOLD:
395
  continue
396
 
397
- # Filtre lexical : au moins 1 mot clé doit apparaître dans le contenu
398
- if LIST_REQUIRE_LEXICAL_MATCH and keywords:
399
- if not _lexical_match(d.page_content or "", keywords):
400
  continue
401
 
402
  kept.append((aid, score))
403
 
404
- # Tri par score croissant (meilleur d'abord) + dédup
405
- kept_sorted = sorted(kept, key=lambda x: x[1])
406
  seen = set()
407
- articles: List[str] = []
408
  for aid, _ in kept_sorted:
409
  if aid in seen:
410
  continue
411
  seen.add(aid)
412
- articles.append(aid)
413
- if len(articles) >= LIST_MAX_ARTICLES:
414
  break
 
415
 
416
- if not articles:
 
 
 
 
 
 
 
417
  msg = (
418
- "Je n’ai pas trouvé d’articles suffisamment pertinents pour ce thème.\n"
419
- "Conseil : précise ta demande (ex : « conseil de classe composition », "
420
- "« conseil de classe horaires », « conseil de classe bulletin ») "
421
- "ou utilise « Texte exact » si tu connais déjà l’article."
422
  )
423
- return {"mode": "LIST", "answer": msg, "articles": []}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
- return {"mode": "LIST", "answer": "", "articles": articles}
 
 
426
 
427
- # ---------- EXPLAIN (SYNTHÈSE extractive) ----------
428
  if is_explain_synthesis_request(q):
429
  if not article_id:
430
  return {"mode": "EXPLAIN", "answer": _EXPLAIN_REFUSAL, "articles": []}
@@ -436,7 +425,7 @@ def answer_query(q: str) -> Dict[str, Any]:
436
  summary = extractive_summary(article_id, text)
437
  return {"mode": "EXPLAIN", "answer": summary, "articles": [article_id]}
438
 
439
- # ---------- QA (FAST) ----------
440
  vs = get_vectorstore()
441
  docs = vs.similarity_search(q, k=max(1, QA_TOP_K_FINAL))
442
  sources = [normalize_article_id(d.metadata.get("article_id", "")) for d in docs]
@@ -448,10 +437,9 @@ def answer_query(q: str) -> Dict[str, Any]:
448
  ctx_parts.append(f"[{aid}]\n{txt}")
449
 
450
  context = "\n\n".join(ctx_parts).strip()
451
-
452
  prompt = build_qa_prompt_fast(q, context, sources)
453
- ans = llm_generate_qa(prompt).strip()
454
 
 
455
  final = f"{_QA_WARNING}\n\n{ans}"
456
 
457
  return {"mode": "QA", "answer": final, "articles": sources}
 
3
 
4
  """
5
  rag_core.py – Modes :
6
+ - LIST : rapide (FAISS, pas de LLM) — robuste : 2 passes (strict puis fallback)
7
  - FULLTEXT : rapide (texte exact depuis JSONL, pas de LLM)
8
+ - EXPLAIN : rapide -> synthèse extractive (text mining), pas une explication
9
  - QA : présent, mais accéléré (moins de garde-fous, avertissement utilisateur)
10
 
11
  Notes produit :
 
34
  SNIPPET_CHARS = 260
35
 
36
  # --- LIST (FIABILITÉ) ---
 
37
  LIST_K = int(os.environ.get("LIST_K", "30"))
38
  LIST_MAX_ARTICLES = int(os.environ.get("LIST_MAX_ARTICLES", "8"))
39
+
40
+ # Seuil sur distance FAISS (plus petit = meilleur).
41
+ # Par défaut : tolérant (sinon LIST tombe à 0 trop facilement).
42
+ LIST_SCORE_THRESHOLD = float(os.environ.get("LIST_SCORE_THRESHOLD", "0.80"))
43
+
44
+ # Lexical guard : utile, mais doit être "fallbackable"
45
  LIST_REQUIRE_LEXICAL_MATCH = os.environ.get("LIST_REQUIRE_LEXICAL_MATCH", "1") == "1"
46
  LIST_MIN_KEYWORDS = int(os.environ.get("LIST_MIN_KEYWORDS", "1"))
47
 
48
+ # Fallback si 0 résultat : on relâche le lexical et/ou le seuil
49
+ LIST_FALLBACK_RELAX_LEXICAL = os.environ.get("LIST_FALLBACK_RELAX_LEXICAL", "1") == "1"
50
+ LIST_FALLBACK_SCORE_THRESHOLD = float(os.environ.get("LIST_FALLBACK_SCORE_THRESHOLD", "1.10"))
51
+
52
  # --- EXPLAIN (synthèse extractive) ---
53
  EXTRACT_MAX_SEGMENTS = 5
54
  EXTRACT_MAX_CHARS_TOTAL = 900
 
56
  EXTRACT_MAX_SEG_LEN = 420
57
 
58
  # --- QA : accélération ---
59
+ QA_TOP_K_FINAL = int(os.environ.get("QA_TOP_K_FINAL", "2"))
60
+ QA_DOC_MAX_CHARS = int(os.environ.get("QA_DOC_MAX_CHARS", "700"))
61
+ QA_MAX_TOKENS = int(os.environ.get("QA_MAX_TOKENS", "160"))
62
+ QA_TEMPERATURE = float(os.environ.get("QA_TEMPERATURE", "0.2"))
63
 
64
  ARTICLE_ID_RE = re.compile(
65
  r"\b(?:article\s+)?([LDR]\s?\d{1,4}(?:[.-]\d+){0,4})\b",
 
72
  "extraits", "extrait", "résumé extractif", "resume extractif",
73
  ]
74
 
 
 
 
 
 
 
75
  LIST_TRIGGERS = [
76
  "quels articles", "quelles dispositions", "articles parlent",
77
  "articles qui parlent", "articles sur", "donne les articles",
 
99
 
100
 
101
  # ==================== LLM INIT ====================
102
+
103
  llm = Llama(
104
  model_path="models/model.gguf",
105
  n_ctx=1024,
 
129
  return normalize_article_id(m.group(1)) if m else None
130
 
131
 
 
 
 
 
 
132
  def load_article_text(article_id: str) -> Optional[str]:
133
  if not CHUNKS_PATH.exists():
134
  raise FileNotFoundError(f"Fichier chunks introuvable : {CHUNKS_PATH}")
 
155
 
156
 
157
  def is_explain_synthesis_request(q: str) -> bool:
 
 
 
 
158
  ql = (q or "").lower()
159
  return any(t in ql for t in EXPLAIN_TRIGGERS)
160
 
 
176
  return _VS
177
 
178
 
179
+ # ==================== LIST: KEYWORDS GUARD ====================
180
 
181
  _STOPWORDS_FR = {
182
  "de", "des", "du", "la", "le", "les", "un", "une", "et", "ou", "a", "à",
 
186
  "code", "education", "éducation", "l'", "d'", "du", "des"
187
  }
188
 
189
+ def _simple_singularize(token: str) -> str:
190
+ # mini heuristique : conseils -> conseil, classes -> classe
191
+ if token.endswith("s") and len(token) >= 5:
192
+ return token[:-1]
193
+ return token
194
 
195
  def _extract_keywords_for_list(q: str) -> List[str]:
 
 
 
 
 
 
196
  ql = (q or "").lower()
 
 
197
  for t in LIST_TRIGGERS:
198
  ql = ql.replace(t, " ")
199
 
 
200
  toks = re.findall(r"[a-z0-9àâäçéèêëîïôöùûüÿ\-]{3,}", ql, flags=re.IGNORECASE)
201
  toks = [t.strip("-") for t in toks if t.strip("-")]
202
 
 
203
  out = []
204
  for t in toks:
205
+ t = _simple_singularize(t)
206
  if t in _STOPWORDS_FR:
207
  continue
208
  if len(t) < 3:
209
  continue
210
  out.append(t)
211
 
 
212
  seen = set()
213
  uniq = []
214
  for t in out:
 
219
  return uniq
220
 
221
 
222
+ def _lexical_match(doc_text: str, keywords: List[str], min_hits: int) -> bool:
223
  if not keywords:
224
  return False
225
  low = (doc_text or "").lower()
 
227
  for kw in keywords:
228
  if kw in low:
229
  hits += 1
230
+ if hits >= min_hits:
231
  return True
232
  return False
233
 
234
 
235
+ # ==================== EXTRACTIVE SUMMARY ====================
236
 
237
  _NORMATIVE_PATTERNS = [
238
  r"\bdoit\b", r"\bdoivent\b", r"\best\b", r"\bsont\b",
 
245
  r"\bI\.\b", r"\bII\.\b", r"\bIII\.\b", r"\b1°\b", r"\b2°\b", r"\b3°\b",
246
  ]
247
 
 
248
  def _split_into_segments(text: str) -> List[str]:
249
  if not text:
250
  return []
 
258
  segs.append(ln)
259
  return segs
260
 
 
261
  def _score_segment(seg: str) -> int:
262
  s = 0
263
  low = seg.lower()
 
270
  s -= 1
271
  return s
272
 
 
273
  def extractive_summary(article_id: str, article_text: str) -> str:
 
 
 
 
 
274
  segs = _split_into_segments(article_text)
275
  cleaned: List[str] = []
276
  for s in segs:
 
305
  return f"{body}\n\nArticles cités : {article_id}"
306
 
307
 
308
+ # ==================== QA PROMPT ====================
309
 
310
  def _truncate(s: str, n: int) -> str:
311
  if not s:
 
313
  s = s.strip()
314
  return s if len(s) <= n else s[:n].rstrip() + "\n[...]\n"
315
 
 
316
  def build_qa_prompt_fast(question: str, context: str, sources: List[str]) -> str:
317
  src = ", ".join(sources)
318
  return f"""Tu es un assistant qui aide à comprendre le Code de l'éducation (France).
 
320
  CONTRAINTE :
321
  - Appuie-toi en priorité sur le CONTEXTE fourni.
322
  - Si l'information n'est pas dans le contexte, dis-le simplement.
323
+ - Réponse courte, pratique, 6-10 phrases max.
324
 
325
  QUESTION :
326
  {question}
 
334
 
335
  # ==================== CORE ====================
336
 
337
+ def _list_articles(theme_query: str) -> Dict[str, Any]:
338
+ vs = get_vectorstore()
339
+ keywords = _extract_keywords_for_list(theme_query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
+ # Enrichissement léger pour embedding
342
+ list_query = f"articles sur {theme_query}"
 
343
 
344
+ scored_docs: List[Tuple[Any, float]] = vs.similarity_search_with_score(list_query, k=LIST_K)
 
345
 
346
+ def run_pass(score_threshold: float, require_lexical: bool) -> List[str]:
347
  kept: List[Tuple[str, float]] = []
348
  for d, score in scored_docs:
349
  aid = normalize_article_id(d.metadata.get("article_id", ""))
350
  if not aid:
351
  continue
352
 
353
+ if score > score_threshold:
 
354
  continue
355
 
356
+ if require_lexical and keywords:
357
+ if not _lexical_match(d.page_content or "", keywords, LIST_MIN_KEYWORDS):
 
358
  continue
359
 
360
  kept.append((aid, score))
361
 
362
+ kept_sorted = sorted(kept, key=lambda x: x[1]) # meilleur d'abord
 
363
  seen = set()
364
+ out: List[str] = []
365
  for aid, _ in kept_sorted:
366
  if aid in seen:
367
  continue
368
  seen.add(aid)
369
+ out.append(aid)
370
+ if len(out) >= LIST_MAX_ARTICLES:
371
  break
372
+ return out
373
 
374
+ # Pass 1 : strict
375
+ articles = run_pass(LIST_SCORE_THRESHOLD, LIST_REQUIRE_LEXICAL_MATCH)
376
+
377
+ # Pass 2 : fallback (on veut éviter "0 résultat")
378
+ if not articles and LIST_FALLBACK_RELAX_LEXICAL:
379
+ articles = run_pass(LIST_FALLBACK_SCORE_THRESHOLD, False)
380
+
381
+ if articles:
382
  msg = (
383
+ "Résultats approximatifs : le thème ne correspond pas textuellement aux passages indexés.\n"
384
+ "Conseil : précise (ex : « conseil de classe composition » / « vacances scolaires calendrier »), "
385
+ "puis vérifie via « Texte exact »."
 
386
  )
387
+ return {"mode": "LIST", "answer": msg, "articles": articles}
388
+
389
+ if not articles:
390
+ msg = (
391
+ "Je n’ai pas trouvé d’articles suffisamment pertinents pour ce thème.\n"
392
+ "Conseil : précise ta demande (ex : « conseil de classe composition », "
393
+ "« vacances scolaires calendrier ») ou utilise « Question (QA) » (plus lent)."
394
+ )
395
+ return {"mode": "LIST", "answer": msg, "articles": []}
396
+
397
+ return {"mode": "LIST", "answer": "", "articles": articles}
398
+
399
+
400
+ def answer_query(q: str) -> Dict[str, Any]:
401
+ q = (q or "").strip()
402
+ if not q:
403
+ return {"mode": "QA", "answer": _REFUSAL, "articles": []}
404
+
405
+ article_id = extract_article_id(q)
406
+
407
+ # FULLTEXT
408
+ if article_id and is_fulltext_request(q):
409
+ text = load_article_text(article_id)
410
+ return {"mode": "FULLTEXT", "answer": text or _REFUSAL, "articles": [article_id]}
411
 
412
+ # LIST
413
+ if is_list_request(q):
414
+ return _list_articles(q)
415
 
416
+ # EXPLAIN (synthèse extractive)
417
  if is_explain_synthesis_request(q):
418
  if not article_id:
419
  return {"mode": "EXPLAIN", "answer": _EXPLAIN_REFUSAL, "articles": []}
 
425
  summary = extractive_summary(article_id, text)
426
  return {"mode": "EXPLAIN", "answer": summary, "articles": [article_id]}
427
 
428
+ # QA (FAST)
429
  vs = get_vectorstore()
430
  docs = vs.similarity_search(q, k=max(1, QA_TOP_K_FINAL))
431
  sources = [normalize_article_id(d.metadata.get("article_id", "")) for d in docs]
 
437
  ctx_parts.append(f"[{aid}]\n{txt}")
438
 
439
  context = "\n\n".join(ctx_parts).strip()
 
440
  prompt = build_qa_prompt_fast(q, context, sources)
 
441
 
442
+ ans = llm_generate_qa(prompt).strip()
443
  final = f"{_QA_WARNING}\n\n{ans}"
444
 
445
  return {"mode": "QA", "answer": final, "articles": sources}