FabIndy commited on
Commit
56a777c
·
1 Parent(s): 4ebf8d4

Switch to Groq-only LLM, remove GGUF dependency, speed up build and inference

Browse files
Files changed (5) hide show
  1. app.py +86 -183
  2. requirements.txt +4 -5
  3. src/qa.py +43 -52
  4. src/rag_core.py +105 -81
  5. src/resources.py +128 -25
app.py CHANGED
@@ -1,7 +1,5 @@
1
- # app.py — Gradio UI for hf-code-education (CPU / Hugging Face Spaces)
2
- # This file must NOT change the validated RAG logic.
3
- # It only calls src/rag_core.py:answer_query(query).
4
- # HF Spaces expects launch on 0.0.0.0:7860
5
 
6
  import os
7
  import sys
@@ -14,16 +12,24 @@ from huggingface_hub import hf_hub_download
14
 
15
 
16
  # ----------------------------
17
- # Assets download (FAISS + GGUF)
18
  # ----------------------------
 
 
 
19
 
20
  def ensure_faiss_index_present():
 
 
 
 
21
  repo_id = os.environ.get("FAISS_REPO_ID", "FabIndy/code-education-faiss-index")
22
- token = os.environ.get("HF_TOKEN") # optional if public dataset
23
 
24
  local_dir = Path("db/faiss_code_edu_by_article")
25
  local_dir.mkdir(parents=True, exist_ok=True)
26
 
 
27
  f_faiss = hf_hub_download(
28
  repo_id=repo_id,
29
  repo_type="dataset",
@@ -37,60 +43,36 @@ def ensure_faiss_index_present():
37
  token=token,
38
  )
39
 
 
40
  shutil.copyfile(f_faiss, local_dir / "index.faiss")
41
  shutil.copyfile(f_pkl, local_dir / "index.pkl")
42
 
43
 
44
- def ensure_model_present():
45
- os.makedirs("models", exist_ok=True)
46
-
47
- # Stable local name expected by rag_core.py
48
- local_path = os.path.join("models", "model.gguf")
49
- if os.path.exists(local_path):
50
- return
51
-
52
- repo_id = os.environ.get("MODEL_REPO_ID")
53
- filename = os.environ.get("MODEL_FILENAME")
54
-
55
- if not repo_id:
56
- raise RuntimeError(
57
- "Modèle GGUF absent (models/model.gguf) et variable MODEL_REPO_ID non définie."
58
- )
59
- if not filename:
60
- raise RuntimeError(
61
- "Variable MODEL_FILENAME non définie (ex: Qwen2.5-1.5B-Instruct-Q4_K_M.gguf)."
62
- )
63
-
64
- downloaded = hf_hub_download(repo_id=repo_id, filename=filename, repo_type="model")
65
- shutil.copyfile(downloaded, local_path)
66
-
67
-
68
  ensure_faiss_index_present()
69
- ensure_model_present()
70
 
71
 
72
  # ----------------------------
73
- # Import validated RAG core (do not modify)
74
  # ----------------------------
75
-
76
  ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
77
- SRC_DIR = os.path.join(ROOT_DIR, "src")
78
- if SRC_DIR not in sys.path:
79
- sys.path.insert(0, SRC_DIR)
 
80
 
81
  try:
82
- import rag_core # src/rag_core.py
83
  except Exception as e:
84
  raise RuntimeError(
85
  "Impossible d'importer src/rag_core.py. "
86
- "Vérifie que le fichier existe bien et qu'il s'appelle exactement rag_core.py."
87
  ) from e
88
 
89
 
90
  # ----------------------------
91
- # Helpers (display only)
92
  # ----------------------------
93
-
94
  def _render_list(articles) -> str:
95
  if not articles:
96
  return "Aucun article trouvé."
@@ -100,10 +82,6 @@ def _render_list(articles) -> str:
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
 
@@ -111,51 +89,51 @@ def _format_result(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
- # Other modes => show answer + footer
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])
135
-
136
- return str(result)
137
 
138
 
 
 
 
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
  # ----------------------------
151
- # Tab-specific wrappers (implicit routing without touching rag_core)
152
  # ----------------------------
153
-
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 (rag_core will route)
159
  return call_core(f"Quels articles parlent de {t} ?")
160
 
161
 
@@ -163,7 +141,6 @@ def tab_fulltext(article_id: str) -> str:
163
  a = (article_id or "").strip()
164
  if not a:
165
  return "Entre un identifiant d’article (ex : D422-5, L111-1, R421-10)."
166
- # Force FULLTEXT trigger
167
  return call_core(f"Donne l’intégralité de l’article {a}")
168
 
169
 
@@ -171,7 +148,6 @@ 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
- # Triggers SYNTHESIS routing in rag_core (via explain/synthèse triggers)
175
  return call_core(f"Synthèse (points clés) de l’article {a}")
176
 
177
 
@@ -179,7 +155,6 @@ def tab_summary_ai(article_id: str) -> str:
179
  a = (article_id or "").strip()
180
  if not a:
181
  return "Entre un identifiant d’article (ex : D422-5)."
182
- # Triggers SUMMARY_AI routing in rag_core (new)
183
  return call_core(f"Résumé IA de l’article {a}")
184
 
185
 
@@ -187,157 +162,85 @@ def tab_qa(question: str) -> str:
187
  q = (question or "").strip()
188
  if not q:
189
  return "Entre une question."
190
- # Free QA (slower, interpretative) – warning is added by rag_core
191
  return call_core(q)
192
 
193
 
194
  def clear_all():
195
- return "", "", "", "", ""
196
 
197
 
198
  # ----------------------------
199
  # UI
200
  # ----------------------------
201
-
202
  CSS = """
203
- :root {
204
- --font-sans: Inter, "Source Sans 3", Roboto, "Segoe UI", Arial, sans-serif;
205
- }
206
- body, .gradio-container {
207
- font-family: var(--font-sans) !important;
208
- font-size: 15px;
209
- line-height: 1.5;
210
- }
211
- .gradio-container {
212
- max-width: 980px !important;
213
- }
214
- #answer textarea {
215
- max-height: 480px !important;
216
- overflow-y: auto !important;
217
- font-size: 14px;
218
- line-height: 1.55;
219
- }
220
- .small-note {
221
- font-size: 13px;
222
- opacity: 0.9;
223
- }
224
  """
225
 
226
  THEME = gr.themes.Soft()
227
 
228
- with gr.Blocks(title="Code de l’éducation — Assistant (RAG)", css=CSS, theme=THEME) as demo:
229
  gr.Markdown(
230
  """
231
  # Code de l’éducation — Assistant (RAG)
232
 
233
- Outil de consultation des articles du Code de l’éducation, destiné aux chefs d’établissement.
 
 
 
 
 
234
 
235
- Méthode recommandée (simple et robuste)
236
- 1) **Trouver des articles** (LIST)
237
- 2) **Lire le texte officiel** (FULLTEXT)
238
- 3) **Résumé** : au choix
239
- - **Extraits officiels (fiable)** : passages clés copiés du texte (sans reformulation)
240
- - **Résumé IA** : reformulation pour lecture rapide (peut contenir des erreurs)
241
-
242
- > Le mode Question (IA) est interprétatif : à vérifier sur le texte officiel.
243
  """.strip()
244
  )
