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

Refactor UI and RAG modes: extractive synthesis for EXPLAIN, faster QA with warnings

Browse files
Files changed (2) hide show
  1. app.py +87 -89
  2. src/rag_core.py +126 -137
app.py CHANGED
@@ -88,35 +88,47 @@ except Exception as e:
88
 
89
 
90
  # ----------------------------
91
- # Helpers
92
  # ----------------------------
93
 
 
 
 
 
 
 
 
 
94
  def _format_result(result) -> str:
95
  """
96
- Formats output robustly WITHOUT assuming a strict schema.
97
- We do NOT modify any RAG logic, just display what comes back.
98
  """
99
  if result is None:
100
- return "Aucune réponse (result=None)."
101
 
102
  if isinstance(result, str):
103
- return result
104
 
105
  if isinstance(result, dict):
106
- parts = []
107
- if "mode" in result:
108
- parts.append(f"Mode: {result['mode']}")
109
- if "answer" in result:
110
- parts.append(str(result["answer"]))
111
- elif "response" in result:
112
- parts.append(str(result["response"]))
113
- else:
114
- parts.append(str(result))
115
-
116
- for k in ["sources", "citations", "articles", "context_used", "context"]:
117
- if k in result and result[k]:
118
- parts.append(f"\n\n---\n{k}:\n{result[k]}")
119
- return "\n\n".join(parts)
 
 
 
 
120
 
121
  if isinstance(result, (tuple, list)):
122
  return "\n\n".join([str(x) for x in result])
@@ -127,12 +139,12 @@ def _format_result(result) -> str:
127
  def call_core(query: str) -> str:
128
  q = (query or "").strip()
129
  if not q:
130
- return "Entre une demande (vide = rien à traiter)."
131
  try:
132
  result = rag_core.answer_query(q) # validated logic
133
  return _format_result(result)
134
  except Exception:
135
- return "Erreur côté application (pas côté utilisateur):\n\n" + traceback.format_exc()
136
 
137
 
138
  # ----------------------------
@@ -142,10 +154,11 @@ def call_core(query: str) -> str:
142
  def tab_list(theme: str) -> str:
143
  t = (theme or "").strip()
144
  if not t:
145
- return "Entre un thème (ex : vacances scolaires, obligation scolaire, conseil de classe)."
146
  # Force LIST trigger
147
  return call_core(f"Quels articles parlent de {t} ?")
148
 
 
149
  def tab_fulltext(article_id: str) -> str:
150
  a = (article_id or "").strip()
151
  if not a:
@@ -153,29 +166,25 @@ def tab_fulltext(article_id: str) -> str:
153
  # Force FULLTEXT trigger
154
  return call_core(f"Donne l’intégralité de l’article {a}")
155
 
156
- def tab_explain(article_id: str, level: str) -> str:
 
157
  a = (article_id or "").strip()
158
  if not a:
159
  return "Entre un identifiant d’article (ex : D422-5)."
160
- lvl = (level or "simple").strip().lower()
161
- if lvl == "très simple":
162
- prompt = f"Explique en termes très simples l’article {a}."
163
- elif lvl == "détaillé":
164
- prompt = f"Explique l’article {a} de façon détaillée mais concise."
165
- else:
166
- prompt = f"Explique en termes simples l’article {a}."
167
- # Still RAG: rag_core will retrieve the article and enforce citations/refusal rules
168
- return call_core(prompt)
169
-
170
- def tab_advanced(question: str) -> str:
171
  q = (question or "").strip()
172
  if not q:
173
  return "Entre une question."
174
- # Free QA (slow) – clearly labeled in UI
175
  return call_core(q)
176
 
 
177
  def clear_all():
178
- return "", "", "", "simple", "", ""
179
 
180
 
181
  # ----------------------------
@@ -191,63 +200,60 @@ body, .gradio-container {
191
  font-size: 15px;
192
  line-height: 1.5;
193
  }
194
- h1, h2, h3 {
195
- font-weight: 600;
196
- letter-spacing: -0.01em;
197
- }
198
  .gradio-container {
199
  max-width: 980px !important;
200
  }
201
  #answer textarea {
202
- max-height: 420px !important;
203
  overflow-y: auto !important;
204
  font-size: 14px;
205
  line-height: 1.55;
206
  }
207
- .wrap {
208
- gap: 0.6rem !important;
 
209
  }
210
  """
211
 
212
  THEME = gr.themes.Soft()
213
 
214
- with gr.Blocks(title="Assistant Code de l’éducation (RAG)") as demo:
215
  gr.Markdown(
216
  """
217
- # Assistant Code de l’éducation
218
- Cet outil recherche dans le Code de l’éducation et répond **uniquement** à partir des articles retrouvés.
219
 
