Spaces:
Running
Running
Refactor UI and RAG modes: extractive synthesis for EXPLAIN, faster QA with warnings
Browse files- app.py +87 -89
- 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
|
| 97 |
-
We
|
| 98 |
"""
|
| 99 |
if result is None:
|
| 100 |
-
return "Aucune réponse
|
| 101 |
|
| 102 |
if isinstance(result, str):
|
| 103 |
-
return result
|
| 104 |
|
| 105 |
if isinstance(result, dict):
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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,
|
| 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 |
-
|
|
|
|
| 157 |
a = (article_id or "").strip()
|
| 158 |
if not a:
|
| 159 |
return "Entre un identifiant d’article (ex : D422-5)."
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 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 (
|
| 175 |
return call_core(q)
|
| 176 |
|
|
|
|
| 177 |
def clear_all():
|
| 178 |
-
return "", "", "", "
|
| 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:
|
| 203 |
overflow-y: auto !important;
|
| 204 |
font-size: 14px;
|
| 205 |
line-height: 1.55;
|
| 206 |
}
|
| 207 |
-
.
|
| 208 |
-
|
|
|
|
| 209 |
}
|
| 210 |
"""
|
| 211 |
|
| 212 |
THEME = gr.themes.Soft()
|
| 213 |
|
| 214 |
-
with gr.Blocks(title="
|
| 215 |
gr.Markdown(
|
| 216 |
"""
|
| 217 |
-
#
|
| 218 |
-
Cet outil recherche dans le Code de l’éducation et répond **uniquement** à partir des articles retrouvés.
|
| 219 |
|
| 220 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
""".strip()
|
| 227 |
)
|
| 228 |
|
| 229 |
gr.Markdown(
|
| 230 |
"""
|
| 231 |
-
> **
|
| 232 |
-
>
|
| 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
|
| 240 |
list_inp = gr.Textbox(
|
| 241 |
label="Thème",
|
| 242 |
-
placeholder="Ex : vacances scolaires,
|
| 243 |
lines=1,
|
| 244 |
)
|
| 245 |
list_btn = gr.Button("Lister les articles", variant="primary")
|
| 246 |
gr.Markdown(
|
| 247 |
-
"
|
| 248 |
)
|
| 249 |
|
| 250 |
-
with gr.Tab("Texte exact
|
| 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 |
-
"
|
| 259 |
)
|
| 260 |
|
| 261 |
-
with gr.Tab("
|
| 262 |
-
|
| 263 |
label="Identifiant d’article",
|
| 264 |
placeholder="Ex : D422-5",
|
| 265 |
lines=1,
|
| 266 |
)
|
| 267 |
-
|
| 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 |
-
"
|
| 275 |
-
"Pour de bonnes performances, reste sur **un article précis**."
|
| 276 |
)
|
| 277 |
|
| 278 |
-
with gr.Tab("Question
|
| 279 |
-
|
| 280 |
label="Votre question",
|
| 281 |
-
placeholder="Ex : Un chef d’établissement peut-il organiser un conseil de classe après
|
| 282 |
-
lines=
|
| 283 |
-
max_lines=
|
| 284 |
)
|
| 285 |
-
|
| 286 |
gr.Markdown(
|
| 287 |
-
"
|
| 288 |
)
|
| 289 |
|
| 290 |
-
|
| 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 |
-
|
| 300 |
-
|
|
|
|
| 301 |
clear.click(
|
| 302 |
clear_all,
|
| 303 |
-
outputs=[list_inp, full_inp,
|
| 304 |
)
|
| 305 |
|
| 306 |
with gr.Accordion("Exemples", open=False):
|
| 307 |
gr.Markdown(
|
| 308 |
-
"- Trouver : `vacances scolaires`\n"
|
| 309 |
-
"- Texte exact : `D422-5`\n"
|
| 310 |
-
"-
|
| 311 |
-
"-
|
|
|
|
| 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 –
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
-
|
| 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 |
-
# ---
|
| 44 |
-
EXTRACT_MAX_SEGMENTS = 5
|
| 45 |
-
EXTRACT_MAX_CHARS_TOTAL = 900
|
| 46 |
-
EXTRACT_MIN_SEG_LEN = 30
|
| 47 |
-
EXTRACT_MAX_SEG_LEN = 420
|
| 48 |
|
| 49 |
-
#
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 79 |
"Sinon, commence par : \"Quels articles parlent de … ?\""
|
| 80 |
)
|
| 81 |
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
llm = Llama(
|
| 86 |
model_path="models/model.gguf",
|
| 87 |
-
n_ctx=1024,
|
| 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=
|
| 98 |
-
max_tokens=
|
| 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"\
|
| 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
|
| 234 |
"""
|
| 235 |
-
|
| 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"
|
| 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 |
-
"
|
| 270 |
+ "\n".join(out_parts)
|
| 271 |
)
|
| 272 |
return f"{body}\n\nArticles cités : {article_id}"
|
| 273 |
|
| 274 |
|
| 275 |
-
|
| 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 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
"""
|
| 289 |
|
| 290 |
|
| 291 |
-
def
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
-
|
| 296 |
-
-
|
| 297 |
-
-
|
| 298 |
-
-
|
| 299 |
|
| 300 |
QUESTION :
|
| 301 |
{question}
|
|
@@ -303,10 +293,8 @@ QUESTION :
|
|
| 303 |
CONTEXTE :
|
| 304 |
{context}
|
| 305 |
|
| 306 |
-
|
| 307 |
-
|
| 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 |
-
# ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
vs = get_vectorstore()
|
| 362 |
-
docs = vs.similarity_search(q, k=
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
-
|
| 367 |
-
|
| 368 |
|
| 369 |
-
return {"mode": "QA", "answer":
|
|
|
|
| 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}
|