Number_one / app.py
EnriqueAlves's picture
Reactive BM25 (lecture paginee) sur la version du collegue (garde bge-reranker + k=2)
9f65306
Raw
History Blame Contribute Delete
28.1 kB
"""
RAG Chat API - Gustave Eiffel Hackathon 2026
=============================================
Pipeline RAG avance :
1. Recherche HYBRIDE : vectoriel + BM25 fusionnes par RRF [v2bm25]
2. Reranking par CROSS-ENCODER local (multilingue, gratuit, CPU) [v3rerank]
3. ROUTING petit/gros modele (GPT-5-mini <-> GPT-5.1) [v3routing]
- Question courte / QCM ferme -> mini d'abord, escalade au gros si reponse faible
- Question longue / ouverte -> gros modele directement (evite la double facturation)
L'index BM25 et le cross-encoder se construisent au demarrage a partir des
chunks deja presents dans ChromaDB (pas de re-embedding necessaire).
"""
import os
import re
import json
import logging
import time
import math
from pathlib import Path
from typing import Optional
os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")
import requests as http_requests
import gradio as gr
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import chromadb
from chromadb.config import Settings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pypdf import PdfReader
from llm import call_llm as call_llm_with_metrics
# [v2bm25] BM25 keyword search (graceful fallback if not installed)
try:
from rank_bm25 import BM25Okapi
_BM25_AVAILABLE = True
except ImportError:
_BM25_AVAILABLE = False
# [v3rerank] Cross-encoder reranker (graceful fallback if not installed)
try:
from sentence_transformers import CrossEncoder
_CE_AVAILABLE = True
except ImportError:
_CE_AVAILABLE = False
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
if not _BM25_AVAILABLE:
logger.warning("rank-bm25 not installed -- falling back to vector-only retrieval.")
if not _CE_AVAILABLE:
logger.warning("sentence-transformers not installed -- reranking disabled.")
logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DATA_DIR = Path("/data") if Path("/data").is_dir() else Path("./data")
CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
TRAIN_DOCS_DIR = Path("./train_data")
COLLECTION_NAME = "rag_documents"
CHUNK_SIZE = 512
CHUNK_OVERLAP = 50
TOP_K_RESULTS = 2
# [v2bm25] hybrid retrieval candidates before fusion
RRF_CANDIDATES = 20
RRF_K = 60
# [v3rerank] number of candidates fed to the cross-encoder before final cut
RERANK_INPUT_K = 15
CROSS_ENCODER_MODEL = "BAAI/bge-reranker-v2-m3"
# [v3routing] question classification threshold (words)
SHORT_Q_MAX_WORDS = 25
# [v4guardrail] Abstention par score cross-encoder (sigmoide, 0-1) du MEILLEUR
# passage. En dessous du seuil -> question hors corpus -> abstention.
RERANK_ABSTAIN_THRESHOLD = 0.15
ABSTAIN_ANSWER = "Le contexte fourni ne permet pas de repondre a cette question."
# [v4qcm] Directive ajoutee au prompt quand QCM + contexte pertinent.
QCM_DIRECTIVE = (
"\n\nINSTRUCTION SUPPLEMENTAIRE : Cette question est un QCM et le contexte "
"fourni est juge pertinent. Tu DOIS choisir la meilleure reponse parmi les "
"options proposees en t'appuyant sur le contexte, meme si la reponse n'y est "
"pas formulee explicitement : raisonne a partir des elements disponibles et "
"tranche. Commence 'answer' par la ou les lettres correctes."
)
# explicit "weak answer" markers that trigger escalation to the large model
_WEAK_MARKERS = [
"ne permet pas de répondre", "ne permet pas de repondre",
"don't have enough", "do not have enough", "i don't have enough",
"cannot answer", "i cannot answer",
"je ne sais pas",
]
_CONFIG_PATH = DATA_DIR / "config.json"
if not _CONFIG_PATH.exists():
_CONFIG_PATH = Path(__file__).parent / "config.json"
logger.warning(
f"No config.json found in {DATA_DIR} -- falling back to root config.json (example file)."
)
with open(_CONFIG_PATH, encoding="utf-8") as _f:
_config = json.load(_f)
# Embedding model (Azure OpenAI)
EMBEDDING_ENDPOINT_URL = _config["embedding"]["endpoint_url"]
EMBEDDING_MODEL_NAME = _config["embedding"]["model"]
# LLM (large / default) -- Azure OpenAI
LLM_ENDPOINT_URL = _config["llm"]["endpoint_url"]
LLM_MODEL_NAME = _config["llm"]["model"]
LLM_MAX_TOKENS = _config["llm"].get("max_completion_tokens", 512)
LLM_TEMPERATURE = _config["llm"].get("temperature", 0.7)
LLM_TOP_P = _config["llm"].get("top_p", 0.95)
# [v3routing] LLM small (mini) -- optional. If absent, routing is disabled.
_llm_small = _config.get("llm_small")
if _llm_small:
LLM_SMALL_ENDPOINT_URL = _llm_small["endpoint_url"]
LLM_SMALL_MODEL_NAME = _llm_small["model"]
LLM_SMALL_MAX_TOKENS = _llm_small.get("max_completion_tokens", 512)
LLM_SMALL_TEMPERATURE = _llm_small.get("temperature", 0.7)
LLM_SMALL_TOP_P = _llm_small.get("top_p", 0.95)
_ROUTING_ENABLED = True
logger.info(f"Routing enabled: small={LLM_SMALL_MODEL_NAME}, large={LLM_MODEL_NAME}")
else:
_ROUTING_ENABLED = False
logger.info("No llm_small in config -- routing disabled, large model only.")
AZURE_API_KEY = os.environ.get("AZURE_API_KEY")
if not AZURE_API_KEY:
logger.warning("AZURE_API_KEY is not set -- LLM and embedding calls will fail.")
_PROMPT_TEMPLATE_PATH = Path(__file__).parent / "prompts" / "rag_prompt.txt"
RAG_PROMPT_TEMPLATE = _PROMPT_TEMPLATE_PATH.read_text(encoding="utf-8")
logger.info(f"Embedding model configured: {EMBEDDING_MODEL_NAME} via Azure OpenAI")
# ---------------------------------------------------------------------------
# Vector Store (ChromaDB)
# ---------------------------------------------------------------------------
logger.info(f"Initializing ChromaDB at: {CHROMA_PERSIST_DIR}")
chroma_client = chromadb.PersistentClient(
path=CHROMA_PERSIST_DIR,
settings=Settings(anonymized_telemetry=False),
)
collection = chroma_client.get_or_create_collection(
name=COLLECTION_NAME,
metadata={"hnsw:space": "cosine"},
)
logger.info(f"ChromaDB collection '{COLLECTION_NAME}' ready. Documents: {collection.count()}")
# ---------------------------------------------------------------------------
# [v2bm25] BM25 keyword index
# ---------------------------------------------------------------------------
_bm25_index = None
_bm25_ids: list[str] = []
_bm25_docs: list[str] = []
_bm25_metas: list[dict] = []
def _tokenize(text: str) -> list[str]:
return re.findall(r"\w+", text.lower())
def build_bm25_index() -> None:
global _bm25_index, _bm25_ids, _bm25_docs, _bm25_metas
if not _BM25_AVAILABLE:
return
try:
# Lecture PAGINEE : un collection.get() global depasse la limite SQLite
# "too many SQL variables" sur un gros corpus (84k chunks) -> BM25 jamais construit.
total = collection.count()
_bm25_ids, _bm25_docs, _bm25_metas = [], [], []
_off, _step = 0, 2000
while _off < total:
_batch = collection.get(include=["documents", "metadatas"], limit=_step, offset=_off)
_bm25_ids.extend(_batch.get("ids", []) or [])
_bm25_docs.extend(_batch.get("documents", []) or [])
_bm25_metas.extend(_batch.get("metadatas", []) or [])
_off += _step
if not _bm25_docs:
_bm25_index = None
logger.info("BM25 index empty (no documents in store yet).")
return
tokenized_corpus = [_tokenize(d) for d in _bm25_docs]
_bm25_index = BM25Okapi(tokenized_corpus)
logger.info(f"BM25 index built over {len(_bm25_docs)} chunks.")
except Exception as e:
_bm25_index = None
logger.warning(f"Failed to build BM25 index: {e}")
# ---------------------------------------------------------------------------
# [v3rerank] Cross-encoder reranker
# ---------------------------------------------------------------------------
_cross_encoder = None
def load_cross_encoder() -> None:
global _cross_encoder
if not _CE_AVAILABLE:
return
try:
logger.info(f"Loading cross-encoder '{CROSS_ENCODER_MODEL}' (first run downloads the model)...")
_cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL, max_length=512)
logger.info("Cross-encoder ready.")
except Exception as e:
_cross_encoder = None
logger.warning(f"Failed to load cross-encoder: {e}")
def rerank_contexts(query: str, contexts: list[dict], top_k: int) -> list[dict]:
"""Rerank candidate chunks with the cross-encoder, keep the top_k best."""
if _cross_encoder is None or len(contexts) <= 1:
return contexts[:top_k]
try:
pairs = [[query, c["text"]] for c in contexts]
scores = _cross_encoder.predict(pairs)
for c, s in zip(contexts, scores):
c["rerank_score"] = float(s)
contexts.sort(key=lambda c: c.get("rerank_score", 0.0), reverse=True)
except Exception as e:
logger.warning(f"Reranking failed, falling back to fusion order: {e}")
return contexts[:top_k]
logger.info(f"LLM configured: {LLM_MODEL_NAME} via {LLM_ENDPOINT_URL}")
# ---------------------------------------------------------------------------
# Helper Functions
# ---------------------------------------------------------------------------
def extract_text_from_pdf(pdf_path: Path) -> str:
reader = PdfReader(str(pdf_path))
pages_text = []
for page_num, page in enumerate(reader.pages, start=1):
text = page.extract_text()
if text and text.strip():
pages_text.append(f"[Page {page_num}]\n{text.strip()}")
full_text = "\n\n".join(pages_text)
logger.info(f"Extracted {len(reader.pages)} pages from PDF: {pdf_path.name} ({len(full_text)} chars)")
return full_text
def chunk_text(text: str, source: str = "unknown") -> list[dict]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(text)
return [{"text": chunk, "source": source, "chunk_index": i} for i, chunk in enumerate(chunks)]
def generate_embeddings(texts: list[str]) -> list[list[float]]:
headers = {"api-key": AZURE_API_KEY, "Content-Type": "application/json"}
payload = {"input": texts, "model": EMBEDDING_MODEL_NAME}
try:
resp = http_requests.post(EMBEDDING_ENDPOINT_URL, headers=headers, json=payload, timeout=120)
resp.raise_for_status()
data = resp.json()
return [item["embedding"] for item in data["data"]]
except http_requests.exceptions.HTTPError as e:
logger.error(f"Embedding API call failed: {e} -- {resp.text}")
raise HTTPException(status_code=503, detail=f"Embedding service unavailable: {str(e)}")
except (http_requests.exceptions.JSONDecodeError, ValueError):
logger.error(f"Embedding API returned non-JSON response (status {resp.status_code}): {repr(resp.text)}")
raise HTTPException(status_code=502, detail="Embedding service returned an invalid response")
except (KeyError, IndexError) as e:
logger.error(f"Unexpected embedding response format: {e} -- body: {resp.text}")
raise HTTPException(status_code=502, detail="Unexpected response from embedding service")
def add_documents_to_vectorstore(documents: list[dict]) -> int:
if not documents:
return 0
texts = [doc["text"] for doc in documents]
embeddings = generate_embeddings(texts)
existing_count = collection.count()
ids = [f"doc_{existing_count + i}" for i in range(len(documents))]
metadatas = [{"source": doc["source"], "chunk_index": doc["chunk_index"]} for doc in documents]
collection.add(ids=ids, embeddings=embeddings, documents=texts, metadatas=metadatas)
logger.info(f"Added {len(documents)} chunks to vector store. Total: {collection.count()}")
build_bm25_index() # [v2bm25] keep keyword index in sync
return len(documents)
def retrieve_relevant_context(query: str, top_k: int = TOP_K_RESULTS) -> list[dict]:
"""
Hybrid retrieval (vector + BM25, RRF) -> optional cross-encoder rerank -> top_k.
"""
n = collection.count()
if n == 0:
return []
candidate_k = min(max(top_k * 5, RRF_CANDIDATES), n)
# --- 1) Vector search (semantic) ---
query_embedding = generate_embeddings([query])[0]
vres = collection.query(
query_embeddings=[query_embedding],
n_results=candidate_k,
include=["documents", "metadatas", "distances"],
)
v_ids = vres["ids"][0]
v_docs = vres["documents"][0]
v_metas = vres["metadatas"][0]
doc_lookup: dict[str, tuple[str, str]] = {}
vector_rank: dict[str, int] = {}
for rank, (did, dtext, dmeta) in enumerate(zip(v_ids, v_docs, v_metas)):
vector_rank[did] = rank
doc_lookup[did] = (dtext, (dmeta or {}).get("source", "unknown"))
# --- 2) BM25 search (keywords) ---
bm25_rank: dict[str, int] = {}
if _bm25_index is not None:
scores = _bm25_index.get_scores(_tokenize(query))
top_positions = sorted(range(len(scores)), key=lambda p: scores[p], reverse=True)[:candidate_k]
for rank, pos in enumerate(top_positions):
did = _bm25_ids[pos]
bm25_rank[did] = rank
if did not in doc_lookup:
doc_lookup[did] = (_bm25_docs[pos], (_bm25_metas[pos] or {}).get("source", "unknown"))
# --- 3) Reciprocal Rank Fusion ---
fused: dict[str, float] = {}
for did, rank in vector_rank.items():
fused[did] = fused.get(did, 0.0) + 1.0 / (RRF_K + rank + 1)
for did, rank in bm25_rank.items():
fused[did] = fused.get(did, 0.0) + 1.0 / (RRF_K + rank + 1)
# keep more candidates if a reranker will refine them
pre_k = RERANK_INPUT_K if _cross_encoder is not None else top_k
ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:pre_k]
contexts = []
for did, fused_score in ranked:
text, source = doc_lookup[did]
contexts.append({"text": text, "source": source, "similarity_score": round(fused_score, 4)})
# --- 4) [v3rerank] Cross-encoder reranking ---
if _cross_encoder is not None:
contexts = rerank_contexts(query, contexts, top_k)
else:
contexts = contexts[:top_k]
return contexts
def build_rag_prompt(query: str, contexts: list[dict]) -> str:
context_text = "\n\n".join(f"[Source: {ctx['source']}]\n{ctx['text']}" for ctx in contexts)
return RAG_PROMPT_TEMPLATE.format(context=context_text, question=query)
# ---------------------------------------------------------------------------
# [v3routing] Question classification + model calls
# ---------------------------------------------------------------------------
_QCM_OPTION_RE = re.compile(r"(?:^|\s)[A-Da-d]\s*[\)\.\-]") # "A)" "B." "c -" ...
_QCM_KEYWORDS = ["parmi les", "laquelle", "lesquelles", "vrai ou faux",
"cochez", "réponse correcte", "reponse correcte",
"proposition", "qcm"]
def is_qcm(query: str) -> bool:
q = query.strip()
return len(_QCM_OPTION_RE.findall(q)) >= 2 or any(k in q.lower() for k in _QCM_KEYWORDS)
def _top_rerank_norm(contexts: list):
"""Normalised (sigmoid) score of the best reranked passage, or None."""
if not contexts:
return None
s = contexts[0].get("rerank_score")
if s is None:
return None
try:
return 1.0 / (1.0 + math.exp(-float(s)))
except OverflowError:
return 0.0 if s < 0 else 1.0
def classify_question(query: str) -> str:
"""
Return "short" (QCM / short closed question -> mini first)
or "long" (open question -> large model directly).
"""
q = query.strip()
low = q.lower()
# QCM markers => treat as short/closed
option_hits = len(_QCM_OPTION_RE.findall(q))
if option_hits >= 2 or any(k in low for k in _QCM_KEYWORDS):
return "short"
# otherwise decide by length
if len(q.split()) <= SHORT_Q_MAX_WORDS:
return "short"
return "long"
def _parse_llm_json(raw_content: str):
"""Return (answer, explanation, parsed_ok)."""
json_str = raw_content.strip()
if json_str.startswith("```"):
json_str = json_str.split("\n", 1)[-1]
json_str = json_str.rsplit("```", 1)[0].strip()
try:
parsed = json.loads(json_str)
return parsed["answer"], parsed["explanation"], True
except (json.JSONDecodeError, KeyError, TypeError):
return raw_content, "LLM did not return a structured explanation.", False
def _is_weak_answer(answer_text: str, parsed_ok: bool) -> bool:
if not parsed_ok:
return True
if not answer_text or not str(answer_text).strip():
return True
low = str(answer_text).lower()
return any(m in low for m in _WEAK_MARKERS)
def _call_model(prompt: str, which: str) -> dict:
"""which = 'small' or 'large'."""
if which == "small" and _ROUTING_ENABLED:
return call_llm_with_metrics(
prompt, endpoint_url=LLM_SMALL_ENDPOINT_URL, api_key=AZURE_API_KEY,
model=LLM_SMALL_MODEL_NAME, max_completion_tokens=LLM_SMALL_MAX_TOKENS,
temperature=LLM_SMALL_TEMPERATURE, top_p=LLM_SMALL_TOP_P,
)
return call_llm_with_metrics(
prompt, endpoint_url=LLM_ENDPOINT_URL, api_key=AZURE_API_KEY,
model=LLM_MODEL_NAME, max_completion_tokens=LLM_MAX_TOKENS,
temperature=LLM_TEMPERATURE, top_p=LLM_TOP_P,
)
def _empty_tokens() -> dict:
return {"prompt": 0, "completion": 0, "cached": 0, "total": 0}
def _add_tokens(a: dict, b: dict) -> dict:
return {k: (a.get(k, 0) or 0) + (b.get(k, 0) or 0) for k in ("prompt", "completion", "cached", "total")}
def rag_query(query: str, top_k: int = TOP_K_RESULTS) -> dict:
start_time = time.perf_counter()
contexts = retrieve_relevant_context(query, top_k=top_k)
if not contexts:
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
return {
"answer": "No documents have been ingested yet. Please upload documents first.",
"sources": [], "explanation": "No documents found in the vector store.",
"total_token": 0, "prompt_tokens": 0, "completion_tokens": 0, "cached_tokens": 0,
"co2_grams": None, "energy_kwh": None, "run_time_in_ms": elapsed_ms,
"model_used": "none", "question_type": "n/a",
}
prompt = build_rag_prompt(query, contexts)
# [v4guardrail] Abstention si contexte trop peu pertinent (memoire absent)
top_score = _top_rerank_norm(contexts)
if top_score is not None:
logger.info(f"top_rerank={top_score:.3f} | qcm={is_qcm(query)} | q={query[:60]!r}")
if top_score < RERANK_ABSTAIN_THRESHOLD:
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
return {
"answer": ABSTAIN_ANSWER,
"sources": [{"source": c["source"], "score": c.get("rerank_score", c["similarity_score"]),
"ref_text": c["text"]} for c in contexts],
"explanation": "Aucun passage suffisamment pertinent : question hors corpus.",
"total_token": 0, "prompt_tokens": 0, "completion_tokens": 0, "cached_tokens": 0,
"co2_grams": None, "energy_kwh": None, "run_time_in_ms": elapsed_ms,
"model_used": "abstained", "question_type": "abstain",
"top_rerank": round(top_score, 3),
}
# [v4qcm] QCM + contexte pertinent -> forcer le modele a trancher
if is_qcm(query):
prompt = prompt + QCM_DIRECTIVE
# [v3routing] decide path
qtype = classify_question(query) if _ROUTING_ENABLED else "long"
tokens = _empty_tokens()
co2 = 0.0
energy = 0.0
models_used = []
def _accumulate(res):
nonlocal tokens, co2, energy
tokens = _add_tokens(tokens, res.get("tokens", {}))
if isinstance(res.get("co2_grams"), (int, float)):
co2 += res["co2_grams"]
if isinstance(res.get("energy_kwh"), (int, float)):
energy += res["energy_kwh"]
if qtype == "short" and _ROUTING_ENABLED:
# mini first
small_res = _call_model(prompt, "small")
_accumulate(small_res)
models_used.append(LLM_SMALL_MODEL_NAME)
answer, explanation, parsed_ok = _parse_llm_json(small_res["content"])
if _is_weak_answer(answer, parsed_ok):
# escalate to large
large_res = _call_model(prompt, "large")
_accumulate(large_res)
models_used.append(LLM_MODEL_NAME)
answer, explanation, _ = _parse_llm_json(large_res["content"])
else:
# long/open question -> large directly (no double billing)
large_res = _call_model(prompt, "large")
_accumulate(large_res)
models_used.append(LLM_MODEL_NAME)
answer, explanation, _ = _parse_llm_json(large_res["content"])
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
return {
"answer": answer,
"sources": [{"source": c["source"], "score": c.get("rerank_score", c["similarity_score"]),
"ref_text": c["text"]} for c in contexts],
"explanation": explanation,
"total_token": tokens["total"],
"prompt_tokens": tokens["prompt"],
"completion_tokens": tokens["completion"],
"cached_tokens": tokens["cached"],
"co2_grams": co2 if co2 else None,
"energy_kwh": energy if energy else None,
"run_time_in_ms": elapsed_ms,
"model_used": " -> ".join(models_used), # e.g. "gpt-5-mini -> gpt-5.1" if escalated
"question_type": qtype,
"top_rerank": round(top_score, 3) if top_score is not None else None,
}
# ---------------------------------------------------------------------------
# Ingest Train Documents (on-demand)
# ---------------------------------------------------------------------------
def ingest_train_documents():
if collection.count() > 0:
logger.info("Vector store already has documents, skipping ingestion.")
return
if not TRAIN_DOCS_DIR.exists():
logger.warning(f"No train_data directory found at: {TRAIN_DOCS_DIR}")
return
for file_path in TRAIN_DOCS_DIR.rglob("*.txt"):
text = file_path.read_text(encoding="utf-8")
add_documents_to_vectorstore(chunk_text(text, source=file_path.name))
for file_path in TRAIN_DOCS_DIR.rglob("*.pdf"):
text = extract_text_from_pdf(file_path)
if text.strip():
add_documents_to_vectorstore(chunk_text(text, source=file_path.name))
else:
logger.warning(f"No extractable text found in: {file_path.name}")
logger.info(f"Train document ingestion complete. Total chunks: {collection.count()}")
# Build indexes at startup from existing ChromaDB chunks
build_bm25_index() # [v2bm25]
load_cross_encoder() # [v3rerank]
# ---------------------------------------------------------------------------
# FastAPI Application
# ---------------------------------------------------------------------------
app = FastAPI(
title="RAG Chat API - Gustave Eiffel Hackathon 2026",
description="A RAG system with /query endpoint for evaluation",
version="3.0.0",
)
class QueryRequest(BaseModel):
query: str
top_k: Optional[int] = TOP_K_RESULTS
class IngestRequest(BaseModel):
text: str
source: str = "user_upload"
@app.post("/query")
async def query_endpoint(request: QueryRequest):
result = rag_query(request.query, top_k=request.top_k)
return JSONResponse(content=result)
@app.post("/ingest")
async def ingest_endpoint(request: IngestRequest):
chunks = chunk_text(request.text, source=request.source)
count = add_documents_to_vectorstore(chunks)
return JSONResponse(content={"status": "success", "chunks_added": count, "total_chunks": collection.count()})
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"documents_in_store": collection.count(),
"embedding_model": EMBEDDING_MODEL_NAME,
"llm_model": LLM_MODEL_NAME,
"bm25_enabled": _bm25_index is not None,
"reranker_enabled": _cross_encoder is not None,
"routing_enabled": _ROUTING_ENABLED,
}
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
def gradio_query(question: str) -> tuple[str, str, str, str, str]:
if not question.strip():
return "Please enter a question.", "", "", "", ""
result = rag_query(question)
sources_text = "\n".join(f" - {s['source']} (relevance: {s['score']:.2f})" for s in result["sources"])
routing_info = f"\n\n🔀 Modèle: {result.get('model_used','?')} (type: {result.get('question_type','?')})"
answer = f"{result['answer']}\n\n📚 Sources:\n{sources_text}{routing_info}" if result["sources"] else result["answer"]
explanation = result.get("explanation", "")
token_info = str(result.get("total_token", 0))
co2_value = result.get("co2_grams")
co2_info = f"{co2_value:.4f} g" if isinstance(co2_value, (int, float)) else "N/A"
run_time = f"{result.get('run_time_in_ms', 0)} ms"
return answer, explanation, token_info, co2_info, run_time
def gradio_ingest(text: str, source_name: str) -> str:
if not text.strip():
return "Please provide text to ingest."
count = add_documents_to_vectorstore(chunk_text(text, source=source_name or "user_upload"))
return f"✅ Ingested {count} chunks. Total documents in store: {collection.count()}"
with gr.Blocks(title="RAG Chat API - Gustave Eiffel Hackathon") as demo:
gr.Markdown("""
# 🗼 RAG Chat API - Gustave Eiffel Hackathon 2026
Pipeline avancé : recherche hybride (vectoriel + BM25) → reranking cross-encoder → routing mini/gros.
**API Endpoint:** `POST /query` avec `{"query": "votre question"}`.
---
""")
with gr.Tab("💬 Chat"):
gr.Markdown("Posez une question sur les mémoires ingérés.")
with gr.Row():
query_input = gr.Textbox(label="Your Question", placeholder="e.g., Qu'est-ce que le risk adjustment ?", lines=2)
query_button = gr.Button("Ask", variant="primary")
query_output = gr.Textbox(label="Answer", lines=8, interactive=False)
query_explanation = gr.Textbox(label="Explanation", lines=3, interactive=False)
with gr.Row():
query_tokens = gr.Textbox(label="Total Tokens", interactive=False)
query_co2 = gr.Textbox(label="CO2 Emission", interactive=False)
query_runtime = gr.Textbox(label="Run Time", interactive=False)
query_button.click(fn=gradio_query, inputs=query_input,
outputs=[query_output, query_explanation, query_tokens, query_co2, query_runtime])
with gr.Tab("📄 Ingest Documents"):
gr.Markdown("Add new documents to the knowledge base.")
doc_text = gr.Textbox(label="Document Text", placeholder="Paste your document text here...", lines=10)
doc_source = gr.Textbox(label="Source Name", placeholder="e.g., my_document.txt", value="user_upload")
ingest_button = gr.Button("Ingest Document", variant="primary")
ingest_output = gr.Textbox(label="Status", interactive=False)
ingest_button.click(fn=gradio_ingest, inputs=[doc_text, doc_source], outputs=ingest_output)
with gr.Tab("ℹ️ API Info"):
gr.Markdown("""
## API Endpoints
### POST /query
```json
{"query": "What is the Eiffel Tower?", "top_k": 3}
```
### GET /health
Returns system health, document count, and which features are active
(bm25, reranker, routing).
""")
app = gr.mount_gradio_app(app, demo, path="/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)