220
- ### Pour de meilleures performances (recommandé)
221
- 1) **Commencez par lister les articles** (onglet “Trouver des articles”)
222
- 2) **Consultez le texte exact** d’un article (onglet “Texte exact d’un article”)
223
- 3) **Demandez une explication uniquement sur un article précis** (onglet “Expliquer un article”)
224
 
225
- > Le mode “Question avancée” est **lent sur CPU**. Utilisez-le seulement si nécessaire.
 
 
 
 
 
226
  """.strip()
227
  )
228
 
229
  gr.Markdown(
230
  """
231
- > **Information importante**
232
- > Lors du premier lancement, l’application peut nécessiter 1 à 2 minutes d’initialisation.
233
- > Ensuite, l’utilisation est immédiate.
234
- > En cas d’utilisation simultanée, les demandes sont traitées successivement afin de garantir la fiabilité des réponses.
235
  """.strip()
236
  )
237
 
238
  with gr.Tabs():
239
- with gr.Tab("Trouver des articles (rapide)"):
240
  list_inp = gr.Textbox(
241
  label="Thème",
242
- placeholder="Ex : vacances scolaires, obligation scolaire, conseil de classe…",
243
  lines=1,
244
  )
245
  list_btn = gr.Button("Lister les articles", variant="primary")
246
  gr.Markdown(
247
- "Astuce : ce mode est le plus rapide. Il sert à identifier les bons articles avant tout le reste."
248
  )
249
 
250
- with gr.Tab("Texte exact d’un article (rapide)"):
251
  full_inp = gr.Textbox(
252
  label="Identifiant d’article",
253
  placeholder="Ex : D422-5",
@@ -255,40 +261,33 @@ Cet outil recherche dans le Code de l’éducation et répond **uniquement** à
255
  )
256
  full_btn = gr.Button("Afficher le texte exact", variant="primary")
257
  gr.Markdown(
258
- "Astuce : utilise ce mode pour obtenir une citation exacte (zéro hallucination)."
259
  )
260
 
261
- with gr.Tab("Expliquer un article (plus lent)"):
262
- exp_inp = gr.Textbox(
263
  label="Identifiant d’article",
264
  placeholder="Ex : D422-5",
265
  lines=1,
266
  )
267
- exp_level = gr.Dropdown(
268
- label="Niveau d’explication",
269
- choices=["simple", "très simple", "détaillé"],
270
- value="simple",
271
- )
272
- exp_btn = gr.Button("Expliquer (LLM)", variant="primary")
273
  gr.Markdown(
274
- "Important : ce mode appelle le LLM c’est plus lent sur CPU. "
275
- "Pour de bonnes performances, reste sur **un article précis**."
276
  )
277
 
278
- with gr.Tab("Question avancée (lent)"):
279
- adv_inp = gr.Textbox(
280
  label="Votre question",
281
- placeholder="Ex : Un chef d’établissement peut-il organiser un conseil de classe après 18 h ?",
282
- lines=2,
283
- max_lines=4,
284
  )
285
- adv_btn = gr.Button("Poser la question (lent)", variant="secondary")
286
  gr.Markdown(
287
- "Ce mode peut être lent sur CPU et peut refuser si les articles retrouvés ne suffisent pas."
288
  )
289
 
290
- # Shared output (single place to read answers)
291
- out = gr.Textbox(label="Réponse", elem_id="answer", lines=12, max_lines=18)
292
 
293
  with gr.Row():
294
  clear = gr.Button("Effacer", variant="secondary")
@@ -296,27 +295,26 @@ Cet outil recherche dans le Code de l’éducation et répond **uniquement** à
296
  # Wire actions
297
  list_btn.click(tab_list, inputs=list_inp, outputs=out)
298
  full_btn.click(tab_fulltext, inputs=full_inp, outputs=out)
299
- exp_btn.click(tab_explain, inputs=[exp_inp, exp_level], outputs=out)
300
- adv_btn.click(tab_advanced, inputs=adv_inp, outputs=out)
 
301
  clear.click(
302
  clear_all,
303
- outputs=[list_inp, full_inp, exp_inp, exp_level, adv_inp, out],
304
  )
305
 
306
  with gr.Accordion("Exemples", open=False):
307
  gr.Markdown(
308
- "- Trouver : `vacances scolaires`\n"
309
- "- Texte exact : `D422-5`\n"
310
- "- Expliquer : `D422-5`\n"
311
- "- Avancé : `Quelles sont les conditions de nomination d'un chef d'établissement ? Cite uniquement les articles fournis.`"
 
312
  )
313
 
314
 
315
  if __name__ == "__main__":
316
- # Pass css/theme to launch (Gradio 6.x)
317
  demo.launch(
318
  server_name="0.0.0.0",
319
  server_port=7860,
320
- css=CSS,
321
- theme=THEME,
322
  )
 
88
 
89
 
90
  # ----------------------------
91
+ # Helpers (display only)
92
  # ----------------------------
93
 
94
+ def _render_list(articles) -> str:
95
+ if not articles:
96
+ return "Aucun article trouvé."
97
+ arts = [str(a).strip() for a in articles if str(a).strip()]
98
+ arts = sorted(set(arts))
99
+ return "Articles proposés :\n" + "\n".join([f"- {a}" for a in arts])
100
+
101
+
102
  def _format_result(result) -> str:
103
  """
