FabIndy commited on
Commit
b00b20e
·
1 Parent(s): 14fa239

Fix EXPLAIN mode: dedicated LLM prompt, longer output, strict article handling

Browse files
Files changed (1) hide show
  1. src/rag_core.py +140 -323
src/rag_core.py CHANGED
@@ -2,23 +2,7 @@
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
- rag_core.py
6
-
7
- Transposition FIDÈLE de rag_chat_llama.py (mêmes règles, mêmes seuils, même prompt,
8
- même validation anti-hallucination), mais sans boucle interactive : on expose
9
- une fonction answer_query(question) utilisable par une app Hugging Face.
10
-
11
- ROUTAGE AUTO :
12
- - FULLTEXT : demande "texte exact / intégral / article X" => impression exacte depuis JSONL (SANS LLM)
13
- - LIST : demande "quels articles parlent ..." => liste articles + extrait (SANS LLM)
14
- - EXPLAIN : demande "explique/résume..." + ID article => LLM sur 1 article (RAG strict)
15
- demande "explique/résume..." sans ID => REFUS (orienter vers LIST/FULLTEXT)
16
- - QA : RAG => LLM + prompt strict + VALIDATION (anti-hallucinations)
17
-
18
- Prérequis :
19
- - data/chunks_articles.jsonl (article-level)
20
- - db/faiss_code_edu_by_article (FAISS)
21
- - models/model.gguf (GGUF)
22
  """
23
 
24
  import json
@@ -31,56 +15,47 @@ from langchain_huggingface import HuggingFaceEmbeddings
31
  from llama_cpp import Llama
32
 
33
 
34
- # -------------------- CONFIG --------------------
 
35
  CHUNKS_PATH = Path("data/chunks_articles.jsonl")
36
  DB_DIR = Path("db/faiss_code_edu_by_article")
37
 
38
  EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
39
 
40
- TOP_K_FETCH = 30 # nb de docs candidats récupérés
41
- TOP_K_FINAL = 3 # nb max envoyés au LLM (QA)
42
- SCORE_THRESHOLD = 1.10 # à ajuster (voir affichage des scores)
43
- MAX_CHARS_PER_DOC = 800
 
44
  SNIPPET_CHARS = 260
45
 
46
- # Déclencheurs FULLTEXT
47
- FULLTEXT_TRIGGERS = [
48
- "contenu exact", "texte exact", "texte intégral", "texte integral",
49
- "intégral", "integral", "cite intégralement", "cite integralement",
50
- "donne l'intégralité", "donne l'integralite", "recopie", "reproduis",
51
- "affiche l'article", "donne l'article", "donne moi l'article",
 
 
52
  ]
53
 
54
- # Déclencheurs LIST
55
  LIST_TRIGGERS = [
56
- "quels articles", "quelles dispositions", "articles parlent",
57
- "articles qui parlent", "articles sur", "donne les articles",
58
- "cite les articles", "références", "references",
59
  ]
60
 
61
- # Déclencheurs EXPLAIN (reformulation)
62
- EXPLAIN_TRIGGERS = [
63
- "explique", "expliquer", "explication",
64
- "résume", "resume", "résumé", "resume-moi", "résume-moi",
65
- "reformule", "reformuler",
66
- "simplifie", "simplifier",
67
- "en termes simples", "très simple", "tres simple",
68
- "vulgarise", "vulgariser",
69
- "clarifie", "clarifier",
70
  ]
71
 
72
- # Regex article id
73
- ARTICLE_ID_RE = re.compile(
74
- r"\b(?:article\s+)?([LDR]\s?\d{1,4}(?:[.-]\d+){0,4})\b",
75
- flags=re.IGNORECASE
76
  )
77
 
78
- EPLE_RE = re.compile(r"\bEPLE\b", flags=re.IGNORECASE)
79
 
80
- # Pour valider les sorties "Articles cités : ..."
81
- ARTICLES_CITES_RE = re.compile(r"Articles cités\s*:\s*(.*)$", flags=re.IGNORECASE | re.MULTILINE)
82
 
83
- # -------------------- LLM INIT (FIDÈLE) --------------------
84
  llm = Llama(
85
  model_path="models/model.gguf",
86
  n_ctx=2048,
@@ -90,7 +65,8 @@ llm = Llama(
90
  )
91
 
92
 
93
- def llm_generate(prompt: str) -> str:
 
94
  out = llm.create_chat_completion(
95
  messages=[{"role": "user", "content": prompt}],
96
  temperature=0.1,
@@ -99,29 +75,30 @@ def llm_generate(prompt: str) -> str:
99
  return out["choices"][0]["message"]["content"].strip()
100
 
101
 
102
- # -------------------- UTILS (FIDÈLES) --------------------
 
 
 
 
 
 
 
 
 
 
103
 
104
  def normalize_article_id(raw: str) -> str:
105
- s = raw.strip().upper().replace(" ", "")
106
- s = s.replace(".", "-")
107
- return s
108
 
109
 
110
  def extract_article_id(q: str) -> Optional[str]:
111
  m = ARTICLE_ID_RE.search(q)
112
- if not m:
113
- return None
114
- return normalize_article_id(m.group(1))
115
 
116
 
117
- def is_fulltext_request(q: str) -> bool:
118
  ql = q.lower()
119
- if any(t in ql for t in FULLTEXT_TRIGGERS):
120
- return True
121
- aid = extract_article_id(q)
122
- if aid and len(ql) <= 25:
123
- return True
124
- return False
125
 
126
 
127
  def is_list_request(q: str) -> bool:
@@ -129,309 +106,149 @@ def is_list_request(q: str) -> bool:
129
  return any(t in ql for t in LIST_TRIGGERS)
130
 
131
 
132
- def is_explain_request(q: str) -> bool:
133
  ql = q.lower()
134
- return any(t in ql for t in EXPLAIN_TRIGGERS)
135
-
136
-
137
- def dedupe_keep_order(items: Iterable[str]) -> List[str]:
138
- seen = set()
139
- out = []
140
- for x in items:
141
- if x not in seen:
142
- out.append(x)
143
- seen.add(x)
144
- return out
145
-
146
-
147
- def safe_snippet(text: str, n: int) -> str:
148
- t = " ".join((text or "").split())
149
- if len(t) <= n:
150
- return t
151
- return t[:n].rstrip() + "…"
152
 
153
 
154
  def load_article_text(article_id: str) -> Optional[str]:
155
- if not CHUNKS_PATH.exists():
156
- raise FileNotFoundError(f"Fichier chunks introuvable : {CHUNKS_PATH}")
157
-
158
  with CHUNKS_PATH.open("r", encoding="utf-8") as f:
159
  for line in f:
160
- if not line.strip():
161
- continue
162
  obj = json.loads(line)
163
- aid = normalize_article_id(obj.get("article_id", ""))
164
- if aid == article_id:
165
- return (obj.get("text") or "").strip()
166
  return None
167
 
168
 
169
- def load_vectorstore() -> FAISS:
170
- if not DB_DIR.exists():
171
- raise FileNotFoundError(f"Index FAISS introuvable : {DB_DIR}")
172
- embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL)
173
- return FAISS.load_local(str(DB_DIR), embeddings, allow_dangerous_deserialization=True)
174
-
175
-
176
- def retrieve_scored(vs: FAISS, query: str) -> List[Tuple[object, float]]:
177
- """
178
- Retourne liste (Document, score). Plus le score est PETIT, plus c'est proche (distance).
179
- """
180
- return vs.similarity_search_with_score(query, k=TOP_K_FETCH)
181
-
182
-
183
- def filter_docs(scored: List[Tuple[object, float]]) -> List[Tuple[object, float]]:
184
- """
185
- Filtre simple par seuil + garde TOP_K_FINAL.
186
- """
187
- kept = [(d, s) for (d, s) in scored if s <= SCORE_THRESHOLD]
188
- if not kept:
189
- # fallback : au moins TOP_K_FINAL meilleurs, sinon tu refuses trop souvent
190
- kept = sorted(scored, key=lambda x: x[1])[:TOP_K_FINAL]
191
- else:
192
- kept = sorted(kept, key=lambda x: x[1])[:TOP_K_FINAL]
193
- return kept
194
-
195
-
196
- def build_context(scored_docs: List[Tuple[object, float]]) -> Tuple[str, List[str], Dict[str, str], Dict[str, float]]:
197
- used = []
198
- by_id: Dict[str, str] = {}
199
- by_score: Dict[str, float] = {}
200
-
201
- blocks = []
202
- for d, s in scored_docs:
203
- aid = d.metadata.get("article_id", "UNKNOWN")
204
- aid_norm = normalize_article_id(aid)
205
- used.append(aid_norm)
206
-
207
- txt = (d.page_content or "").strip()
208
- by_id[aid_norm] = txt
209
- by_score[aid_norm] = float(s)
210
-
211
- if len(txt) > MAX_CHARS_PER_DOC:
212
- txt = txt[:MAX_CHARS_PER_DOC].rstrip() + "\n[.]"
213
-
214
- blocks.append(f"[{aid_norm}]\n{txt}")
215
-
216
- used = dedupe_keep_order(used)
217
- return "\n\n".join(blocks), used, by_id, by_score
218
-
219
-
220
- def eple_context_ok(question: str, by_id: Dict[str, str]) -> bool:
221
- """
222
- Si la question contient "EPLE", on veut que le contexte contienne explicitement
223
- des indices "collège/lycée/établissement public local d'enseignement".
224
- """
225
- if not EPLE_RE.search(question):
226
- return True
227
-
228
- joined = "\n".join(by_id.values()).lower()
229
- signals = [
230
- "établissement public local d'enseignement",
231
- "etablissement public local d'enseignement",
232
- "collège", "college", "lycée", "lycee",
233
- "chef d'établissement", "chef d'etablissement",
234
- ]
235
- return any(sig in joined for sig in signals)
236
-
237
-
238
- def extract_cited_articles(answer: str) -> List[str]:
239
- m = ARTICLES_CITES_RE.search(answer)
240
- if not m:
241
- return []
242
- tail = m.group(1).strip()
243
- if not tail:
244
- return []
245
- parts = re.split(r"[,\s]+", tail)
246
- out = []
247
- for p in parts:
248
- p = p.strip()
249
- if not p:
250
- continue
251
- # tolère "D422-15." ou "[D422-15]"
252
- p = p.strip("[]().;:")
253
- if ARTICLE_ID_RE.match(p) or re.match(r"^[LDR]\d", p, flags=re.I):
254
- out.append(normalize_article_id(p))
255
- return dedupe_keep_order(out)
256
-
257
-
258
- def validate_answer(answer: str, allowed_articles: List[str]) -> bool:
259
- cited = extract_cited_articles(answer)
260
- allowed_set = set(allowed_articles)
261
-
262
- # si le LLM ne cite rien => on refuse (sinon il peut raconter)
263
- if not cited:
264
- return False
265
-
266
- # interdit de citer un article non présent dans la liste autorisée
267
- if any(c not in allowed_set for c in cited):
268
- return False
269
-
270
- return True
271
-
272
-
273
- def build_prompt(question: str, context: str, allowed_articles: List[str]) -> str:
274
- allowed = ", ".join(allowed_articles)
275
-
276
- return f"""Tu es un assistant juridique spécialisé dans le Code de l'éducation (France).
277
-
278
- RÈGLES ABSOLUES (non négociables) :
279
- 1) Tu réponds UNIQUEMENT à partir du CONTEXTE fourni ci-dessous.
280
- 2) Tu n'inventes rien, tu ne complètes pas, tu ne "supposes" pas. Interdiction d'utiliser :
281
- "on peut supposer", "il est possible que", "on peut déduire", "probablement", etc.
282
- 3) Si le CONTEXTE ne permet pas de répondre, tu dis exactement :
283
- "Je ne peux pas répondre avec certitude à partir des articles fournis."
284
- 4) Tu DOIS citer uniquement des articles présents dans la liste autorisée :
285
- {allowed}
286
- 5) Attention au sigle EPLE :
287
- - EPLE = établissement public local d'enseignement (collèges/lycées).
288
- - Ne confonds pas avec d'autres établissements.
289
- Si le CONTEXTE ne traite pas clairement des EPLE au sens collèges/lycées, tu refuses de conclure.
290
 
