Spaces:
Running
Running
Stabilize RAG core: add SUMMARY_AI, speed up LIST, clean resources and config
Browse files- src/config.py +112 -34
- src/qa.py +52 -11
- src/rag_core.py +91 -11
- src/resources.py +74 -17
src/config.py
CHANGED
|
@@ -1,55 +1,133 @@
|
|
| 1 |
# src/config.py
|
|
|
|
|
|
|
| 2 |
import os
|
| 3 |
import re
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
-
# Paths
|
| 7 |
-
CHUNKS_PATH = Path("data/chunks_articles.jsonl")
|
| 8 |
-
DB_DIR = Path("db/faiss_code_edu_by_article")
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
|
|
|
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
-
LLM_N_CTX = int(os.environ.get("LLM_N_CTX", "1024"))
|
| 16 |
-
LLM_N_THREADS = int(os.environ.get("LLM_N_THREADS", "10"))
|
| 17 |
-
LLM_N_BATCH = int(os.environ.get("LLM_N_BATCH", "128"))
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
QA_MAX_TOKENS = int(os.environ.get("QA_MAX_TOKENS", "160"))
|
| 22 |
-
QA_TEMPERATURE = float(os.environ.get("QA_TEMPERATURE", "0.2"))
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
EXPLAIN_TRIGGERS = [
|
| 30 |
-
"synthèse", "synthese", "points clés", "points cles",
|
| 31 |
-
"extraits", "extrait", "résumé extractif", "resume extractif",
|
| 32 |
-
]
|
| 33 |
LIST_TRIGGERS = [
|
| 34 |
-
"quels articles",
|
| 35 |
-
"
|
| 36 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
]
|
|
|
|
| 38 |
FULLTEXT_TRIGGERS = [
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
]
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
SYNTHESIS_REFUSAL = (
|
| 48 |
-
"Pour
|
| 49 |
-
"Sinon, commence par : \"Quels articles parlent de … ?\""
|
| 50 |
)
|
| 51 |
|
| 52 |
QA_WARNING = (
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# src/config.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
from pathlib import Path
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
# =========================
|
| 10 |
+
# Paths (HF / local)
|
| 11 |
+
# =========================
|
| 12 |
|
| 13 |
+
# Base directory = root of repo (…/hf-code-education)
|
| 14 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
# Data
|
| 17 |
+
CHUNKS_PATH = str(Path(os.environ.get("CHUNKS_PATH", BASE_DIR / "data" / "chunks_articles.jsonl")))
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
# Vectorstore (FAISS)
|
| 20 |
+
DB_DIR = str(Path(os.environ.get("DB_DIR", BASE_DIR / "db" / "faiss_code_edu_by_article")))
|
| 21 |
+
|
| 22 |
+
# Embeddings model for FAISS queries (used in QA mode)
|
| 23 |
+
EMBED_MODEL = os.environ.get("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
|
| 24 |
+
|
| 25 |
+
# LLM (GGUF) path
|
| 26 |
+
LLM_MODEL_PATH = str(Path(os.environ.get("LLM_MODEL_PATH", BASE_DIR / "models" / "model.gguf")))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# =========================
|
| 30 |
+
# Article ID regex
|
| 31 |
+
# =========================
|
| 32 |
+
|
| 33 |
+
# Match typical French code article IDs: L111-1, R421-10, D521-5, etc.
|
| 34 |
+
ARTICLE_ID_RE = re.compile(r"\b([LDR]\s?\d{1,4}(?:-\d+){1,4})\b", re.IGNORECASE)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# =========================
|
| 38 |
+
# Triggers (routing)
|
| 39 |
+
# =========================
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
LIST_TRIGGERS = [
|
| 42 |
+
"quels articles",
|
| 43 |
+
"quels sont les articles",
|
| 44 |
+
"articles sur",
|
| 45 |
+
"articles parlant",
|
| 46 |
+
"articles qui parlent",
|
| 47 |
+
"trouve des articles",
|
| 48 |
+
"trouver des articles",
|
| 49 |
+
"liste des articles",
|
| 50 |
+
"liste",
|
| 51 |
]
|
| 52 |
+
|
| 53 |
FULLTEXT_TRIGGERS = [
|
| 54 |
+
"intégralité",
|
| 55 |
+
"integralite",
|
| 56 |
+
"texte officiel",
|
| 57 |
+
"texte intégral",
|
| 58 |
+
"texte integral",
|
| 59 |
+
"donne l’intégralité",
|
| 60 |
+
"donne l'integralite",
|
| 61 |
+
"donne le texte",
|
| 62 |
+
"affiche l'article",
|
| 63 |
]
|
| 64 |
|
| 65 |
+
EXPLAIN_TRIGGERS = [
|
| 66 |
+
"explique",
|
| 67 |
+
"expliquer",
|
| 68 |
+
"synthèse",
|
| 69 |
+
"synthese",
|
| 70 |
+
"points clés",
|
| 71 |
+
"points cles",
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# =========================
|
| 76 |
+
# Messages utilisateur
|
| 77 |
+
# =========================
|
| 78 |
+
|
| 79 |
+
REFUSAL = (
|
| 80 |
+
"Je ne peux pas répondre à cette demande telle quelle.\n"
|
| 81 |
+
"Indique un thème (mode LIST) ou un identifiant d’article (mode FULLTEXT / Résumé / Synthèse)."
|
| 82 |
+
)
|
| 83 |
|
| 84 |
SYNTHESIS_REFUSAL = (
|
| 85 |
+
"Pour faire une synthèse, j’ai besoin d’un identifiant d’article (ex : D521-5)."
|
|
|
|
| 86 |
)
|
| 87 |
|
| 88 |
QA_WARNING = (
|
| 89 |
+
"Réponse IA : cette réponse peut contenir des erreurs. "
|
| 90 |
+
"Vérifie toujours sur le texte officiel et, en cas de doute, demande un avis juridique."
|
| 91 |
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# =========================
|
| 95 |
+
# QA settings (speed / safety)
|
| 96 |
+
# =========================
|
| 97 |
+
|
| 98 |
+
QA_TOP_K_FINAL = int(os.environ.get("QA_TOP_K_FINAL", "2"))
|
| 99 |
+
QA_DOC_MAX_CHARS = int(os.environ.get("QA_DOC_MAX_CHARS", "700"))
|
| 100 |
+
QA_MAX_TOKENS = int(os.environ.get("QA_MAX_TOKENS", "160"))
|
| 101 |
+
QA_TEMPERATURE = float(os.environ.get("QA_TEMPERATURE", "0.2"))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# =========================
|
| 105 |
+
# SUMMARY_AI settings (future move out of rag_core)
|
| 106 |
+
# =========================
|
| 107 |
+
|
| 108 |
+
SUMMARY_TRIGGERS = [
|
| 109 |
+
"résumé ia", "resume ia",
|
| 110 |
+
"résumé", "resume",
|
| 111 |
+
"résumer", "resumer",
|
| 112 |
+
"summary",
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
SUMMARY_WARNING = (
|
| 116 |
+
"Résumé IA : reformulation automatique (peut contenir des erreurs ou omissions). "
|
| 117 |
+
"Vérifie toujours sur le texte officiel."
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
SUMMARY_DOC_MAX_CHARS = int(os.environ.get("SUMMARY_DOC_MAX_CHARS", "1200"))
|
| 121 |
+
SUMMARY_MAX_TOKENS = int(os.environ.get("SUMMARY_MAX_TOKENS", "180"))
|
| 122 |
+
SUMMARY_TEMPERATURE = float(os.environ.get("SUMMARY_TEMPERATURE", "0.2"))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# =========================
|
| 126 |
+
# Llama.cpp settings
|
| 127 |
+
# =========================
|
| 128 |
+
|
| 129 |
+
# Important : sur HF CPU, trop de threads peut parfois dégrader.
|
| 130 |
+
# Laisse configurable. Valeur par défaut prudente.
|
| 131 |
+
LLM_N_CTX = int(os.environ.get("LLM_N_CTX", "1024"))
|
| 132 |
+
LLM_N_THREADS = int(os.environ.get("LLM_N_THREADS", str(max(1, (os.cpu_count() or 2) - 1))))
|
| 133 |
+
LLM_N_BATCH = int(os.environ.get("LLM_N_BATCH", "128"))
|
src/qa.py
CHANGED
|
@@ -4,10 +4,11 @@
|
|
| 4 |
"""
|
| 5 |
qa.py — Mode QA (interprétatif, LLM CPU, plus lent)
|
| 6 |
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
-
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -21,29 +22,40 @@ from typing import List
|
|
| 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", "160"))
|
| 27 |
qa_temperature: float = float(os.environ.get("QA_TEMPERATURE", "0.2"))
|
| 28 |
|
| 29 |
|
| 30 |
-
# ====================
|
| 31 |
|
| 32 |
def truncate_text(s: str, n: int) -> str:
|
|
|
|
| 33 |
if not s:
|
| 34 |
return ""
|
| 35 |
s = s.strip()
|
| 36 |
return s if len(s) <= n else s[:n].rstrip() + "\n[...]\n"
|
| 37 |
|
| 38 |
|
|
|
|
|
|
|
| 39 |
def build_qa_prompt_fast(question: str, context: str, sources: List[str]) -> str:
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
return f"""Tu es un assistant qui aide à comprendre le Code de l'éducation (France).
|
| 42 |
|
| 43 |
CONTRAINTE :
|
| 44 |
-
- Appuie-toi
|
| 45 |
-
- Si l'information n'est pas dans le contexte, dis-le
|
| 46 |
-
- Réponse courte, pratique, 6
|
|
|
|
| 47 |
|
| 48 |
QUESTION :
|
| 49 |
{question}
|
|
@@ -51,7 +63,31 @@ QUESTION :
|
|
| 51 |
CONTEXTE :
|
| 52 |
{context}
|
| 53 |
|
| 54 |
-
Indique à la fin : "Sources (articles) : {src}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"""
|
| 56 |
|
| 57 |
|
|
@@ -59,9 +95,14 @@ Indique à la fin : "Sources (articles) : {src}"
|
|
| 59 |
|
| 60 |
def llm_generate_qa(llm, prompt: str, cfg: QAConfig | None = None) -> str:
|
| 61 |
"""
|
| 62 |
-
llm: instance llama_cpp.Llama créée ailleurs (ex: dans rag_core
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
"""
|
| 64 |
cfg = cfg or QAConfig()
|
|
|
|
| 65 |
out = llm.create_chat_completion(
|
| 66 |
messages=[{"role": "user", "content": prompt}],
|
| 67 |
temperature=cfg.qa_temperature,
|
|
|
|
| 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
|
|
|
|
| 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()
|
| 39 |
return s if len(s) <= n else s[:n].rstrip() + "\n[...]\n"
|
| 40 |
|
| 41 |
|
| 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 |
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 |
|
|
|
|
| 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,
|
src/rag_core.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
|
|
| 1 |
# src/rag_core.py
|
| 2 |
from __future__ import annotations
|
|
|
|
| 3 |
from typing import Dict, Any, List
|
| 4 |
import json
|
| 5 |
|
| 6 |
-
|
| 7 |
from src import list as list_mode
|
| 8 |
from src import fulltext as fulltext_mode
|
| 9 |
from src import synthesis as synthesis_mode
|
| 10 |
from src import qa as qa_mode
|
| 11 |
-
from src import resources
|
| 12 |
-
|
| 13 |
|
| 14 |
from src.config import (
|
| 15 |
CHUNKS_PATH,
|
|
@@ -33,6 +32,50 @@ from src.utils import (
|
|
| 33 |
from src.resources import get_vectorstore, get_llm
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# ====================
|
| 38 |
# CHARGEMENT CORPUS (UNE FOIS)
|
|
@@ -40,6 +83,7 @@ from src.resources import get_vectorstore, get_llm
|
|
| 40 |
|
| 41 |
_ARTICLES: Dict[str, str] | None = None
|
| 42 |
|
|
|
|
| 43 |
def get_all_articles() -> Dict[str, str]:
|
| 44 |
global _ARTICLES
|
| 45 |
if _ARTICLES is None:
|
|
@@ -105,6 +149,40 @@ def _qa_answer(question: str) -> Dict[str, Any]:
|
|
| 105 |
}
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
# ====================
|
| 109 |
# ROUTEUR
|
| 110 |
# ====================
|
|
@@ -126,12 +204,16 @@ def answer_query(q: str) -> Dict[str, Any]:
|
|
| 126 |
"articles": [article_id],
|
| 127 |
}
|
| 128 |
|
| 129 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
if is_list_request(q):
|
| 131 |
return list_mode.list_articles(
|
| 132 |
q,
|
| 133 |
-
articles=get_all_articles(),
|
| 134 |
-
vs=
|
| 135 |
normalize_article_id=normalize_article_id,
|
| 136 |
list_triggers=LIST_TRIGGERS,
|
| 137 |
cfg=list_mode.ListConfig(),
|
|
@@ -157,18 +239,16 @@ def answer_query(q: str) -> Dict[str, Any]:
|
|
| 157 |
"articles": [article_id],
|
| 158 |
}
|
| 159 |
|
| 160 |
-
|
| 161 |
-
# LIST par défaut si requête courte (nominale)
|
| 162 |
if len(q.split()) <= 5:
|
| 163 |
return list_mode.list_articles(
|
| 164 |
q,
|
| 165 |
articles=get_all_articles(),
|
| 166 |
-
vs=
|
| 167 |
normalize_article_id=normalize_article_id,
|
| 168 |
list_triggers=LIST_TRIGGERS,
|
| 169 |
cfg=list_mode.ListConfig(),
|
| 170 |
)
|
| 171 |
|
| 172 |
-
# QA
|
| 173 |
return _qa_answer(q)
|
| 174 |
-
|
|
|
|
| 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
|
| 10 |
from src import synthesis as synthesis_mode
|
| 11 |
from src import qa as qa_mode
|
|
|
|
|
|
|
| 12 |
|
| 13 |
from src.config import (
|
| 14 |
CHUNKS_PATH,
|
|
|
|
| 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 = (
|
| 48 |
+
"Résumé IA : reformulation automatique (peut contenir des erreurs ou omissions). "
|
| 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 = 1200
|
| 54 |
+
SUMMARY_MAX_TOKENS = 180
|
| 55 |
+
SUMMARY_TEMPERATURE = 0.2
|
| 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 à 8 puces maximum.
|
| 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 |
# ====================
|
| 81 |
# CHARGEMENT CORPUS (UNE FOIS)
|
|
|
|
| 83 |
|
| 84 |
_ARTICLES: Dict[str, str] | None = None
|
| 85 |
|
| 86 |
+
|
| 87 |
def get_all_articles() -> Dict[str, str]:
|
| 88 |
global _ARTICLES
|
| 89 |
if _ARTICLES is None:
|
|
|
|
| 149 |
}
|
| 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 |
# ====================
|
|
|
|
| 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(),
|
|
|
|
| 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)
|
|
|
src/resources.py
CHANGED
|
@@ -1,37 +1,94 @@
|
|
| 1 |
# src/resources.py
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
from typing import Optional
|
|
|
|
| 5 |
from langchain_community.vectorstores import FAISS
|
| 6 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 7 |
from llama_cpp import Llama
|
| 8 |
|
| 9 |
-
from src.config import
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
_VS: Optional[FAISS] = None
|
| 14 |
_LLM: Optional[Llama] = None
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def get_vectorstore() -> FAISS:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
global _VS
|
| 18 |
-
if _VS is None:
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
return _VS
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def get_llm() -> Llama:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
global _LLM
|
| 29 |
-
if _LLM is None:
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return _LLM
|
|
|
|
| 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,
|
| 13 |
+
EMBED_MODEL,
|
| 14 |
+
LLM_MODEL_PATH,
|
| 15 |
+
LLM_N_CTX,
|
| 16 |
+
LLM_N_THREADS,
|
| 17 |
+
LLM_N_BATCH,
|
| 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"
|
| 30 |
+
"Attendu : un dossier contenant un index FAISS (ex: index.faiss, index.pkl)."
|
| 31 |
+
)
|
| 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"
|
| 39 |
+
f"Fichiers attendus : {faiss_file.name} et {pkl_file.name}"
|
| 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:
|
| 50 |
+
return _VS
|
| 51 |
+
|
| 52 |
+
db_dir = Path(DB_DIR)
|
| 53 |
+
_assert_vectorstore_files(db_dir)
|
| 54 |
+
|
| 55 |
+
embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL)
|
| 56 |
+
|
| 57 |
+
_VS = FAISS.load_local(
|
| 58 |
+
str(db_dir),
|
| 59 |
+
embeddings,
|
| 60 |
+
allow_dangerous_deserialization=True,
|
| 61 |
+
)
|
| 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
|