104
+ Formats output robustly WITHOUT changing RAG logic.
105
+ We keep a minimal/pro feel, but allow debugging via mode.
106
  """
107
  if result is None:
108
+ return "Aucune réponse."
109
 
110
  if isinstance(result, str):
111
+ return result.strip() or "Aucune réponse."
112
 
113
  if isinstance(result, dict):
114
+ mode = str(result.get("mode", "")).strip()
115
+ answer = result.get("answer", result.get("response", ""))
116
+ answer = "" if answer is None else str(answer).strip()
117
+ articles = result.get("articles") or []
118
+
119
+ # LIST is usually answer="" and only articles
120
+ if mode.upper() == "LIST":
121
+ return _render_list(articles)
122
+
123
+ # FULLTEXT / EXPLAIN / QA => show answer
124
+ footer_parts = []
125
+ if mode:
126
+ footer_parts.append(f"Mode : {mode}")
127
+ if articles:
128
+ footer_parts.append("Articles : " + ", ".join([str(a) for a in articles]))
129
+
130
+ footer = ("\n\n—\n" + " | ".join(footer_parts)) if footer_parts else ""
131
+ return (answer if answer else "Aucune réponse.") + footer
132
 
133
  if isinstance(result, (tuple, list)):
134
  return "\n\n".join([str(x) for x in result])
 
139
  def call_core(query: str) -> str:
140
  q = (query or "").strip()
141
  if not q:
142
+ return "Entre une demande."
143
  try:
144
  result = rag_core.answer_query(q) # validated logic
145
  return _format_result(result)
146
  except Exception:
147
+ return "Erreur côté application :\n\n" + traceback.format_exc()
148
 
149
 
150
  # ----------------------------
 
154
  def tab_list(theme: str) -> str:
155
  t = (theme or "").strip()
156
  if not t:
157
+ return "Entre un thème (ex : vacances scolaires, conseil de classe, obligation scolaire)."
158
  # Force LIST trigger
159
  return call_core(f"Quels articles parlent de {t} ?")
160
 
161
+
162
  def tab_fulltext(article_id: str) -> str:
163
  a = (article_id or "").strip()
164
  if not a:
 
166
  # Force FULLTEXT trigger
167
  return call_core(f"Donne l’intégralité de l’article {a}")
168
 
169
+
170
+ def tab_synthese(article_id: str) -> str:
171
  a = (article_id or "").strip()
172
  if not a:
173
  return "Entre un identifiant d’article (ex : D422-5)."
174
+ # IMPORTANT: triggers EXPLAIN in the new rag_core (synthèse/points clés/extraits)
175
+ return call_core(f"Synthèse (points clés) de l’article {a}")
176
+
177
+
178
+ def tab_qa(question: str) -> str:
 
 
 
 
 
 
179
  q = (question or "").strip()
180
  if not q:
181
  return "Entre une question."
182
+ # Free QA (slower, interpretative) – warning is added by rag_core
183
  return call_core(q)
184
 
185
+
186
  def clear_all():
187
+ return "", "", "", "", ""
188
 
189
 
190
  # ----------------------------
 
200
  font-size: 15px;
201
  line-height: 1.5;
202
  }
 
 
 
 
203
  .gradio-container {
204
  max-width: 980px !important;
205
  }
206
  #answer textarea {
207
+ max-height: 480px !important;
208
  overflow-y: auto !important;
209
  font-size: 14px;
210
  line-height: 1.55;
211
  }
212
+ .small-note {
213
+ font-size: 13px;
214
+ opacity: 0.9;
215
  }
216
  """
217
 
218
  THEME = gr.themes.Soft()
219
 
220
+ with gr.Blocks(title="Code de l’éducation — Assistant (RAG)", css=CSS, theme=THEME) as demo:
221
  gr.Markdown(
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
  )
243
 
244
  with gr.Tabs():
245
+ with gr.Tab("Trouver des articles"):
246
  list_inp = gr.Textbox(
247
  label="Thème",
248
+ placeholder="Ex : vacances scolaires, conseil de classe, obligation scolaire…",
249
  lines=1,
250
  )
251
  list_btn = gr.Button("Lister les articles", variant="primary")
252
  gr.Markdown(
253
+ "<div class='small-note'>Conseil : commence presque toujours par ici. C’est le plus rapide.</div>"
254
  )
255
 
