code-education-rag / src /rag_core.py
FabIndy's picture
Switch to Groq-only LLM, remove GGUF dependency, speed up build and inference
56a777c
# src/rag_core.py
from __future__ import annotations
from typing import Dict, Any, List
import json
import os
from src import list as list_mode
from src import fulltext as fulltext_mode
from src import synthesis as synthesis_mode
from src import qa as qa_mode
from src.config import (
CHUNKS_PATH,
LIST_TRIGGERS,
REFUSAL,
SYNTHESIS_REFUSAL,
QA_WARNING,
QA_TOP_K_FINAL,
QA_DOC_MAX_CHARS,
)
from src.utils import (
normalize_article_id,
extract_article_id,
is_list_request,
is_fulltext_request,
is_synthesis_request,
)
from src.resources import get_vectorstore, groq_max_tokens_for
# ====================
# SUMMARY_AI (Groq-only, rapide)
# ====================
SUMMARY_TRIGGERS = [
"résumé ia", "resume ia",
"résume ia", "resume-ia",
"summary ia", "ai summary",
]
SUMMARY_WARNING = (
"Résumé IA : reformulation automatique (peut contenir des erreurs ou omissions). "
"Vérifie toujours sur le texte officiel."
)
def is_summary_request(q: str) -> bool:
ql = (q or "").lower()
return any(t in ql for t in SUMMARY_TRIGGERS)
def _build_summary_context_from_extractive(article_id: str, full_text: str) -> str:
"""
Construit un contexte court à partir de la synthèse extractive existante.
On récupère 3–4 segments "- ..." pour alimenter le LLM avec très peu de texte.
"""
extract = synthesis_mode.extractive_summary(article_id, full_text)
lines: List[str] = []
for line in extract.splitlines():
line = line.strip()
if line.startswith("- "):
seg = line[2:].strip()
if seg:
lines.append(seg)
lines = lines[:4] # limite dure
if not lines:
# fallback ultra sûr
return qa_mode.truncate_text(full_text, 400)
return "\n".join(f"- {l}" for l in lines)
def _summary_ai(article_id: str) -> Dict[str, Any]:
article_id = normalize_article_id(article_id)
text = load_article_text(article_id)
if not text:
return {
"mode": "SUMMARY_AI",
"answer": f"Article {article_id} introuvable.",
"articles": [],
}
# Contexte réduit (extraits) pour accélérer
context = _build_summary_context_from_extractive(article_id, text)
# Prompt strict FR + puces (défini dans qa.py)
prompt = qa_mode.build_summary_prompt(article_id, context)
# Paramètres Groq (via env vars)
cfg = qa_mode.QAConfig(
qa_top_k_final=1,
qa_doc_max_chars=600,
qa_max_tokens=groq_max_tokens_for("summary"),
qa_temperature=float(os.environ.get("GROQ_TEMPERATURE", "0.1")),
)
ans = qa_mode.llm_generate_qa(prompt, cfg=cfg).strip()
return {
"mode": "SUMMARY_AI",
"answer": f"{SUMMARY_WARNING}\n\n{ans}",
"articles": [article_id],
}
# ====================
# CHARGEMENT CORPUS (UNE FOIS)
# ====================
_ARTICLES: Dict[str, str] | None = None
def get_all_articles() -> Dict[str, str]:
global _ARTICLES
if _ARTICLES is None:
articles: Dict[str, str] = {}
with open(CHUNKS_PATH, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
obj = json.loads(line)
aid = normalize_article_id(obj.get("article_id", ""))
text = obj.get("text") or obj.get("page_content") or ""
if aid and text:
articles[aid] = text
_ARTICLES = articles
return _ARTICLES
# ====================
# FULLTEXT
# ====================
def load_article_text(article_id: str) -> str | None:
return fulltext_mode.load_article_text(
normalize_article_id(article_id),
CHUNKS_PATH,
)
# ====================
# QA (Groq-only pour la génération)
# ====================
def _qa_answer(question: str) -> Dict[str, Any]:
# Retrieval vectoriel (FAISS)
vs = get_vectorstore()
docs = vs.similarity_search(question, k=max(1, QA_TOP_K_FINAL))
sources: List[str] = []
for d in docs:
aid = normalize_article_id(d.metadata.get("article_id", ""))
if aid and aid not in sources:
sources.append(aid)
ctx_parts: List[str] = []
for d in docs[:max(1, QA_TOP_K_FINAL)]:
aid = normalize_article_id(d.metadata.get("article_id", "UNKNOWN"))
txt = qa_mode.truncate_text(d.page_content or "", QA_DOC_MAX_CHARS)
ctx_parts.append(f"[{aid}]\n{txt}")
prompt = qa_mode.build_qa_prompt_fast(question, "\n\n".join(ctx_parts), sources)
cfg = qa_mode.QAConfig(
qa_top_k_final=QA_TOP_K_FINAL,
qa_doc_max_chars=QA_DOC_MAX_CHARS,
qa_max_tokens=groq_max_tokens_for("qa"),
qa_temperature=float(os.environ.get("GROQ_TEMPERATURE", "0.1")),
)
ans = qa_mode.llm_generate_qa(prompt, cfg=cfg).strip()
return {
"mode": "QA",
"answer": f"{QA_WARNING}\n\n{ans}",
"articles": sources,
}
# ====================
# ROUTEUR
# ====================
def _looks_like_question(q: str) -> bool:
"""
Détecte une intention de question, même si la requête est courte.
C'est crucial pour éviter que des questions tombent dans LIST par défaut.
"""
ql = (q or "").strip().lower()
if "?" in ql:
return True
starters = (
"que ", "qu'", "quoi", "comment", "pourquoi", "quand", "où",
"est-ce", "peux", "peut", "dois", "doit", "faut", "faudrait",
"quelle", "quelles", "quel", "quels",
)
return ql.startswith(starters)
def answer_query(q: str) -> Dict[str, Any]:
q = (q or "").strip()
if not q:
return {"mode": "QA", "answer": REFUSAL, "articles": []}
article_id = extract_article_id(q)
# 1) FULLTEXT
if article_id and is_fulltext_request(q):
article_id = normalize_article_id(article_id)
text = load_article_text(article_id)
return {
"mode": "FULLTEXT",
"answer": text or REFUSAL,
"articles": [article_id],
}
# 2) SUMMARY_AI (Résumé IA)
if article_id and is_summary_request(q):
return _summary_ai(article_id)
# 3) SYNTHESIS (extractif fiable)
if is_synthesis_request(q):
if not article_id:
return {"mode": "SYNTHESIS", "answer": SYNTHESIS_REFUSAL, "articles": []}
article_id = normalize_article_id(article_id)
text = load_article_text(article_id)
if not text:
return {
"mode": "SYNTHESIS",
"answer": f"Article {article_id} introuvable.",
"articles": [],
}
return {
"mode": "SYNTHESIS",
"answer": synthesis_mode.extractive_summary(article_id, text),
"articles": [article_id],
}
# 4) LIST explicite
if is_list_request(q):
return list_mode.list_articles(
q,
articles=get_all_articles(),
vs=None, # important : LIST doit rester léger/explicable
normalize_article_id=normalize_article_id,
list_triggers=LIST_TRIGGERS,
cfg=list_mode.ListConfig(),
)
# 5) Routage robuste : si c'est une QUESTION, on force QA
if _looks_like_question(q):
return _qa_answer(q)
# 6) Si un article est mentionné et que ce n'est pas un mode dédié,
# on privilégie QA (cas : "Que dit l'article D521-5" sans forcément de "?")
if article_id:
return _qa_answer(q)
# 7) LIST par défaut si requête courte (mots-clés)
if len(q.split()) <= 5:
return list_mode.list_articles(
q,
articles=get_all_articles(),
vs=None,
normalize_article_id=normalize_article_id,
list_triggers=LIST_TRIGGERS,
cfg=list_mode.ListConfig(),
)
# 8) QA par défaut
return _qa_answer(q)