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