245
 
246
- gr.Markdown(
247
- """
248
- > Au premier lancement, l’application peut nécessiter 1 à 2 minutes d��initialisation (téléchargement index et modèle).
249
- > Ensuite, l’utilisation est immédiate.
250
- """.strip()
251
- )
252
 
253
  with gr.Tabs():
254
- # 1) LIST
255
  with gr.Tab("Trouver des articles"):
256
- list_inp = gr.Textbox(
257
- label="Thème",
258
- placeholder="Ex : vacances scolaires, conseil de classe, obligation scolaire…",
259
- lines=1,
260
- )
261
- list_btn = gr.Button("Rechercher", variant="primary")
262
- gr.Markdown(
263
- "<div class='small-note'>Conseil : commence presque toujours par ici. C’est le plus rapide.</div>"
264
- )
265
 
266
- # 2) FULLTEXT
267
  with gr.Tab("Texte officiel"):
268
- full_inp = gr.Textbox(
269
- label="Identifiant d’article",
270
- placeholder="Ex : D422-5",
271
- lines=1,
272
- )
273
- full_btn = gr.Button("Afficher", variant="primary")
274
- gr.Markdown(
275
- "<div class='small-note'>Le texte officiel est la référence. À utiliser pour vérifier toute interprétation.</div>"
276
- )
277
 
278
- # 3) RESUME (SYNTHESIS + SUMMARY_AI)
279
  with gr.Tab("Résumé"):
280
- syn_inp = gr.Textbox(
281
- label="Identifiant d’article",
282
- placeholder="Ex : D422-5",
283
- lines=1,
284
- )
285
-
286
  with gr.Row():
287
- syn_btn = gr.Button("Extraits officiels (fiable)", variant="primary")
288
- sum_btn = gr.Button("Résumé IA (peut contenir des erreurs)", variant="secondary")
 
 
 
289
 
290
  gr.Markdown(
291
  "<div class='small-note'>"
292
- "<b>Extraits officiels</b> = passages clés copiés du texte officiel (sans reformulation). "
293
- "<b>Résumé IA</b> = reformulation pour lecture rapide : à vérifier sur le texte officiel."
294
  "</div>"
295
  )
296
 
297
- out = gr.Textbox(label="Réponse", elem_id="answer", lines=12, max_lines=20)
298
-
299
- # Advanced QA (collapsed)
300
- with gr.Accordion("Avancé : Question (IA)", open=False):
301
- qa_inp = gr.Textbox(
302
- label="Votre question",
303
- placeholder="Ex : Un chef d’établissement peut-il organiser un conseil de classe après 19h ?",
304
- lines=3,
305
- max_lines=6,
306
- )
307
- qa_btn = gr.Button("Poser la question", variant="primary")
308
- gr.Markdown(
309
- "<div class='small-note'>Attention : réponse rédigée par IA, risque d’erreur. Toujours vérifier sur le texte officiel.</div>"
310
- )
311
 
312
  with gr.Row():
313
- clear = gr.Button("Effacer", variant="secondary")
314
-
315
- # Wire actions
316
- list_btn.click(tab_list, inputs=list_inp, outputs=out)
317
- full_btn.click(tab_fulltext, inputs=full_inp, outputs=out)
318
-
319
- syn_btn.click(tab_synthese, inputs=syn_inp, outputs=out)
320
- sum_btn.click(tab_summary_ai, inputs=syn_inp, outputs=out)
321
-
322
- qa_btn.click(tab_qa, inputs=qa_inp, outputs=out)
323
-
324
- clear.click(
325
- clear_all,
326
- outputs=[list_inp, full_inp, syn_inp, qa_inp, out],
327
- )
328
-
329
- with gr.Accordion("Exemples", open=False):
330
- gr.Markdown(
331
- "- **Trouver des articles** : `vacances scolaires`\n"
332
- "- **Texte officiel** : `D422-5`\n"
333
- "- **Résumé / Extraits officiels** : `D422-5`\n"
334
- "- **Résumé / Résumé IA** : `D422-5`\n"
335
- "- **Question (IA)** : `Un chef d’établissement peut-il organiser un conseil de classe après 19h ?`\n"
336
- )
337
-
338
 
339
  if __name__ == "__main__":
340
- demo.launch(
341
- server_name="0.0.0.0",
342
- server_port=7860,
343
- )
 
1
+ # app.py — Simplified (Groq-only, no GGUF)
2
+ from __future__ import annotations
 
 
3
 
4
  import os
5
  import sys
 
12
 
13
 
14
  # ----------------------------
15
+ # Helpers
16
  # ----------------------------
17
+ def groq_enabled() -> bool:
18
+ return bool(os.environ.get("GROQ_API_KEY", "").strip())
19
+
20
 
21
  def ensure_faiss_index_present():
22
+ """
23
+ FAISS index is needed for QA retrieval.
24
+ (Groq replaces ONLY the generator, not the retrieval.)
25
+ """
26
  repo_id = os.environ.get("FAISS_REPO_ID", "FabIndy/code-education-faiss-index")
27
+ token = os.environ.get("HF_TOKEN") # optional if index repo is public
28
 
29
  local_dir = Path("db/faiss_code_edu_by_article")
30
  local_dir.mkdir(parents=True, exist_ok=True)
31
 
32
+ # Download to HF cache
33
  f_faiss = hf_hub_download(
34
  repo_id=repo_id,
35
  repo_type="dataset",
 
43
  token=token,
44
  )
45
 
46
+ # Copy to expected local dir
47
  shutil.copyfile(f_faiss, local_dir / "index.faiss")
48
  shutil.copyfile(f_pkl, local_dir / "index.pkl")
49
 
50
 
51
+ # Always ensure FAISS (required for QA retrieval)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  ensure_faiss_index_present()
 
53
 
54
 
55
  # ----------------------------
56
+ # Import validated RAG core
57
  # ----------------------------
 
58
  ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
59
+
60
+ # rag_core imports "from src import ..." so we add project root (not /src)
61
+ if ROOT_DIR not in sys.path:
62
+ sys.path.insert(0, ROOT_DIR)
63
 
64
  try:
65
+ from src import rag_core
66
  except Exception as e:
67
  raise RuntimeError(
68
  "Impossible d'importer src/rag_core.py. "
69
+ "Vérifie que le dossier src/ contient bien rag_core.py et qu'il n'y a pas d'erreurs d'import."
70
  ) from e
71
 
72
 
73
  # ----------------------------
74
+ # Rendering helpers
75
  # ----------------------------
 
76
  def _render_list(articles) -> str:
77
  if not articles:
78
  return "Aucun article trouvé."
 
82
 
83
 
84
  def _format_result(result) -> str:
 
 
 
 
85
  if result is None:
86
  return "Aucune réponse."