256
+ with gr.Tab("Texte exact"):
257
  full_inp = gr.Textbox(
258
  label="Identifiant d’article",
259
  placeholder="Ex : D422-5",
 
261
  )
262
  full_btn = gr.Button("Afficher le texte exact", variant="primary")
263
  gr.Markdown(
264
+ "<div class='small-note'>Le texte exact est la référence. À utiliser pour vérifier toute interprétation.</div>"
265
  )
266
 
267
+ with gr.Tab("Synthèse d’un article"):
268
+ syn_inp = gr.Textbox(
269
  label="Identifiant d’article",
270
  placeholder="Ex : D422-5",
271
  lines=1,
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)"):
279
+ qa_inp = gr.Textbox(
280
  label="Votre question",
281
+ placeholder="Ex : Un chef d’établissement peut-il organiser un conseil de classe après 19h ?",
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)
 
291
 
292
  with gr.Row():
293
  clear = gr.Button("Effacer", variant="secondary")
 
295
  # Wire actions
296
  list_btn.click(tab_list, inputs=list_inp, outputs=out)
297
  full_btn.click(tab_fulltext, inputs=full_inp, outputs=out)
298
+ syn_btn.click(tab_synthese, inputs=syn_inp, outputs=out)
299
+ qa_btn.click(tab_qa, inputs=qa_inp, outputs=out)
300
+
301
  clear.click(
302
  clear_all,
303
+ outputs=[list_inp, full_inp, syn_inp, qa_inp, out],
304
  )
305
 
306
  with gr.Accordion("Exemples", open=False):
307
  gr.Markdown(
308
+ "- **Trouver des articles** : `vacances scolaires`\n"
309
+ "- **Texte exact** : `D422-5`\n"
310
+ "- **Synthèse** : `D422-5`\n"
311
+ "- **QA** : `Un chef d’établissement peut-il organiser un conseil de classe après 19h ?`\n"
312
+ "- **QA** : `Explique-moi en termes simples l’article D422-5.`"
313
  )
314
 
315
 
316
  if __name__ == "__main__":
 
317
  demo.launch(
318
  server_name="0.0.0.0",
319
  server_port=7860,
 
 
320
  )
