# 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)