87
 
 
89
  return result.strip() or "Aucune réponse."
90
 
91
  if isinstance(result, dict):
92
+ mode = str(result.get("mode", "")).strip().upper()
93
+ answer = result.get("answer", result.get("response", "")) or ""
94
+ answer = str(answer).strip()
95
  articles = result.get("articles") or []
96
 
97
+ if mode == "LIST":
 
98
  return _render_list(articles)
99
 
100
+ tail = f"\n\nArticles : {', '.join(map(str, articles))}" if articles else ""
101
+ return (answer or "Aucune réponse.") + tail
 
 
 
 
102
 
103
+ return str(result).strip() or "Aucune réponse."
 
 
 
 
 
 
104
 
105
 
106
+ # ----------------------------
107
+ # Core call
108
+ # ----------------------------
109
  def call_core(query: str) -> str:
110
  q = (query or "").strip()
111
  if not q:
112
  return "Entre une demande."
113
+
114
+ # Groq-only: if missing, fail fast with a clear message
115
+ if not groq_enabled():
116
+ return (
117
+ "Groq n'est pas configuré.\n\n"
118
+ "Ajoute la variable d'environnement GROQ_API_KEY dans le Space "
119
+ "(Settings → Variables).\n"
120
+ "Optionnel : GROQ_MODEL, GROQ_MAX_TOKENS_SUMMARY, GROQ_MAX_TOKENS_QA, GROQ_TEMPERATURE."
121
+ )
122
+
123
  try:
124
+ result = rag_core.answer_query(q)
125
  return _format_result(result)
126
  except Exception:
127
  return "Erreur côté application :\n\n" + traceback.format_exc()
128
 
129
 
130
  # ----------------------------
131
+ # Tab wrappers
132
  # ----------------------------
 
133
  def tab_list(theme: str) -> str:
134
  t = (theme or "").strip()
135
  if not t:
136
  return "Entre un thème (ex : vacances scolaires, conseil de classe, obligation scolaire)."
 
137
  return call_core(f"Quels articles parlent de {t} ?")
138
 
139
 
 
141
  a = (article_id or "").strip()
142
  if not a:
143
  return "Entre un identifiant d’article (ex : D422-5, L111-1, R421-10)."
 
144
  return call_core(f"Donne l’intégralité de l’article {a}")
145
 
146
 
 
148
  a = (article_id or "").strip()
149
  if not a:
150
  return "Entre un identifiant d’article (ex : D422-5)."
 
151
  return call_core(f"Synthèse (points clés) de l’article {a}")
152
 
153
 
 
155
  a = (article_id or "").strip()
156
  if not a:
157
  return "Entre un identifiant d’article (ex : D422-5)."
 
158
  return call_core(f"Résumé IA de l’article {a}")
159
 
160
 
 
162
  q = (question or "").strip()
163
  if not q:
164
  return "Entre une question."
 
165
  return call_core(q)
166
 
167
 
168
  def clear_all():
169
+ return "", "", "", ""
170
 
171
 
172
  # ----------------------------
173
  # UI
174
  # ----------------------------
 
175
  CSS = """
176
+ :root { --font-sans: Inter, "Source Sans 3", Roboto, "Segoe UI", Arial, sans-serif; }
177
+ body, .gradio-container { font-family: var(--font-sans) !important; font-size: 15px; line-height: 1.5; }
178
+ .gradio-container { max-width: 980px !important; }
179
+ #answer textarea { max-height: 480px !important; overflow-y: auto !important; font-size: 14px; line-height: 1.55; }
180
+ .small-note { font-size: 13px; opacity: 0.9; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  """
182
 
183
  THEME = gr.themes.Soft()
184
 
185
+ with gr.Blocks(title="Code de l’éducation — Assistant (Groq)", css=CSS, theme=THEME) as demo:
186
  gr.Markdown(
187
  """
188
  # Code de l’éducation — Assistant (RAG)
189
 
190
+ - **LIST** : trouve des articles (recherche explicable)
191
+ - **Texte officiel** : affiche l’article exact
192
+ - **Résumé** :
193
+ - **Extraits officiels** : fiable (sans reformulation)
194
+ - **Résumé IA** : rapide (reformulation, peut comporter des erreurs)
195
+ - **Question (IA)** : interprétatif → toujours vérifier sur le texte officiel
196
 
197
+ > Génération **100% via Groq**.
 
 
 
 
 
 
 
198
  """.strip()
199
  )
200
 
201
+ if groq_enabled():
202
+ gr.Markdown("Groq configuré.")
203
+ else:
204
+ gr.Markdown("Groq non configuré : ajoute `GROQ_API_KEY` dans les Variables du Space.")
 
 
205
 
206
  with gr.Tabs():
 
207
  with gr.Tab("Trouver des articles"):
208
+ list_inp = gr.Textbox(label="Thème", placeholder="Ex : vacances scolaires, conseil de classe…")
209
+ list_btn = gr.Button("Chercher")
210
+ list_out = gr.Textbox(label="Résultat", elem_id="answer", lines=18)
211
+ list_btn.click(fn=tab_list, inputs=list_inp, outputs=list_out)
 
 
 
 
 
212
 
 
213
  with gr.Tab("Texte officiel"):
214
+ ft_inp = gr.Textbox(label="Identifiant d’article", placeholder="Ex : D521-5")
215
+ ft_btn = gr.Button("Afficher")
216
+ ft_out = gr.Textbox(label="Texte officiel", elem_id="answer", lines=18)
217
+ ft_btn.click(fn=tab_fulltext, inputs=ft_inp, outputs=ft_out)
 
 
 
 
 
218
 
 
219
  with gr.Tab("Résumé"):
220
+ s_inp = gr.Textbox(label="Identifiant d’article", placeholder="Ex : D521-5")
 
 
 
 
 
221
  with gr.Row():
222
+ s_btn = gr.Button("Extraits officiels (fiable)")
223
+ ai_btn = gr.Button("Résumé IA (rapide)")
224
+ s_out = gr.Textbox(label="Résumé", elem_id="answer", lines=18)
225
+ s_btn.click(fn=tab_synthese, inputs=s_inp, outputs=s_out)
226
+ ai_btn.click(fn=tab_summary_ai, inputs=s_inp, outputs=s_out)
227
 
228
  gr.Markdown(
229
  "<div class='small-note'>"
230
+ "Extraits officiels : copies du texte (sans reformulation). "
231
+ "Résumé IA : reformulation (peut contenir des erreurs)."
232
  "</div>"
233
  )
234
 
235
+ with gr.Tab("Question (IA)"):
236
+ qa_inp = gr.Textbox(label="Question", placeholder="Ex : Qui décide des dates de vacances scolaires ?")
237
+ qa_btn = gr.Button("Répondre")
238
+ qa_out = gr.Textbox(label="Réponse", elem_id="answer", lines=18)
239
+ qa_btn.click(fn=tab_qa, inputs=qa_inp, outputs=qa_out)
 
 
 
 
 
 
 
 
 
240
 
241
  with gr.Row():