src/rag_core.py CHANGED
@@ -2,27 +2,22 @@
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
- rag_core.py – EXPLAIN ultra rapide via résumé extractif (text mining)
6
-
7
- Objectif :
8
- - LIST & FULLTEXT restent instantanés (pas de LLM)
9
- - EXPLAIN devient très rapide : extraction de 3–6 segments clés de l’article
10
- - QA reste possible (LLM), mais lent (CPU)
11
-
12
- Principe EXPLAIN :
13
- - ID d’article obligatoire, sinon refus.
14
- - On charge le texte exact de l’article depuis chunks_articles.jsonl
15
- - On produit une "explication" par extraction (aucune génération) -> zéro hallucination
16
- - Optionnel : reformulation LLM sur le résumé (désactivé par défaut)
17
-
18
- Ce fichier remplace le précédent (qui envoyait l’article intégral au LLM en EXPLAIN).
19
  """
20
 
21
  import json
22
  import os
23
  import re
24
  from pathlib import Path
25
- from typing import List, Optional, Dict, Any
26
 
27
  from langchain_community.vectorstores import FAISS
28
  from langchain_huggingface import HuggingFaceEmbeddings
@@ -36,25 +31,37 @@ DB_DIR = Path("db/faiss_code_edu_by_article")
36
 
37
  EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
38
 
39
- TOP_K_FINAL = 3
40
-
41
  SNIPPET_CHARS = 260
42
 
43
- # --- Résumé extractif ---
44
- EXTRACT_MAX_SEGMENTS = 5 # nb max de segments extraits
45
- EXTRACT_MAX_CHARS_TOTAL = 900 # garde-fou (résumé total)
46
- EXTRACT_MIN_SEG_LEN = 30 # ignore segments trop courts
47
- EXTRACT_MAX_SEG_LEN = 420 # tronque segments trop longs
48
 
49
- # option : reformulation LLM sur résumé extractif (OFF par défaut)
50
- EXPLAIN_USE_LLM = os.environ.get("EXPLAIN_USE_LLM", "0").strip() == "1"
 
 
 
51
 
52
  ARTICLE_ID_RE = re.compile(
53
  r"\b(?:article\s+)?([LDR]\s?\d{1,4}(?:[.-]\d+){0,4})\b",
54
  flags=re.IGNORECASE,
55
  )
56
 
 
 
 
57
  EXPLAIN_TRIGGERS = [
 
 
 
 
 
 
 
 
58
  "explique", "expliquer", "explication",
59
  "résume", "resume", "résumé", "reformule", "simplifie",
60
  "en termes simples", "vulgarise", "clarifie",
@@ -74,17 +81,23 @@ FULLTEXT_TRIGGERS = [
74
  ]
75
 
76
  _REFUSAL = "Je ne peux pas répondre avec certitude à partir des articles fournis."
 
77
  _EXPLAIN_REFUSAL = (
78
- "Pour expliquer ou résumer, indique un identifiant d’article (ex : D422-5). "
79
  "Sinon, commence par : \"Quels articles parlent de … ?\""
80
  )
81
 
82
- # ==================== LLM INIT (QA + option EXPLAIN LLM) ====================
 
 
 
83
 
84
- # Le LLM est utile pour QA. Pour EXPLAIN "très vite", on le désactive par défaut.
 
 
85
  llm = Llama(
86
  model_path="models/model.gguf",
87
- n_ctx=1024, # réduit pour CPU
88
  n_threads=10,
89
  n_batch=128,
90
  verbose=False,
@@ -94,21 +107,8 @@ llm = Llama(
94
  def llm_generate_qa(prompt: str) -> str:
95
  out = llm.create_chat_completion(
96
  messages=[{"role": "user", "content": prompt}],
97
- temperature=0.1,
98
- max_tokens=120,
99
- )
100
- return out["choices"][0]["message"]["content"].strip()
101
-
102
-
103
- def llm_generate_explain_from_summary(prompt: str) -> str:
104
- """
105
- Reformulation optionnelle du résumé extractif.
106
- On reste court pour ne pas exploser la latence CPU.
107
- """
108
- out = llm.create_chat_completion(
109
- messages=[{"role": "user", "content": prompt}],
110
- temperature=0.2,
111
- max_tokens=160,
112
  )
113
  return out["choices"][0]["message"]["content"].strip()
114
 
@@ -124,21 +124,6 @@ def extract_article_id(q: str) -> Optional[str]:
124
  return normalize_article_id(m.group(1)) if m else None
125
 
126
 
127
- def is_explain_request(q: str) -> bool:
128
- ql = (q or "").lower()
129
- return any(t in ql for t in EXPLAIN_TRIGGERS)
130
-
131
-
132
- def is_list_request(q: str) -> bool:
133
- ql = (q or "").lower()
134
- return any(t in ql for t in LIST_TRIGGERS)
135
-
136
-
137
- def is_fulltext_request(q: str) -> bool:
138
- ql = (q or "").lower()
139
- return any(t in ql for t in FULLTEXT_TRIGGERS)
140
-
141
-
142
  def safe_snippet(text: str, n: int) -> str:
143
  t = " ".join((text or "").split())
144
  return t if len(t) <= n else t[:n].rstrip() + "…"
@@ -159,6 +144,27 @@ def load_article_text(article_id: str) -> Optional[str]:
159
  return None
160
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  # ==================== VECTORSTORE ====================
163
 
164
  _VS: Optional[FAISS] = None
@@ -179,34 +185,23 @@ def get_vectorstore() -> FAISS:
179
  # ==================== EXTRACTIVE SUMMARY (FAST) ====================
180
 
181
  _NORMATIVE_PATTERNS = [
182
- # Verbes normatifs / obligations
183
  r"\bdoit\b", r"\bdoivent\b", r"\best\b", r"\bsont\b",
184
  r"\bpeut\b", r"\bpeuvent\b",
185
  r"\best tenu\b", r"\bsont tenus\b", r"\best tenu de\b",
186
- r"\best interdit\b", r"\bsont interdits\b", r"\bil est interdit\b",
187
  r"\bobligatoire\b", r"\bobligation\b",
188
- # Conditions / exceptions
189
  r"\bsi\b", r"\blorsque\b", r"\bsauf\b", r"\bà condition\b", r"\ba condition\b",
190
  r"\bdans le cas\b", r"\ben cas\b", r"\btoutefois\b",
191
- # Structure
192
  r"\bI\.\b", r"\bII\.\b", r"\bIII\.\b", r"\b1°\b", r"\b2°\b", r"\b3°\b",
193
  ]
194
 
195
 
196
  def _split_into_segments(text: str) -> List[str]:
197
- """
198
- Découpe grossière mais robuste pour du juridique :
199
- - on coupe par lignes / alinéas
200
- - puis on recoupe si lignes trop longues via ; .
201
- """
202
  if not text:
203
  return []
204
-
205
- # 1) alinéas
206
  lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
207
  segs: List[str] = []
208
  for ln in lines:
209
- # 2) recoupe douce
210
  if len(ln) > 600:
211
  parts = re.split(r"(?<=[.;:])\s+", ln)
212
  segs.extend([p.strip() for p in parts if p.strip()])
@@ -221,23 +216,21 @@ def _score_segment(seg: str) -> int:
221
  for pat in _NORMATIVE_PATTERNS:
222
  if re.search(pat, low, flags=re.IGNORECASE):
223
  s += 2
224
- # bonus si segment contient des marqueurs juridiques
225
  if re.search(r"\b(décret|arrêté|loi|code)\b", low):
226
  s += 1
227
- # pénalité si segment trop long (moins lisible)
228
  if len(seg) > 450:
229
  s -= 1
230
  return s
231
 
232
 
233
- def extractive_explain(article_id: str, article_text: str) -> str:
234
  """
