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

Improve LIST reliability with score threshold and lexical guard; align UI with extractive synthesis and faster QA

Browse files
Files changed (2) hide show
  1. app.py +11 -12
  2. src/rag_core.py +127 -28
app.py CHANGED
@@ -222,21 +222,20 @@ with gr.Blocks(title="Code de l’éducation — Assistant (RAG)", css=CSS, them
222
  """
223
  # Code de l’éducation — Assistant (RAG)
224
 
225
- Outil de consultation des **articles** du Code de l’éducation, destiné aux **chefs d’établissement**.
226
 
227
- **Méthode recommandée (rapide et fiable)**
228
- 1) **Trouver les articles** (LIST)
229
- 2) **Lire le texte exact** (FULLTEXT)
230
- 3) **Obtenir une synthèse** d’un article (extraction, sans reformulation)
231
 
232
- > Le mode **Question (QA)** est plus lent et propose une **interprétation** : à vérifier sur le texte exact.
233
  """.strip()
234
  )
235
 
236
  gr.Markdown(
237
- """
238
- > **Note de service**
239
- > Au premier lancement, l’application peut nécessiter **1 à 2 minutes** d’initialisation (téléchargement index et modèle).
240
  > Ensuite, l’utilisation est immédiate.
241
  """.strip()
242
  )
@@ -272,7 +271,7 @@ Outil de consultation des **articles** du Code de l’éducation, destiné aux *
272
  )
273
  syn_btn = gr.Button("Afficher la synthèse", variant="primary")
274
  gr.Markdown(
275
- "<div class='small-note'>Synthèse = <b>extraction</b> de passages clés (sans reformulation). Très rapide.</div>"
276
  )
277
 
278
  with gr.Tab("Question (QA)"):
@@ -282,9 +281,9 @@ Outil de consultation des **articles** du Code de l’éducation, destiné aux *
282
  lines=3,
283
  max_lines=6,
284
  )
285
- qa_btn = gr.Button("Poser la question", variant="secondary")
286
  gr.Markdown(
287
- "<div class='small-note'><b>Attention :</b> ce mode peut être plus lent. La réponse est une interprétation rédigée par IA, à vérifier sur le texte exact.</div>"
288
  )
289
 
290
  out = gr.Textbox(label="Réponse", elem_id="answer", lines=12, max_lines=20)
 
222
  """
223
  # Code de l’éducation — Assistant (RAG)
224
 
225
+ Outil de consultation des articles du Code de l’éducation, destiné aux chefs d’établissement.
226
 
227
+ Méthode recommandée (rapide et fiable)
228
+ 1) Trouver les articles (LIST)
229
+ 2) Lire le texte exact (FULLTEXT)
230
+ 3) Obtenir une synthèse d’un article (extraction, sans reformulation)
231
 
232
+ > Le mode Question (QA) est lent et propose une interprétation : à vérifier sur le texte exact.
233
  """.strip()
234
  )
235
 
236
  gr.Markdown(
237
+ """
238
+ > Au premier lancement, l’application peut nécessiter 1 à 2 minutes d’initialisation (téléchargement index et modèle).
 
239
  > Ensuite, l’utilisation est immédiate.
240
  """.strip()
241
  )
 
271
  )
272
  syn_btn = gr.Button("Afficher la synthèse", variant="primary")
273
  gr.Markdown(
274
+ "<div class='small-note'>Synthèse = extraction de passages clés (sans reformulation). Très rapide.</div>"
275
  )
276
 
277
  with gr.Tab("Question (QA)"):
 
281
  lines=3,
282
  max_lines=6,
283
  )
284
+ qa_btn = gr.Button("Poser la question", variant="primary")
285
  gr.Markdown(
286
+ "<div class='small-note'> Attention : ce mode peut être plus lent. La réponse est une interprétation rédigée par IA, à vérifier sur le texte exact.</div>"
287
  )
288
 
289
  out = gr.Textbox(label="Réponse", elem_id="answer", lines=12, max_lines=20)
src/rag_core.py CHANGED
@@ -3,7 +3,7 @@
3
 
4
  """
5
  rag_core.py – Modes :
6
- - LIST : rapide (FAISS, pas de LLM)
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)
@@ -33,6 +33,17 @@ EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
33
 
34
  SNIPPET_CHARS = 260
35
 
 
 
 
 
 
 
 
 
 
 
 
36
  # --- EXPLAIN (synthèse extractive) ---
37
  EXTRACT_MAX_SEGMENTS = 5
38
  EXTRACT_MAX_CHARS_TOTAL = 900
@@ -50,17 +61,12 @@ ARTICLE_ID_RE = re.compile(
50
  flags=re.IGNORECASE,
51
  )
52
 
53
- # On garde les triggers, mais on va router autrement :
54
- # - EXPLAIN = "Synthèse (extraction)" => nécessite ID article
55
- # - QA accepte aussi les formulations "explique-moi en termes simples..." -> QA
56
  EXPLAIN_TRIGGERS = [
57
  "synthèse", "synthese", "points clés", "points cles",
58
  "extraits", "extrait", "résumé extractif", "resume extractif",
59
  ]
60
 
61
- # On garde aussi "explique/résume" mais attention :
62
- # si la demande contient "explique" + ID et qu'on veut une explication LLM => QA.
63
- # si la demande contient "synthèse" / "points clés" => EXPLAIN.
64
  EXPLAINISH_WORDS = [
65
  "explique", "expliquer", "explication",
66
  "résume", "resume", "résumé", "reformule", "simplifie",
@@ -156,10 +162,8 @@ def is_fulltext_request(q: str) -> bool:
156
 
157
  def is_explain_synthesis_request(q: str) -> bool:
158
  """