242
+ clear_btn = gr.Button("Effacer")
243
+ clear_btn.click(fn=clear_all, inputs=None, outputs=[list_inp, ft_inp, s_inp, qa_inp])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
  if __name__ == "__main__":
246
+ demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
requirements.txt CHANGED
@@ -1,13 +1,12 @@
1
  gradio==4.44.1
 
 
2
  faiss-cpu>=1.9.0.post1
3
  sentence-transformers==3.0.1
4
- llama-cpp-python==0.3.7
5
  langchain-community
6
  langchain-huggingface
7
- huggingface_hub
8
- torch
9
- tf-keras
10
-
11
 
 
12
 
13
 
 
1
  gradio==4.44.1
2
+ huggingface_hub
3
+
4
  faiss-cpu>=1.9.0.post1
5
  sentence-transformers==3.0.1
6
+
7
  langchain-community
8
  langchain-huggingface
 
 
 
 
9
 
10
+ groq
11
 
12
 
src/qa.py CHANGED
@@ -2,37 +2,34 @@
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
- qa.py — Mode QA (interprétatif, LLM CPU, plus lent)
6
 
7
- Objectif :
8
- - Construire un prompt QA rapide et "prudent"
9
- - Fournir un wrapper d'appel LLM (llama_cpp.Llama) instancié ailleurs
10
- - Fournir une utilitaire de tronquage de contexte
11
- - (ajout) Construire un prompt de Résumé IA (SUMMARY_AI), pour réutiliser le même moteur LLM
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import os
17
  from dataclasses import dataclass
18
- from typing import List
 
 
19
 
20
 
21
  # ==================== CONFIG ====================
22
 
23
  @dataclass(frozen=True)
24
  class QAConfig:
25
- # QA
26
  qa_top_k_final: int = int(os.environ.get("QA_TOP_K_FINAL", "2"))
27
  qa_doc_max_chars: int = int(os.environ.get("QA_DOC_MAX_CHARS", "700"))
28
- qa_max_tokens: int = int(os.environ.get("QA_MAX_TOKENS", "160"))
29
- qa_temperature: float = float(os.environ.get("QA_TEMPERATURE", "0.2"))
30
 
31
 
32
  # ==================== TEXT UTILS ====================
33
 
34
  def truncate_text(s: str, n: int) -> str:
35
- """Tronque une chaîne à n caractères, avec un marqueur explicite."""
36
  if not s:
37
  return ""
38
  s = s.strip()
@@ -42,20 +39,15 @@ def truncate_text(s: str, n: int) -> str:
42
  # ==================== PROMPTS ====================
43
 
44
  def build_qa_prompt_fast(question: str, context: str, sources: List[str]) -> str:
45
- """
46
- Prompt QA court :
47
- - s'appuie sur le contexte fourni
48
- - refuse clairement si l'info n'est pas présente
49
- - impose une réponse brève
50
- """
51
- src = ", ".join(sources) if sources else "Aucune"
52
- return f"""Tu es un assistant qui aide à comprendre le Code de l'éducation (France).
53
 
54
- CONTRAINTE :
55
- - Appuie-toi STRICTEMENT sur le CONTEXTE fourni.
56
- - Si l'information n'est pas dans le contexte, dis-le clairement (sans inventer).
57
- - Réponse courte, pratique, 6 à 10 phrases max.
58
- - Ne cite pas de sources externes, uniquement les articles fournis.
 
59
 
60
  QUESTION :
61
  {question}
@@ -63,49 +55,48 @@ QUESTION :
63
  CONTEXTE :
64
  {context}
65
 
66
- Indique à la fin exactement : "Sources (articles) : {src}"
 
67
  """
68
 
69
 
70
  def build_summary_prompt(article_id: str, article_text: str) -> str:
71
- """
72
- Prompt Résumé IA (SUMMARY_AI).
73
- - Résumé reformulé (contrairement à SYNTHESIS qui est extractif)
74
- - Zéro invention : si ce n'est pas dans le texte, ne pas l'ajouter
75
- - Format en puces, concis
76
- """
77
- return f"""Tu aides un professionnel à lire rapidement un article du Code de l'éducation (France).
78
 
79
- TÂCHE : produire un résumé fidèle et utile de l'article, sans inventer.
80
 
81
- RÈGLES :
82
- - Ne mentionne QUE des informations présentes dans le texte.
83
- - Pas d'ajout d'information extérieure.
84
- - 4 à 8 puces maximum.
85
- - Style neutre, factuel.
86
- - Si le texte est très court, reformule simplement l'idée centrale en 2 à 4 puces.
87
- - N'ajoute pas de conclusion, pas de conseils, pas d'interprétation.
88
 
89
- ARTICLE {article_id} (texte officiel) :
 
 
 
 
 
 
 
 
90
  {article_text}
91
  """
92
 
93
 
94
- # ==================== LLM CALL ====================
95
 
96
- def llm_generate_qa(llm, prompt: str, cfg: QAConfig | None = None) -> str:
97
  """
98
- llm: instance llama_cpp.Llama créée ailleurs (ex: dans resources.py ou rag_core.py)
99
-
100
- Remarque :
101
- - On utilise create_chat_completion pour les modèles instruct/chat.
102
- - Les paramètres doivent rester bas (température faible) pour limiter les dérives.
103
  """
 
 
 
 
 
 
104
  cfg = cfg or QAConfig()
 
105
 
106
- out = llm.create_chat_completion(
107
- messages=[{"role": "user", "content": prompt}],
108
- temperature=cfg.qa_temperature,
109
  max_tokens=cfg.qa_max_tokens,
 
110
  )
111
- return out["choices"][0]["message"]["content"].strip()
 
2
  # -*- coding: utf-8 -*-
3
 
4
  """
5
+ qa.py — QA + SUMMARY via Groq uniquement (pas de LLM local)
6
 
7
+ - Pas de fallback llama.cpp (trop lent).
8
+ - Si GROQ_API_KEY n'est pas défini, on lève une erreur explicite.
 
 
 
9
  """
10
 
11
  from __future__ import annotations
12
 
13
  import os
14
  from dataclasses import dataclass
15
+ from typing import List, Dict
16
+
17
+ from src.resources import generate_chat, is_groq_enabled
18
 
19
 
20
  # ==================== CONFIG ====================
21
 
22
  @dataclass(frozen=True)
23
  class QAConfig:
 
24
  qa_top_k_final: int = int(os.environ.get("QA_TOP_K_FINAL", "2"))
25
  qa_doc_max_chars: int = int(os.environ.get("QA_DOC_MAX_CHARS", "700"))
26
+ qa_max_tokens: int = int(os.environ.get("QA_MAX_TOKENS", "220"))
27
+ qa_temperature: float = float(os.environ.get("QA_TEMPERATURE", "0.1"))
28
 
29
 
30
  # ==================== TEXT UTILS ====================
31
 
32
  def truncate_text(s: str, n: int) -> str:
 
33
  if not s:
34
  return ""
35
  s = s.strip()
 