235
- Produit une 'explication' très rapide :
236
  - sélection de segments clés (extraction)
237
  - aucune génération => zéro hallucination
238
  """
239
  segs = _split_into_segments(article_text)
240
- cleaned = []
241
  for s in segs:
242
  s = " ".join(s.split())
243
  if len(s) < EXTRACT_MIN_SEG_LEN:
@@ -247,16 +240,14 @@ def extractive_explain(article_id: str, article_text: str) -> str:
247
  cleaned.append(s)
248
 
249
  if not cleaned:
250
- return f"Résumé impossible : texte vide ou non exploitable.\n\nArticles cités : {article_id}"
251
 
252
  scored = sorted((( _score_segment(s), s) for s in cleaned), key=lambda x: x[0], reverse=True)
253
 
254
- # garde ceux qui ont un score positif, sinon fallback sur les premiers segments
255
  picked = [s for (sc, s) in scored if sc > 0][:EXTRACT_MAX_SEGMENTS]
256
  if not picked:
257
  picked = cleaned[:min(EXTRACT_MAX_SEGMENTS, len(cleaned))]
258
 
259
- # garde-fou longueur totale
260
  out_parts = []
261
  total = 0
262
  for s in picked:
@@ -266,36 +257,35 @@ def extractive_explain(article_id: str, article_text: str) -> str:
266
  total += len(s)
267
 
268
  body = (
269
- "Points clés (extraction du texte, sans reformulation) :\n"
270
  + "\n".join(out_parts)
271
  )
272
  return f"{body}\n\nArticles cités : {article_id}"
273
 
274
 
275
- def build_explain_llm_prompt(article_id: str, extractive_summary: str) -> str:
276
- """
277
- Reformulation LLM optionnelle sur RÉSUMÉ COURT (pas sur l’article intégral).
278
- """
279
- return f"""Tu es un assistant pédagogique. Tu dois reformuler en termes simples le contenu fourni.
280
- Interdictions : rien inventer, rien ajouter, pas d’autres articles.
281
- Tu dois rester fidèle aux points ci-dessous.
282
 
283
- CONTENU (extrait du texte) :
284
- {extractive_summary}
285
-
286
- Donne une explication en 4–6 phrases maximum.
287
- Dernière ligne : Articles cités : {article_id}
288
- """
289
 
290
 
291
- def build_qa_prompt(question: str, context: str, allowed: List[str]) -> str:
292
- return f"""
293
- Tu es un assistant juridique spécialisé dans le Code de l'éducation.
 
 
 
 
 
 
294
 
295
- RÈGLES STRICTES :
296
- - Tu réponds uniquement à partir du contexte
297
- - Tu cites uniquement : {", ".join(allowed)}
298
- - Sinon tu refuses
299
 
300
  QUESTION :
301
  {question}
@@ -303,10 +293,8 @@ QUESTION :
303
  CONTEXTE :
304
  {context}
305
 
306
- FORMAT FINAL :
307
- Réponse courte.
308
- Dernière ligne : Articles cités : A, B
309
- """.strip()
310
 
311
 
312
  # ==================== CORE ====================
@@ -318,33 +306,6 @@ def answer_query(q: str) -> Dict[str, Any]:
318
 
319
  article_id = extract_article_id(q)
320
 
321
- # ---------- EXPLAIN (FAST) ----------
322
- if is_explain_request(q):
323
- if not article_id:
324
- return {"mode": "EXPLAIN", "answer": _EXPLAIN_REFUSAL, "articles": []}
325
-
326
- text = load_article_text(article_id)
327
- if not text:
328
- return {"mode": "EXPLAIN", "answer": f"Article {article_id} introuvable.", "articles": []}
329
-
330
- # 1) explication immédiate par extraction (très rapide)
331
- extractive = extractive_explain(article_id, text)
332
-
333
- # 2) optionnel : mini reformulation LLM sur le résumé (pas sur l’article)
334
- if EXPLAIN_USE_LLM:
335
- try:
336
- prompt = build_explain_llm_prompt(article_id, extractive)
337
- llm_ans = llm_generate_explain_from_summary(prompt).strip()
338
- # garantie citation
339
- if "Articles cités" not in llm_ans:
340
- llm_ans = llm_ans.rstrip() + f"\n\nArticles cités : {article_id}"
341
- return {"mode": "EXPLAIN", "answer": llm_ans, "articles": [article_id]}
342
- except Exception:
343
- # fallback extractif si souci LLM
344
- return {"mode": "EXPLAIN", "answer": extractive, "articles": [article_id]}
345
-
346
- return {"mode": "EXPLAIN", "answer": extractive, "articles": [article_id]}
347
-
348
  # ---------- FULLTEXT ----------
349
  if article_id and is_fulltext_request(q):
350
  text = load_article_text(article_id)
@@ -357,13 +318,41 @@ def answer_query(q: str) -> Dict[str, Any]:
357
  arts = list({normalize_article_id(d.metadata.get("article_id", "")) for d in docs})
358
  return {"mode": "LIST", "answer": "", "articles": arts}
359
 
360
- # ---------- QA ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  vs = get_vectorstore()
362
- docs = vs.similarity_search(q, k=TOP_K_FINAL)
363
- context = "\n\n".join(d.page_content for d in docs)
364
- articles = [normalize_article_id(d.metadata.get("article_id", "")) for d in docs]
 
 
 
 
 
 
 
 
 
 
 
365
 
366
- prompt = build_qa_prompt(q, context, articles)
367
- answer = llm_generate_qa(prompt)
368
 
369
- return {"mode": "QA", "answer": answer, "articles": articles}
 
2
  # -*- coding: utf-8 -*-
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)
10
+
11
+ Notes produit :
12
+ - EXPLAIN = synthèse (extraits du texte), déterministe -> zéro hallucination
13
+ - QA = interprétation/réponse rédigée par LLM (CPU) -> plus lent, à vérifier sur les textes
 
 
 
 
 
14
  """