159
- EXPLAIN = synthèse extractive si :
160
- - le texte contient des marqueurs explicites de synthèse/points clés/extraits
161
- ET
162
- - un ID d'article est présent
163
  """
164
  ql = (q or "").lower()
165
  return any(t in ql for t in EXPLAIN_TRIGGERS)
@@ -182,6 +186,67 @@ def get_vectorstore() -> FAISS:
182
  return _VS
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  # ==================== EXTRACTIVE SUMMARY (FAST) ====================
186
 
187
  _NORMATIVE_PATTERNS = [
@@ -273,12 +338,6 @@ def _truncate(s: str, n: int) -> str:
273
 
274
 
275
  def build_qa_prompt_fast(question: str, context: str, sources: List[str]) -> str:
276
- """
277
- Prompt QA volontairement plus léger :
278
- - on autorise une réponse "interprétative"
279
- - on demande de rester aligné sur le contexte, sans prétendre à l'exactitude parfaite
280
- - pas d'obligation de format strict qui pourrait provoquer des refus
281
- """
282
  src = ", ".join(sources)
283
  return f"""Tu es un assistant qui aide à comprendre le Code de l'éducation (France).
284
 
@@ -311,16 +370,61 @@ def answer_query(q: str) -> Dict[str, Any]:
311
  text = load_article_text(article_id)
312
  return {"mode": "FULLTEXT", "answer": text or _REFUSAL, "articles": [article_id]}
313
 
314
- # ---------- LIST ----------
315
  if is_list_request(q):
316
  vs = get_vectorstore()
317
- docs = vs.similarity_search(q, k=5)
318
- arts = list({normalize_article_id(d.metadata.get("article_id", "")) for d in docs})
319
- return {"mode": "LIST", "answer": "", "articles": arts}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  # ---------- EXPLAIN (SYNTHÈSE extractive) ----------
322
- # On déclenche EXPLAIN uniquement si la demande explicite "synthèse/points clés/extraits"
323
- # + ID article. Sinon, les "explique-moi..." partent en QA (interprétation).
324
  if is_explain_synthesis_request(q):
325
  if not article_id:
326
  return {"mode": "EXPLAIN", "answer": _EXPLAIN_REFUSAL, "articles": []}
@@ -333,14 +437,10 @@ def answer_query(q: str) -> Dict[str, Any]:
333
  return {"mode": "EXPLAIN", "answer": summary, "articles": [article_id]}
334
 
335
  # ---------- QA (FAST) ----------
336
- # Inclut :
337
- # - questions ouvertes ("Un chef d'établissement peut-il...")
338
- # - "explique-moi en termes simples l'article X" => QA (interprétation)
339
  vs = get_vectorstore()
340
  docs = vs.similarity_search(q, k=max(1, QA_TOP_K_FINAL))
341
  sources = [normalize_article_id(d.metadata.get("article_id", "")) for d in docs]
342
 
343
- # Contexte tronqué pour réduire latence CPU
344
  ctx_parts: List[str] = []
345
  for d in docs[:max(1, QA_TOP_K_FINAL)]:
346
  aid = normalize_article_id(d.metadata.get("article_id", "UNKNOWN"))
@@ -352,7 +452,6 @@ def answer_query(q: str) -> Dict[str, Any]:
352
  prompt = build_qa_prompt_fast(q, context, sources)
353
  ans = llm_generate_qa(prompt).strip()
354
 
355
- # On ajoute un avertissement clair au-dessus
356
  final = f"{_QA_WARNING}\n\n{ans}"
357
 
358
  return {"mode": "QA", "answer": final, "articles": sources}
 
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)
 
33
 
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
 
61
  flags=re.IGNORECASE,
62
  )
63
 
64
+ # --- Triggers ---
 
 
65
  EXPLAIN_TRIGGERS = [
66
  "synthèse", "synthese", "points clés", "points cles",
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",
 
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)
 
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", "à",
193
+ "au", "aux", "dans", "sur", "pour", "par", "avec", "sans", "ce", "cet",
194
+ "cette", "ces", "qui", "que", "quoi", "dont", "est", "sont", "être",
195
+ "peut", "peuvent", "doit", "doivent", "article", "articles",
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:
230
+ if t in seen:
231
+ continue
232
+ seen.add(t)
233
+ uniq.append(t)
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()
241
+ hits = 0
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 = [
 
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).
343
 
 
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": []}
 
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]
443
 
 
444
  ctx_parts: List[str] = []
445
  for d in docs[:max(1, QA_TOP_K_FINAL)]:
446
  aid = normalize_article_id(d.metadata.get("article_id", "UNKNOWN"))
 
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}