39
  # ==================== PROMPTS ====================
40
 
41
  def build_qa_prompt_fast(question: str, context: str, sources: List[str]) -> str:
42
+ src = ", ".join(sources) if sources else "N/A"
43
+ return f"""Tu es un assistant juridique francophone. Tu aides à comprendre le Code de l'éducation (France).
 
 
 
 
 
 
44
 
45
+ RÈGLES STRICTES :
46
+ - Réponds uniquement en français.
47
+ - Appuie-toi en priorité sur le CONTEXTE fourni.
48
+ - Si l'information n'est pas dans le contexte, dis-le explicitement.
49
+ - Réponse courte et pratique (6 à 10 phrases maximum).
50
+ - Ne cite pas de sources externes (sites, lois non fournies, jurisprudence, etc.).
51
 
52
  QUESTION :
53
  {question}
 
55
  CONTEXTE :
56
  {context}
57
 
58
+ Termine par une ligne :
59
+ Sources (articles) : {src}
60
  """
61
 
62
 
63
  def build_summary_prompt(article_id: str, article_text: str) -> str:
64
+ return f"""Tu es un assistant juridique francophone.
 
 
 
 
 
 
65
 
66
+ LANGUE : réponds uniquement en français.
67
 
68
+ TÂCHE : résumer fidèlement un article du Code de l'éducation à partir du texte fourni.
 
 
 
 
 
 
69
 
70
+ RÈGLES STRICTES :
71
+ - N'invente rien. Si une information n'est pas dans le texte, ne l'ajoute pas.
72
+ - 4 puces maximum.
73
+ - 1 seule phrase courte par puce.
74
+ - Ne pas numéroter. Ne pas écrire "Puce 1", "Puce 2".
75
+ - Commence chaque ligne par "- ".
76
+ - 60 mots maximum au total.
77
+
78
+ ARTICLE {article_id} (texte / extraits fournis) :
79
  {article_text}
80
  """
81
 
82
 
83
+ # ==================== GENERATION (GROQ ONLY) ====================
84
 
85
+ def llm_generate_qa(prompt: str, cfg: QAConfig | None = None) -> str:
86
  """
87
+ Génération via Groq uniquement.
 
 
 
 
88
  """
89
+ if not is_groq_enabled():
90
+ raise RuntimeError(
91
+ "Groq n'est pas configuré : variable GROQ_API_KEY manquante. "
92
+ "Ajoute GROQ_API_KEY (et optionnellement GROQ_MODEL) dans l'environnement."
93
+ )
94
+
95
  cfg = cfg or QAConfig()
96
+ messages: List[Dict[str, str]] = [{"role": "user", "content": prompt}]
97
 
98
+ return generate_chat(
99
+ messages,
 
100
  max_tokens=cfg.qa_max_tokens,
101
+ temperature=cfg.qa_temperature,
102
  )
 
src/rag_core.py CHANGED
@@ -1,9 +1,9 @@
1
-
2
  # src/rag_core.py
3
  from __future__ import annotations
4
 
5
  from typing import Dict, Any, List
6
  import json
 
7
 
8
  from src import list as list_mode
9
  from src import fulltext as fulltext_mode
@@ -18,8 +18,6 @@ from src.config import (
18
  QA_WARNING,
19
  QA_TOP_K_FINAL,
20
  QA_DOC_MAX_CHARS,
21
- QA_MAX_TOKENS,
22
- QA_TEMPERATURE,
23
  )