291
- QUESTION :
292
- {question}
293
 
294
- CONTEXTE :
295
- {context}
296
 
297
- FORMAT DE SORTIE OBLIGATOIRE :
298
- - Une réponse courte et factuelle.
299
- - Dernière ligne STRICTE : "Articles cités : A, B, C" (uniquement parmi la liste autorisée).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  """
301
 
302
 
303
- # -------------------- CORE API (HF) --------------------
304
- _REFUSAL = "Je ne peux pas répondre avec certitude à partir des articles fournis."
305
- _EXPLAIN_REFUSAL = (
306
- "Pour expliquer ou résumer, indique un identifiant d’article (ex : D422-5). "
307
- "Sinon, commence par : \"Quels articles parlent de … ?\""
308
- )
309
 
310
- # cache pour éviter de recharger FAISS à chaque call
311
- _VS: Optional[FAISS] = None
 
 
312
 
 
 
313
 
314
- def get_vectorstore() -> FAISS:
315
- global _VS
316
- if _VS is None:
317
- _VS = load_vectorstore()
318
- return _VS
 
 
319
 
320
 
 
 
321
  def answer_query(q: str) -> Dict[str, Any]:
322
- """
323
- API équivalente à la boucle interactive de rag_chat_llama.py.
324
-
325
- Retourne un dict structuré :
326
- - mode: "FULLTEXT" | "LIST" | "EXPLAIN" | "QA"
327
- - answer: str (réponse finale ou refus)
328
- - articles: liste des articles récupérés (pour debug/affichage)
329
- - scores: dict {article: score} (pour debug/affichage)
330
- - snippets: (LIST) dict {article: snippet}
331
- - fulltext: (FULLTEXT) texte exact
332
- """
333
- q = (q or "").strip()
334
  if not q:
335
- return {"mode": "QA", "answer": _REFUSAL, "articles": [], "scores": {}}
336
-
337
- # --- EXPLAIN sans ID => REFUS (robuste) ---
338
- # On refuse explicitement pour forcer l'utilisateur à donner un article.
339
- aid = extract_article_id(q)
340
- if is_explain_request(q) and not aid:
341
- return {"mode": "EXPLAIN", "answer": _EXPLAIN_REFUSAL, "articles": [], "scores": {}}
342
 
343
- vs = get_vectorstore()
344
 
345
- # --- FULLTEXT ---
346
- if aid and is_fulltext_request(q):
347
- txt = load_article_text(aid)
348
- if not txt:
349
  return {
350
- "mode": "FULLTEXT",
351
- "answer": f"Je ne trouve pas l'article {aid} dans {CHUNKS_PATH}.",
352
- "articles": [],
353
- "scores": {},
354
- "fulltext": None,
355
  }
356
- return {
357
- "mode": "FULLTEXT",
358
- "answer": txt,
359
- "articles": [aid],
360
- "scores": {},
361
- "fulltext": txt,
362
- }
363
 
364
- # --- EXPLAIN (article unique forcé) ---
365
- # Si l'utilisateur demande une explication ET fournit un ID,
366
- # on force le contexte à cet article (plus fiable + souvent plus rapide).
367
- if aid and is_explain_request(q):
368
- txt = load_article_text(aid)
369
- if not txt:
370
  return {
371
  "mode": "EXPLAIN",
372
- "answer": f"Je ne trouve pas l'article {aid} dans {CHUNKS_PATH}.",
373
- "articles": [],
374
- "scores": {},
375
  }
376
 
377
- context = f"[{aid}]\n{txt}"
378
- articles = [aid]
379
- by_id = {aid: txt}
380
-
381
- # --- EPLE safety gate (inchangé) ---
382
- if not eple_context_ok(q, by_id):
383
- return {"mode": "EXPLAIN", "answer": _REFUSAL, "articles": articles, "scores": {}}
384
-
385
- prompt = build_prompt(q, context, articles)
386
- answer = llm_generate(prompt)
387
 
388
- # --- VALIDATION (inchangée) ---
389
- if not validate_answer(answer, articles):
390
- return {"mode": "EXPLAIN", "answer": _REFUSAL, "articles": articles, "scores": {}}
391
-
392
- return {"mode": "EXPLAIN", "answer": answer, "articles": articles, "scores": {}}
393
 
394
- # --- RETRIEVE (scored) ---
395
- scored = retrieve_scored(vs, q)
396
- scored = filter_docs(scored)
397
- context, articles, by_id, by_score = build_context(scored)
 
 
 
 
398
 
399
- # --- LIST ---
400
  if is_list_request(q):
401
- snippets = {a: safe_snippet(by_id.get(a, ""), SNIPPET_CHARS) for a in articles}
 
 
402
  return {
403
  "mode": "LIST",
404
  "answer": "",
405
- "articles": articles,
406
- "scores": by_score,
407
- "snippets": snippets,
408
- }
409
-
410
- # --- EPLE safety gate ---
411
- if not eple_context_ok(q, by_id):
412
- return {
413
- "mode": "QA",
414
- "answer": _REFUSAL,
415
- "articles": articles,
416
- "scores": by_score,
417
  }
418
 
419
- # --- QA (LLM) ---
420
- prompt = build_prompt(q, context, articles)
421
- answer = llm_generate(prompt)
 
 
422
 
423
- # --- VALIDATION ---
424
- if not validate_answer(answer, articles):
425
- return {
426
- "mode": "QA",
427
- "answer": _REFUSAL,
428
- "articles": articles,
429
- "scores": by_score,
430
- }
431
 
432
  return {
433
  "mode": "QA",
434
  "answer": answer,
435
- "articles": articles,
436
- "scores": by_score,
437
  }
 
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
+ rag_core.py – version corrigée EXPLAIN
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import json
 
15
  from llama_cpp import Llama
16
 
17
 
18
+ # ==================== CONFIG ====================
19
+
20
  CHUNKS_PATH = Path("data/chunks_articles.jsonl")
21
  DB_DIR = Path("db/faiss_code_edu_by_article")
22
 
23
  EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
24
 
25
+ TOP_K_FETCH = 30
26
+ TOP_K_FINAL = 3
27
+ SCORE_THRESHOLD = 1.10
28
+
29
+ MAX_CHARS_PER_DOC = 1200
30
  SNIPPET_CHARS = 260
31
 
32
+ ARTICLE_ID_RE = re.compile(
33
+ r"\b(?:article\s+)?([LDR]\s?\d{1,4}(?:[.-]\d+){0,4})\b",
34
+ flags=re.IGNORECASE
35
+ )
36
+
37
+ EXPLAIN_TRIGGERS = [
38
+ "explique", "explication", "résume", "resume",
39
+ "simplifie", "en termes simples", "vulgarise"
40
  ]
41
 
 
42
  LIST_TRIGGERS = [
43
+ "quels articles", "articles qui", "articles sur", "références"
 
 
44
  ]
45
 
46
+ FULLTEXT_TRIGGERS = [
47
+ "texte exact", "texte intégral", "donne l'article", "intégralité"
 
 
 
 
 
 
 
48
  ]
49
 
50
+ _REFUSAL = "Je ne peux pas répondre avec certitude à partir des articles fournis."
51
+ _EXPLAIN_REFUSAL = (
52
+ "Pour expliquer un article, indique explicitement son identifiant "
53
+ "(ex : D422-5)."
54
  )
55
 
 
56
 
57
+ # ==================== LLM INIT ====================
 
58
 
 
59
  llm = Llama(
60
  model_path="models/model.gguf",
61
  n_ctx=2048,
 
65
  )
66
 
67
 
68
+ def llm_generate_qa(prompt: str) -> str:
69
+ """Réponse courte, stricte"""
70
  out = llm.create_chat_completion(
71
  messages=[{"role": "user", "content": prompt}],
72
  temperature=0.1,
 
75
  return out["choices"][0]["message"]["content"].strip()
76
 
77
 
78
+ def llm_generate_explain(prompt: str) -> str:
79
+ """Réponse explicative (plus longue)"""
80
+ out = llm.create_chat_completion(
81
+ messages=[{"role": "user", "content": prompt}],
82
+ temperature=0.2,
83
+ max_tokens=500,
84
+ )
85
+ return out["choices"][0]["message"]["content"].strip()
86
+
87
+
88
+ # ==================== UTILS ====================
89
 
90
  def normalize_article_id(raw: str) -> str:
91
+ return raw.strip().upper().replace(" ", "").replace(".", "-")
 
 
92
 
93
 
94
  def extract_article_id(q: str) -> Optional[str]:
95
  m = ARTICLE_ID_RE.search(q)
96
+ return normalize_article_id(m.group(1)) if m else None
 
 
97
 
98
 
99
+ def is_explain_request(q: str) -> bool:
100
  ql = q.lower()
101
+ return any(t in ql for t in EXPLAIN_TRIGGERS)
 
 
 
 
 
102
 
103
 
104
  def is_list_request(q: str) -> bool:
 
106
  return any(t in ql for t in LIST_TRIGGERS)
107
 
108
 
109
+ def is_fulltext_request(q: str) -> bool:
110
  ql = q.lower()
111
+ return any(t in ql for t in FULLTEXT_TRIGGERS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
 
114
  def load_article_text(article_id: str) -> Optional[str]:
 
 
 
115
  with CHUNKS_PATH.open("r", encoding="utf-8") as f:
116
  for line in f:
 
 
117
  obj = json.loads(line)
118
+ if normalize_article_id(obj.get("article_id", "")) == article_id:
119
+ return obj.get("text", "").strip()
 
120
  return None
121
 
122
 
123
+ # ==================== VECTORSTORE ====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
+ _VS: Optional[FAISS] = None
 
126
 
 
 
127
 
128
+ def get_vectorstore() -> FAISS:
129
+ global _VS
130
+ if _VS is None:
131
+ embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL)
132
+ _VS = FAISS.load_local(
133
+ str(DB_DIR),
134
+ embeddings,
135
+ allow_dangerous_deserialization=True
136
+ )
137
+ return _VS
138
+
139
+
140
+ # ==================== PROMPTS ====================
141
+
142
+ def build_explain_prompt(article_id: str, article_text: str, level: str) -> str:
143
+ return f"""
144
+ Tu es un assistant pédagogique spécialisé dans le Code de l'éducation.
145
+
146
+ ARTICLE :
147
+ [{article_id}]
148
+ {article_text}
149
+
150
+ TÂCHE :
151
+ Explique cet article de façon {level}, fidèle au texte, sans rien inventer.
152
+
153
+ INTERDICTIONS :
154
+ - Pas d'ajout juridique
155
+ - Pas de généralisation
156
+ - Pas de suppositions
157
+
158
+ FORMAT :
159
+ - Explication structurée
160
+ - Ton clair et accessible
161
+ - Aucune citation d'autres articles
162
  """
163
 
164
 
165
+ def build_qa_prompt(question: str, context: str, allowed: List[str]) -> str:
166
+ return f"""
167
+ Tu es un assistant juridique spécialisé dans le Code de l'éducation.
 
 
 