15
 
16
  import json
17
  import os
18
  import re
19
  from pathlib import Path
20
+ from typing import List, Optional, Dict, Any, Tuple
21
 
22
  from langchain_community.vectorstores import FAISS
23
  from langchain_huggingface import HuggingFaceEmbeddings
 
31
 
32
  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
39
+ EXTRACT_MIN_SEG_LEN = 30
40
+ EXTRACT_MAX_SEG_LEN = 420
41
 
42
+ # --- QA : accélération ---
43
+ QA_TOP_K_FINAL = int(os.environ.get("QA_TOP_K_FINAL", "2")) # 1 ou 2 conseillé sur CPU
44
+ QA_DOC_MAX_CHARS = int(os.environ.get("QA_DOC_MAX_CHARS", "700")) # tronque le contexte envoyé au LLM
45
+ QA_MAX_TOKENS = int(os.environ.get("QA_MAX_TOKENS", "140")) # court
46
+ QA_TEMPERATURE = float(os.environ.get("QA_TEMPERATURE", "0.1")) # stable
47
 
48
  ARTICLE_ID_RE = re.compile(
49
  r"\b(?:article\s+)?([LDR]\s?\d{1,4}(?:[.-]\d+){0,4})\b",
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",
67
  "en termes simples", "vulgarise", "clarifie",
 
81
  ]
82
 
83
  _REFUSAL = "Je ne peux pas répondre avec certitude à partir des articles fournis."
84
+
85
  _EXPLAIN_REFUSAL = (
86
+ "Pour produire une synthèse extractive, indique un identifiant d’article (ex : D422-5). "
87
  "Sinon, commence par : \"Quels articles parlent de … ?\""
88
  )
89
 
90
+ _QA_WARNING = (
91
+ "Mode QA (interprétation) : la réponse ci-dessous est rédigée par un modèle IA sur CPU. "
92
+ "Elle peut être incomplète ou imprécise. Vérifie toujours sur le texte exact des articles."
93
+ )
94
 
95
+
96
+ # ==================== LLM INIT ====================
97
+ # n_ctx réduit pour accélérer QA sur CPU.
98
  llm = Llama(
99
  model_path="models/model.gguf",
100
+ n_ctx=1024,
101
  n_threads=10,
102
  n_batch=128,
103
  verbose=False,
 
107
  def llm_generate_qa(prompt: str) -> str:
108
  out = llm.create_chat_completion(
109
  messages=[{"role": "user", "content": prompt}],
110
+ temperature=QA_TEMPERATURE,
111
+ max_tokens=QA_MAX_TOKENS,
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  )
113
  return out["choices"][0]["message"]["content"].strip()
114
 
 
124
  return normalize_article_id(m.group(1)) if m else None
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def safe_snippet(text: str, n: int) -> str:
128
  t = " ".join((text or "").split())
129
  return t if len(t) <= n else t[:n].rstrip() + "…"
 
144
  return None
145
 
146
 
147
+ def is_list_request(q: str) -> bool:
148
+ ql = (q or "").lower()
149
+ return any(t in ql for t in LIST_TRIGGERS)
150
+
151
+
152
+ def is_fulltext_request(q: str) -> bool:
153
+ ql = (q or "").lower()
154
+ return any(t in ql for t in FULLTEXT_TRIGGERS)
155
+
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)
166
+
167
+
168
  # ==================== VECTORSTORE ====================
