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

Speed up EXPLAIN: reduce LLM input and allow extractive-style explanation

Browse files
Files changed (1) hide show
  1. src/rag_core.py +197 -82
src/rag_core.py CHANGED
@@ -2,13 +2,27 @@
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
- rag_core.py – version corrigée EXPLAIN
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import json
 
9
  import re
10
  from pathlib import Path
11
- from typing import List, Tuple, Optional, Dict, Iterable, Any
12
 
13
  from langchain_community.vectorstores import FAISS
14
  from langchain_huggingface import HuggingFaceEmbeddings
@@ -22,43 +36,55 @@ 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,
62
  n_threads=10,
63
  n_batch=128,
64
  verbose=False,
@@ -66,7 +92,6 @@ llm = Llama(
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,12 +100,15 @@ def llm_generate_qa(prompt: str) -> str:
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
 
@@ -92,31 +120,42 @@ def normalize_article_id(raw: str) -> str:
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:
105
- ql = q.lower()
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
 
@@ -132,33 +171,120 @@ def get_vectorstore() -> FAISS:
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
 
@@ -180,75 +306,64 @@ CONTEXTE :
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
- }
 
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
 
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",
61
  ]
62
 
63
  LIST_TRIGGERS = [
64
+ "quels articles", "quelles dispositions", "articles parlent",
65
+ "articles qui parlent", "articles sur", "donne les articles",
66
+ "cite les articles", "références", "references",
67
  ]
68
 
69
  FULLTEXT_TRIGGERS = [
70
+ "contenu exact", "texte exact", "texte intégral", "texte integral",
71
+ "intégral", "integral", "cite intégralement", "cite integralement",
72
+ "donne l'intégralité", "donne l'integralite", "recopie", "reproduis",
73
+ "affiche l'article", "donne l'article", "donne moi l'article",
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,
 
92
 
93
 
94
  def llm_generate_qa(prompt: str) -> str:
 
95
  out = llm.create_chat_completion(
96
  messages=[{"role": "user", "content": prompt}],
97
  temperature=0.1,
 
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
 
 
120
 
121
 
122
  def extract_article_id(q: str) -> Optional[str]:
123
+ m = ARTICLE_ID_RE.search(q or "")
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() + "…"
145
+
146
+
147
  def load_article_text(article_id: str) -> Optional[str]:
148
+ if not CHUNKS_PATH.exists():
149
+ raise FileNotFoundError(f"Fichier chunks introuvable : {CHUNKS_PATH}")
150
+
151
  with CHUNKS_PATH.open("r", encoding="utf-8") as f:
152
  for line in f:
153
+ if not line.strip():
154
+ continue
155
  obj = json.loads(line)
156
+ aid = normalize_article_id(obj.get("article_id", ""))
157
+ if aid == article_id:
158
+ return (obj.get("text") or "").strip()
159
  return None
160
 
161
 
 
171
  _VS = FAISS.load_local(
172
  str(DB_DIR),
173
  embeddings,
174
+ allow_dangerous_deserialization=True,
175
  )
176
  return _VS
177
 
178
 
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()])
213
+ else:
214
+ segs.append(ln)
215
+ return segs
216
+
217
+
218
+ def _score_segment(seg: str) -> int:
219
+ s = 0
220
+ low = seg.lower()
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:
244
+ continue
245
+ if len(s) > EXTRACT_MAX_SEG_LEN:
246
+ s = s[:EXTRACT_MAX_SEG_LEN].rstrip() + "…"
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:
263
+ if total + len(s) > EXTRACT_MAX_CHARS_TOTAL and out_parts:
264
+ break
265
+ out_parts.append(f"- {s}")
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
 
 
306
  FORMAT FINAL :
307
  Réponse courte.
308
  Dernière ligne : Articles cités : A, B
309
+ """.strip()
310
 
311
 
312
  # ==================== CORE ====================
313
 
314
  def answer_query(q: str) -> Dict[str, Any]:
315
+ q = (q or "").strip()
316
  if not q:
317
  return {"mode": "QA", "answer": _REFUSAL, "articles": []}
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)
351
+ return {"mode": "FULLTEXT", "answer": text or _REFUSAL, "articles": [article_id]}
 
 
 
 
352
 
353
  # ---------- LIST ----------
354
  if is_list_request(q):
355
  vs = get_vectorstore()
356
  docs = vs.similarity_search(q, k=5)
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}