24
  from src.utils import (
25
  normalize_article_id,
@@ -28,20 +26,17 @@ from src.utils import (
28
  is_fulltext_request,
29
  is_synthesis_request,
30
  )
31
-
32
- from src.resources import get_vectorstore, get_llm
33
 
34
 
35
  # ====================
36
- # MODE SUMMARY_AI (temporaire dans rag_core)
37
  # ====================
38
 
39
- # Triggers locaux (on les déplacera ensuite dans config.py + utils.py)
40
  SUMMARY_TRIGGERS = [
41
  "résumé ia", "resume ia",
42
- "résumé", "resume",
43
- "résumer", "resumer",
44
- "summary",
45
  ]
46
 
47
  SUMMARY_WARNING = (
@@ -49,32 +44,68 @@ SUMMARY_WARNING = (
49
  "Vérifie toujours sur le texte officiel."
50
  )
51
 
52
- # Réglages simples (on les déplacera ensuite dans config.py)
53
- SUMMARY_DOC_MAX_CHARS = 600
54
- SUMMARY_MAX_TOKENS = 80
55
- SUMMARY_TEMPERATURE = 0.1
56
-
57
 
58
  def is_summary_request(q: str) -> bool:
59
  ql = (q or "").lower()
60
  return any(t in ql for t in SUMMARY_TRIGGERS)
61
 
62
 
63
- def build_summary_prompt(article_id: str, article_text: str) -> str:
64
- # Prompt minimal, robuste, orienté "zéro invention"
65
- return f"""Tu aides un professionnel à lire rapidement un article du Code de l'éducation (France).
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- TÂCHE : produire un résumé fidèle et utile, sans inventer.
68
- RÈGLES :
69
- - Ne cite QUE ce qui est présent dans le texte.
70
- - Pas d’ajout d’information extérieure.
71
- - 4 puces maximum, 1 ligne par puce, 60 mots maximum au total.
72
- - Style neutre, factuel.
73
- - Si le texte est très court, reformule simplement l’idée centrale en 2 à 4 puces.
74
 
75
- ARTICLE {article_id} (texte officiel) :
76
- {article_text}
77
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
 
80
  # ====================
@@ -113,10 +144,11 @@ def load_article_text(article_id: str) -> str | None:
113
 
114
 
115
  # ====================
116
- # QA
117
  # ====================
118
 
119
  def _qa_answer(question: str) -> Dict[str, Any]:
 
120
  vs = get_vectorstore()
121
  docs = vs.similarity_search(question, k=max(1, QA_TOP_K_FINAL))
122
 
@@ -137,11 +169,12 @@ def _qa_answer(question: str) -> Dict[str, Any]:
137
  cfg = qa_mode.QAConfig(
138
  qa_top_k_final=QA_TOP_K_FINAL,
139
  qa_doc_max_chars=QA_DOC_MAX_CHARS,
140
- qa_max_tokens=QA_MAX_TOKENS,
141
- qa_temperature=QA_TEMPERATURE,
142
  )
143
 
144
- ans = qa_mode.llm_generate_qa(get_llm(), prompt, cfg=cfg).strip()
 
145
  return {
146
  "mode": "QA",
147
  "answer": f"{QA_WARNING}\n\n{ans}",
@@ -150,43 +183,25 @@ def _qa_answer(question: str) -> Dict[str, Any]:
150
 
151
 
152
  # ====================
153
- # SUMMARY_AI
154
  # ====================
155
 
156
- def _summary_ai(article_id: str) -> Dict[str, Any]:
157
- article_id = normalize_article_id(article_id)
158
- text = load_article_text(article_id)
159
-
160
- if not text:
161
- return {
162
- "mode": "SUMMARY_AI",
163
- "answer": f"Article {article_id} introuvable.",
164
- "articles": [],
165
- }
166
-
167
- short_text = qa_mode.truncate_text(text, SUMMARY_DOC_MAX_CHARS)
168
- prompt = build_summary_prompt(article_id, short_text)
169
-
170
- # On réutilise le moteur LLM existant sans toucher à qa.py
171
- cfg = qa_mode.QAConfig(
172
- qa_top_k_final=1, # non utilisé ici mais requis par la dataclass
173
- qa_doc_max_chars=SUMMARY_DOC_MAX_CHARS,
174
- qa_max_tokens=SUMMARY_MAX_TOKENS,
175
- qa_temperature=SUMMARY_TEMPERATURE,
176
  )
177
-
178
- ans = qa_mode.llm_generate_qa(get_llm(), prompt, cfg=cfg).strip()
179
- return {
180
- "mode": "SUMMARY_AI",
181
- "answer": f"{SUMMARY_WARNING}\n\n{ans}",
182
- "articles": [article_id],
183
- }
184
 
185
 
186
- # ====================
187
- # ROUTEUR
188
- # ====================
189
-
190
  def answer_query(q: str) -> Dict[str, Any]:
191
  q = (q or "").strip()
192
  if not q:
@@ -194,7 +209,7 @@ def answer_query(q: str) -> Dict[str, Any]:
194
 
195
  article_id = extract_article_id(q)
196
 
197
- # FULLTEXT
198
  if article_id and is_fulltext_request(q):
199
  article_id = normalize_article_id(article_id)
200
  text = load_article_text(article_id)
@@ -204,22 +219,11 @@ def answer_query(q: str) -> Dict[str, Any]:
204
  "articles": [article_id],
205
  }
206
 
207
- # SUMMARY_AI (doit passer AVANT les fallbacks LIST)
208
  if article_id and is_summary_request(q):
209
  return _summary_ai(article_id)
210
 
211
- # LIST (LEXICAL-FIRST) IMPORTANT : ne charge pas FAISS ici
212
- if is_list_request(q):
213
- return list_mode.list_articles(
214
- q,
215
- articles=get_all_articles(),
216
- vs=None, # <-- crucial pour HF: pas de chargement FAISS inutile
217
- normalize_article_id=normalize_article_id,
218
- list_triggers=LIST_TRIGGERS,
219
- cfg=list_mode.ListConfig(),
220
- )
221
-
222
- # SYNTHESIS
223
  if is_synthesis_request(q):
224
  if not article_id:
225
  return {"mode": "SYNTHESIS", "answer": SYNTHESIS_REFUSAL, "articles": []}
@@ -239,16 +243,36 @@ def answer_query(q: str) -> Dict[str, Any]:
239
  "articles": [article_id],
240
  }
241
 
242
- # LIST par défaut si requête courte (nominale) ne charge pas FAISS ici non plus
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  if len(q.split()) <= 5:
244
  return list_mode.list_articles(
245
  q,
246
  articles=get_all_articles(),
247
- vs=None, # <-- crucial pour HF
248
  normalize_article_id=normalize_article_id,
249
  list_triggers=LIST_TRIGGERS,
250
  cfg=list_mode.ListConfig(),
251
  )
252
 
253
- # QA
254
  return _qa_answer(q)
 
 
1
  # src/rag_core.py
2
  from __future__ import annotations
3
 
4
  from typing import Dict, Any, List
5
  import json
6
+ import os
7
 
8
  from src import list as list_mode
9
  from src import fulltext as fulltext_mode
 
18
  QA_WARNING,
19
  QA_TOP_K_FINAL,
20
  QA_DOC_MAX_CHARS,
 
 
21
  )
22
  from src.utils import (
23
  normalize_article_id,
 
26
  is_fulltext_request,
27
  is_synthesis_request,
28
  )
29
+ from src.resources import get_vectorstore, groq_max_tokens_for
 
30
 
31
 
32
  # ====================
33
+ # SUMMARY_AI (Groq-only, rapide)
34
  # ====================
35
 
 
36
  SUMMARY_TRIGGERS = [
37
  "résumé ia", "resume ia",
38
+ "résume ia", "resume-ia",
39
+ "summary ia", "ai summary",
 
40
  ]
41
 
42
  SUMMARY_WARNING = (
 
44
  "Vérifie toujours sur le texte officiel."
45
  )
46
 
 
 
 
 
 
47
 
48
  def is_summary_request(q: str) -> bool:
49
  ql = (q or "").lower()
50
  return any(t in ql for t in SUMMARY_TRIGGERS)
51
 
52
 
53
+ def _build_summary_context_from_extractive(article_id: str, full_text: str) -> str:
54
+ """
55
+ Construit un contexte court à partir de la synthèse extractive existante.
56
+ On récupère 3–4 segments "- ..." pour alimenter le LLM avec très peu de texte.
57
+ """
58
+ extract = synthesis_mode.extractive_summary(article_id, full_text)
59
+
60
+ lines: List[str] = []
61
+ for line in extract.splitlines():
62
+ line = line.strip()
63
+ if line.startswith("- "):
64
+ seg = line[2:].strip()
65
+ if seg:
66
+ lines.append(seg)
67
+
68
+ lines = lines[:4] # limite dure
69
 
70
+ if not lines:
71
+ # fallback ultra sûr
72
+ return qa_mode.truncate_text(full_text, 400)
 
 
 
 
73
 
74
+ return "\n".join(f"- {l}" for l in lines)
75
+
76
+
77
+ def _summary_ai(article_id: str) -> Dict[str, Any]:
78
+ article_id = normalize_article_id(article_id)
79
+ text = load_article_text(article_id)
80
+
81
+ if not text:
82
+ return {
83
+ "mode": "SUMMARY_AI",
84
+ "answer": f"Article {article_id} introuvable.",
85
+ "articles": [],
86
+ }
87
+
88
+ # Contexte réduit (extraits) pour accélérer
89
+ context = _build_summary_context_from_extractive(article_id, text)
90
+
91
+ # Prompt strict FR + puces (défini dans qa.py)
92
+ prompt = qa_mode.build_summary_prompt(article_id, context)
93
+
94
+ # Paramètres Groq (via env vars)
95
+ cfg = qa_mode.QAConfig(
96
+ qa_top_k_final=1,
97
+ qa_doc_max_chars=600,
98
+ qa_max_tokens=groq_max_tokens_for("summary"),
99
+ qa_temperature=float(os.environ.get("GROQ_TEMPERATURE", "0.1")),
100
+ )
101
+
102
+ ans = qa_mode.llm_generate_qa(prompt, cfg=cfg).strip()
103
+
104
+ return {
105
+ "mode": "SUMMARY_AI",
106
+ "answer": f"{SUMMARY_WARNING}\n\n{ans}",
107
+ "articles": [article_id],
108
+ }
109
 
110
 
111
  # ====================
 
144
 
145
 
146
  # ====================
147
+ # QA (Groq-only pour la génération)
148
  # ====================
149
 
150
  def _qa_answer(question: str) -> Dict[str, Any]:
151
+ # Retrieval vectoriel (FAISS)
152
  vs = get_vectorstore()
153
  docs = vs.similarity_search(question, k=max(1, QA_TOP_K_FINAL))
154
 
 
169
  cfg = qa_mode.QAConfig(
170
  qa_top_k_final=QA_TOP_K_FINAL,
171
  qa_doc_max_chars=QA_DOC_MAX_CHARS,
172
+ qa_max_tokens=groq_max_tokens_for("qa"),
173
+ qa_temperature=float(os.environ.get("GROQ_TEMPERATURE", "0.1")),
174
  )
175
 
176
+ ans = qa_mode.llm_generate_qa(prompt, cfg=cfg).strip()
177
+
178
  return {
179
  "mode": "QA",
180
  "answer": f"{QA_WARNING}\n\n{ans}",
 
183
 
184
 
185
  # ====================
186
+ # ROUTEUR
187
  # ====================
188
 
189
+ def _looks_like_question(q: str) -> bool:
190
+ """
191
+ Détecte une intention de question, même si la requête est courte.
192
+ C'est crucial pour éviter que des questions tombent dans LIST par défaut.
193
+ """
194
+ ql = (q or "").strip().lower()
195
+ if "?" in ql:
196
+ return True
197
+ starters = (
198
+ "que ", "qu'", "quoi", "comment", "pourquoi", "quand", "où",
199
+ "est-ce", "peux", "peut", "dois", "doit", "faut", "faudrait",
200
+ "quelle", "quelles", "quel", "quels",
 
 
 
 
 
 
 
 
201
  )
202
+ return ql.startswith(starters)
 
 
 
 
 
 
203
 
204
 
 
 
 
 
205
  def answer_query(q: str) -> Dict[str, Any]:
206
  q = (q or "").strip()
207
  if not q:
 
209
 
210
  article_id = extract_article_id(q)
211
 
212
+ # 1) FULLTEXT
213
  if article_id and is_fulltext_request(q):
214
  article_id = normalize_article_id(article_id)
215
  text = load_article_text(article_id)
 
219
  "articles": [article_id],
220
  }
221
 
222
+ # 2) SUMMARY_AI (Résumé IA)
223
  if article_id and is_summary_request(q):
224
  return _summary_ai(article_id)
225
 
226
+ # 3) SYNTHESIS (extractif fiable)
 
 
 
 
 
 
 
 
 
 
 
227
  if is_synthesis_request(q):
228
  if not article_id:
229
  return {"mode": "SYNTHESIS", "answer": SYNTHESIS_REFUSAL, "articles": []}
 
243
  "articles": [article_id],
244
  }
245
 
246
+ # 4) LIST explicite
247
+ if is_list_request(q):
248
+ return list_mode.list_articles(
249
+ q,
250
+ articles=get_all_articles(),
251
+ vs=None, # important : LIST doit rester léger/explicable
252
+ normalize_article_id=normalize_article_id,
253
+ list_triggers=LIST_TRIGGERS,
254
+ cfg=list_mode.ListConfig(),
255
+ )
256
+
257
+ # 5) Routage robuste : si c'est une QUESTION, on force QA
258
+ if _looks_like_question(q):
259
+ return _qa_answer(q)
260
+
261
+ # 6) Si un article est mentionné et que ce n'est pas un mode dédié,
262
+ # on privilégie QA (cas : "Que dit l'article D521-5" sans forcément de "?")
263
+ if article_id:
264
+ return _qa_answer(q)
265
+
266
+ # 7) LIST par défaut si requête courte (mots-clés)
267
  if len(q.split()) <= 5:
268
  return list_mode.list_articles(
269
  q,
270
  articles=get_all_articles(),
271
+ vs=None,
272
  normalize_article_id=normalize_article_id,
273
  list_triggers=LIST_TRIGGERS,
274
  cfg=list_mode.ListConfig(),
275
  )
276
 
277
+ # 8) QA par défaut
278
  return _qa_answer(q)
src/resources.py CHANGED
@@ -1,12 +1,12 @@
1
  # src/resources.py
2
  from __future__ import annotations
3
 
 
4
  from pathlib import Path
5
- from typing import Optional
6
 
7
  from langchain_community.vectorstores import FAISS
8
  from langchain_huggingface import HuggingFaceEmbeddings
9
- from llama_cpp import Llama
10
 
11
  from src.config import (
12
  DB_DIR,
@@ -18,12 +18,22 @@ from src.config import (
18
  )
19
 
20
 
 
 
 
21
  _VS: Optional[FAISS] = None
22
- _LLM: Optional[Llama] = None
23
 
 
 
24
 
 
 
 
 
 
 
 
25
  def _assert_vectorstore_files(db_dir: Path) -> None:
26
- """Vérifie que le répertoire FAISS contient les fichiers nécessaires."""
27
  if not db_dir.exists() or not db_dir.is_dir():
28
  raise RuntimeError(
29
  f"Vectorstore introuvable : {db_dir}\n"
@@ -32,7 +42,6 @@ def _assert_vectorstore_files(db_dir: Path) -> None:
32
 
33
  faiss_file = db_dir / "index.faiss"
34
  pkl_file = db_dir / "index.pkl"
35
-
36
  if not faiss_file.exists() or not pkl_file.exists():
37
  raise RuntimeError(
38
  f"Vectorstore incomplet dans {db_dir}\n"
@@ -40,10 +49,37 @@ def _assert_vectorstore_files(db_dir: Path) -> None:
40
  )
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def get_vectorstore() -> FAISS:
44
  """
45
  Charge FAISS + embeddings UNE fois (lazy-loading).
46
- IMPORTANT : coûteux (CPU + I/O). Ne l'appelle que si nécessaire (QA).
47
  """
48
  global _VS
49
  if _VS is not None:
@@ -62,33 +98,100 @@ def get_vectorstore() -> FAISS:
62
  return _VS
63
 
64
 
65
- def _assert_llm_file(model_path: Path) -> None:
66
- """Vérifie que le modèle GGUF est présent."""
67
- if not model_path.exists() or not model_path.is_file():
68
- raise RuntimeError(
69
- f"Modèle GGUF introuvable : {model_path}\n"
70
- "Assure-toi que app.py a bien téléchargé/copier le modèle dans models/ "
71
- "ou que LLM_MODEL_PATH pointe vers un fichier GGUF valide."
72
- )
73
-
74
-
75
- def get_llm() -> Llama:
76
  """
77
- Charge le modèle GGUF UNE fois (lazy-loading).
78
- IMPORTANT : coûteux. Ne l'appelle que pour SUMMARY_AI et QA.
79
  """
80
- global _LLM
81
- if _LLM is not None:
82
- return _LLM
 
 
 
83
 
84
  model_path = Path(LLM_MODEL_PATH)
85
  _assert_llm_file(model_path)
86
 
87
- _LLM = Llama(
88
  model_path=str(model_path),
89
  n_ctx=int(LLM_N_CTX),
90
  n_threads=int(LLM_N_THREADS),
91
  n_batch=int(LLM_N_BATCH),
92
- verbose=False, # garde HF plus propre
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  )
94
- return _LLM
 
 
 
 
 
 
 
 
 
 
 
 
1
  # src/resources.py
2
  from __future__ import annotations
3
 
4
+ import os
5
  from pathlib import Path
6
+ from typing import Optional, List, Dict, Any
7
 
8
  from langchain_community.vectorstores import FAISS
9
  from langchain_huggingface import HuggingFaceEmbeddings
 
10
 
11
  from src.config import (
12
  DB_DIR,
 
18
  )
19
 
20
 
21
+ # --------------------
22
+ # Lazy singletons
23
+ # --------------------
24
  _VS: Optional[FAISS] = None
 
25
 
26
+ # LLM local (fallback)
27
+ _LLM_LOCAL = None
28
 
29
+ # Groq client (primary when GROQ_API_KEY is set)
30
+ _GROQ_CLIENT = None
31
+
32
+
33
+ # --------------------
34
+ # Helpers
35
+ # --------------------
36
  def _assert_vectorstore_files(db_dir: Path) -> None:
 
37
  if not db_dir.exists() or not db_dir.is_dir():
38
  raise RuntimeError(
39
  f"Vectorstore introuvable : {db_dir}\n"
 
42
 
43
  faiss_file = db_dir / "index.faiss"
44
  pkl_file = db_dir / "index.pkl"
 
45
  if not faiss_file.exists() or not pkl_file.exists():
46
  raise RuntimeError(
47
  f"Vectorstore incomplet dans {db_dir}\n"
 
49
  )
50
 
51
 
52
+ def _assert_llm_file(model_path: Path) -> None:
53
+ if not model_path.exists() or not model_path.is_file():
54
+ raise RuntimeError(
55
+ f"Modèle GGUF introuvable : {model_path}\n"
56
+ "Assure-toi que app.py a bien téléchargé/copier le modèle dans models/ "
57
+ "ou que LLM_MODEL_PATH pointe vers un fichier GGUF valide."
58
+ )
59
+
60
+
61
+ def is_groq_enabled() -> bool:
62
+ """Groq est actif si une clé est définie."""
63
+ return bool(os.environ.get("GROQ_API_KEY", "").strip())
64
+
65
+
66
+ def _get_groq_settings() -> Dict[str, Any]:
67
+ """Récupère les paramètres Groq depuis les variables d'environnement."""
68
+ return {
69
+ "model": os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant"),
70
+ "temperature": float(os.environ.get("GROQ_TEMPERATURE", "0.1")),
71
+ "max_tokens_summary": int(os.environ.get("GROQ_MAX_TOKENS_SUMMARY", "120")),
72
+ "max_tokens_qa": int(os.environ.get("GROQ_MAX_TOKENS_QA", "220")),
73
+ }
74
+
75
+
76
+ # --------------------
77
+ # Vectorstore (FAISS)
78
+ # --------------------
79
  def get_vectorstore() -> FAISS:
80
  """
81
  Charge FAISS + embeddings UNE fois (lazy-loading).
82
+ IMPORTANT : coûteux (CPU + I/O). N'appelle que si nécessaire.
83
  """
84
  global _VS
85
  if _VS is not None:
 
98
  return _VS
99
 
100
 
101
+ # --------------------
102
+ # LLM local (fallback)
103
+ # --------------------
104
+ def get_llm_local():
 
 
 
 
 
 
 
105
  """
106
+ Charge le modèle GGUF UNE fois (fallback uniquement).
107
+ Si Groq est activé, tu n'es pas censé l'appeler dans SUMMARY/QA.
108
  """
109
+ global _LLM_LOCAL
110
+ if _LLM_LOCAL is not None:
111
+ return _LLM_LOCAL
112
+
113
+ # Import ici pour éviter de charger llama_cpp inutilement si Groq est utilisé
114
+ from llama_cpp import Llama
115
 
116
  model_path = Path(LLM_MODEL_PATH)
117
  _assert_llm_file(model_path)
118
 
119
+ _LLM_LOCAL = Llama(
120
  model_path=str(model_path),
121
  n_ctx=int(LLM_N_CTX),
122
  n_threads=int(LLM_N_THREADS),
123
  n_batch=int(LLM_N_BATCH),
124
+ verbose=False,
125
+ )
126
+ return _LLM_LOCAL
127
+
128
+
129
+ # --------------------
130
+ # Groq client
131
+ # --------------------
132
+ def get_groq_client():
133
+ """
134
+ Instancie le client Groq UNE fois.
135
+ Utilise GROQ_API_KEY depuis l'environnement.
136
+ """
137
+ global _GROQ_CLIENT
138
+ if _GROQ_CLIENT is not None:
139
+ return _GROQ_CLIENT
140
+
141
+ # Import ici pour ne pas dépendre du package si on veut fallback local
142
+ from groq import Groq # type: ignore
143
+
144
+ # Le SDK lit GROQ_API_KEY automatiquement (ou via Groq(api_key=...))
145
+ _GROQ_CLIENT = Groq(api_key=os.environ.get("GROQ_API_KEY"))
146
+ return _GROQ_CLIENT
147
+
148
+
149
+ # --------------------
150
+ # Unified chat generation
151
+ # --------------------
152
+ def generate_chat(
153
+ messages: List[Dict[str, str]],
154
+ *,
155
+ max_tokens: int,
156
+ temperature: float,
157
+ ) -> str:
158
+ """
159
+ Génère une réponse à partir de messages de chat.
160
+
161
+ - Si GROQ_API_KEY est défini : utilise Groq (rapide).
162
+ - Sinon : fallback llama.cpp local.
163
+
164
+ messages format:
165
+ [{"role": "system"|"user"|"assistant", "content": "..."}]
166
+ """
167
+ if is_groq_enabled():
168
+ settings = _get_groq_settings()
169
+ client = get_groq_client()
170
+
171
+ resp = client.chat.completions.create(
172
+ model=settings["model"],
173
+ messages=messages,
174
+ temperature=temperature,
175
+ max_tokens=max_tokens,
176
+ )
177
+ return (resp.choices[0].message.content or "").strip()
178
+
179
+ # Fallback local llama.cpp
180
+ llm = get_llm_local()
181
+ out = llm.create_chat_completion(
182
+ messages=messages,
183
+ temperature=temperature,
184
+ max_tokens=max_tokens,
185
  )
186
+ return out["choices"][0]["message"]["content"].strip()
187
+
188
+
189
+ def groq_max_tokens_for(mode: str) -> int:
190
+ """
191
+ Helper pratique : renvoie la valeur max_tokens recommandée selon le mode.
192
+ mode : "summary" ou "qa"
193
+ """
194
+ s = _get_groq_settings()
195
+ if mode.lower().startswith("sum"):
196
+ return int(s["max_tokens_summary"])
197
+ return int(s["max_tokens_qa"])