169
 
170
  _VS: Optional[FAISS] = None
 
185
  # ==================== EXTRACTIVE SUMMARY (FAST) ====================
186
 
187
  _NORMATIVE_PATTERNS = [
 
188
  r"\bdoit\b", r"\bdoivent\b", r"\best\b", r"\bsont\b",
189
  r"\bpeut\b", r"\bpeuvent\b",
190
  r"\best tenu\b", r"\bsont tenus\b", r"\best tenu de\b",
191
+ r"\best interdit\b", r"\bil est interdit\b",
192
  r"\bobligatoire\b", r"\bobligation\b",
 
193
  r"\bsi\b", r"\blorsque\b", r"\bsauf\b", r"\bà condition\b", r"\ba condition\b",
194
  r"\bdans le cas\b", r"\ben cas\b", r"\btoutefois\b",
 
195
  r"\bI\.\b", r"\bII\.\b", r"\bIII\.\b", r"\b1°\b", r"\b2°\b", r"\b3°\b",
196
  ]
197
 
198
 
199
  def _split_into_segments(text: str) -> List[str]:
 
 
 
 
 
200
  if not text:
201
  return []
 
 
202
  lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
203
  segs: List[str] = []
204
  for ln in lines:
 
205
  if len(ln) > 600:
206
  parts = re.split(r"(?<=[.;:])\s+", ln)
207
  segs.extend([p.strip() for p in parts if p.strip()])
 
216
  for pat in _NORMATIVE_PATTERNS:
217
  if re.search(pat, low, flags=re.IGNORECASE):
218
  s += 2
 
219
  if re.search(r"\b(décret|arrêté|loi|code)\b", low):
220
  s += 1
 
221
  if len(seg) > 450:
222
  s -= 1
223
  return s
224
 
225
 
226
+ def extractive_summary(article_id: str, article_text: str) -> str:
227
  """
228
+ SYNTHÈSE extractive (rapide) :
229
  - sélection de segments clés (extraction)
230
  - aucune génération => zéro hallucination
231
  """
232
  segs = _split_into_segments(article_text)
233
+ cleaned: List[str] = []
234
  for s in segs:
235
  s = " ".join(s.split())
236
  if len(s) < EXTRACT_MIN_SEG_LEN:
 
240
  cleaned.append(s)
241
 
242
  if not cleaned:
243
+ return f"Synthèse impossible : texte vide ou non exploitable.\n\nArticles cités : {article_id}"
244
 
245
  scored = sorted((( _score_segment(s), s) for s in cleaned), key=lambda x: x[0], reverse=True)
246
 
 
247
  picked = [s for (sc, s) in scored if sc > 0][:EXTRACT_MAX_SEGMENTS]
248
  if not picked:
249
  picked = cleaned[:min(EXTRACT_MAX_SEGMENTS, len(cleaned))]
250
 
 
251
  out_parts = []
252
  total = 0
253
  for s in picked:
 
257
  total += len(s)
258
 
259
  body = (
260
+ "Synthèse (extraits du texte, sans reformulation) :\n"
261
  + "\n".join(out_parts)
262
  )
263
  return f"{body}\n\nArticles cités : {article_id}"
264
 
265
 
266
+ # ==================== QA PROMPT (FAST) ====================
 
 
 
 
 
 
267
 
268
+ def _truncate(s: str, n: int) -> str:
269
+ if not s:
270
+ return ""
271
+ s = s.strip()
272
+ return s if len(s) <= n else s[:n].rstrip() + "\n[...]\n"
 
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
 
285
+ CONTRAINTE :
286
+ - Appuie-toi en priorité sur le CONTEXTE fourni.
287
+ - Si l'information n'est pas dans le contexte, dis-le simplement.
288
+ - Réponse courte, pratique, 5-8 phrases max.
289
 
290
  QUESTION :
291
  {question}
 
293
  CONTEXTE :
294
  {context}
295
 
296
+ Indique à la fin : "Sources (articles) : {src}"
297
+ """
 
 
298
 
299
 
300
  # ==================== CORE ====================
 
306
 
307
  article_id = extract_article_id(q)
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  # ---------- FULLTEXT ----------
310
  if article_id and is_fulltext_request(q):
311
  text = load_article_text(article_id)
 
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": []}
327
+
328
+ text = load_article_text(article_id)
329
+ if not text:
330
+ return {"mode": "EXPLAIN", "answer": f"Article {article_id} introuvable.", "articles": []}
331
+
332
+ summary = extractive_summary(article_id, text)
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"))
347
+ txt = _truncate(d.page_content or "", QA_DOC_MAX_CHARS)
348
+ ctx_parts.append(f"[{aid}]\n{txt}")
349
+
350
+ context = "\n\n".join(ctx_parts).strip()
351
+
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}