168
 
169
+ RÈGLES STRICTES :
170
+ - Tu réponds uniquement à partir du contexte
171
+ - Tu cites uniquement : {", ".join(allowed)}
172
+ - Sinon tu refuses
173
 
174
+ QUESTION :
175
+ {question}
176
 
177
+ CONTEXTE :
178
+ {context}
179
+
180
+ FORMAT FINAL :
181
+ Réponse courte.
182
+ Dernière ligne : Articles cités : A, B
183
+ """
184
 
185
 
186
+ # ==================== CORE ====================
187
+
188
  def answer_query(q: str) -> Dict[str, Any]:
189
+ q = q.strip()
 
 
 
 
 
 
 
 
 
 
 
190
  if not q:
191
+ return {"mode": "QA", "answer": _REFUSAL, "articles": []}
 
 
 
 
 
 
192
 
193
+ article_id = extract_article_id(q)
194
 
195
+ # ---------- EXPLAIN ----------
196
+ if is_explain_request(q):
197
+ if not article_id:
 
198
  return {
199
+ "mode": "EXPLAIN",
200
+ "answer": _EXPLAIN_REFUSAL,
201
+ "articles": []
 
 
202
  }
 
 
 
 
 
 
 
203
 
204
+ text = load_article_text(article_id)
205
+ if not text:
 
 
 
 
206
  return {
207
  "mode": "EXPLAIN",
208
+ "answer": f"Article {article_id} introuvable.",
209
+ "articles": []
 
210
  }
211
 
212
+ prompt = build_explain_prompt(article_id, text, "simple")
213
+ answer = llm_generate_explain(prompt)
 
 
 
 
 
 
 
 
214
 
215
+ return {
216
+ "mode": "EXPLAIN",
217
+ "answer": answer,
218
+ "articles": [article_id]
219
+ }
220
 
221
+ # ---------- FULLTEXT ----------
222
+ if article_id and is_fulltext_request(q):
223
+ text = load_article_text(article_id)
224
+ return {
225
+ "mode": "FULLTEXT",
226
+ "answer": text or _REFUSAL,
227
+ "articles": [article_id]
228
+ }
229
 
230
+ # ---------- LIST ----------
231
  if is_list_request(q):
232
+ vs = get_vectorstore()
233
+ docs = vs.similarity_search(q, k=5)
234
+ arts = list({normalize_article_id(d.metadata["article_id"]) for d in docs})
235
  return {
236
  "mode": "LIST",
237
  "answer": "",
238
+ "articles": arts
 
 
 
 
 
 
 
 
 
 
 
239
  }
240
 
241
+ # ---------- QA ----------
242
+ vs = get_vectorstore()
243
+ docs = vs.similarity_search(q, k=TOP_K_FINAL)
244
+ context = "\n\n".join(d.page_content for d in docs)
245
+ articles = [normalize_article_id(d.metadata["article_id"]) for d in docs]
246
 
247
+ prompt = build_qa_prompt(q, context, articles)
248
+ answer = llm_generate_qa(prompt)
 
 
 
 
 
 
249
 
250
  return {
251
  "mode": "QA",
252
  "answer": answer,
253
+ "articles": articles
 
254
  }