EnriqueAlves commited on
Commit
ed5321e
·
1 Parent(s): 9939107

Deploy v5 (BM25+routing gpt-mini) + prompt FR anti-hallucination renforce sur main

Browse files
Files changed (4) hide show
  1. app.py +404 -363
  2. config.json +19 -13
  3. prompts/rag_prompt.txt +6 -8
  4. requirements.txt +2 -0
app.py CHANGED
@@ -1,31 +1,27 @@
1
  """
2
  RAG Chat API - Gustave Eiffel Hackathon 2026
3
  =============================================
4
-
5
- This application demonstrates a complete Retrieval-Augmented Generation (RAG) system
6
- deployed as a Hugging Face Space. It includes:
7
-
8
- 1. /query endpoint - Called by the RAG evaluation system
9
- 2. LLM API integration via Hugging Face Inference API
10
- 3. Document embedding using sentence-transformers
11
- 4. ChromaDB as the vector store (runs locally within HF Spaces)
12
- 5. End-to-end RAG pipeline: ingest → embed → retrieve → generate
13
-
14
- Architecture:
15
- User Query → Embedding → Vector Search → Context Retrieval → LLM Generation → Response
16
  """
17
-
18
  import os
 
19
  import json
20
  import logging
21
  import time
22
  from pathlib import Path
23
  from typing import Optional
24
-
25
- # Must be set before chromadb is imported so the module never registers its
26
- # posthog telemetry hook (workaround for posthog v3 API incompatibility).
27
  os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")
28
-
29
  import requests as http_requests
30
  import gradio as gr
31
  from fastapi import FastAPI, HTTPException
@@ -35,76 +31,109 @@ import chromadb
35
  from chromadb.config import Settings
36
  from langchain_text_splitters import RecursiveCharacterTextSplitter
37
  from pypdf import PdfReader
38
-
39
  from llm import call_llm as call_llm_with_metrics
40
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  logging.basicConfig(level=logging.INFO)
42
  logger = logging.getLogger(__name__)
43
-
44
- # Suppress non-fatal chromadb telemetry errors (posthog v3 API incompatibility)
 
 
 
 
45
  logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
46
-
47
  # ---------------------------------------------------------------------------
48
  # Configuration
49
  # ---------------------------------------------------------------------------
50
-
51
- # Resolve the data directory: /data when running inside HF Spaces (bucket mount),
52
- # ./data for local development.
53
  DATA_DIR = Path("/data") if Path("/data").is_dir() else Path("./data")
54
-
55
  CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
56
  TRAIN_DOCS_DIR = Path("./train_data")
57
  COLLECTION_NAME = "rag_documents"
58
  CHUNK_SIZE = 512
59
  CHUNK_OVERLAP = 50
60
- TOP_K_RESULTS = 3
61
-
62
- # Settings loaded from data/config.json (runtime config).
63
- # Falls back to the root config.json which serves as the example/template.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  _CONFIG_PATH = DATA_DIR / "config.json"
65
  if not _CONFIG_PATH.exists():
66
  _CONFIG_PATH = Path(__file__).parent / "config.json"
67
  logger.warning(
68
- f"No config.json found in {DATA_DIR} — falling back to root config.json (example file). "
69
- "Copy config.json to the data directory and fill in your values for production use."
70
  )
71
  with open(_CONFIG_PATH, encoding="utf-8") as _f:
72
  _config = json.load(_f)
73
-
74
  # Embedding model (Azure OpenAI)
75
  EMBEDDING_ENDPOINT_URL = _config["embedding"]["endpoint_url"]
76
  EMBEDDING_MODEL_NAME = _config["embedding"]["model"]
77
-
78
- # LLM (Azure OpenAI)
79
  LLM_ENDPOINT_URL = _config["llm"]["endpoint_url"]
80
  LLM_MODEL_NAME = _config["llm"]["model"]
81
  LLM_MAX_TOKENS = _config["llm"].get("max_completion_tokens", 512)
82
  LLM_TEMPERATURE = _config["llm"].get("temperature", 0.7)
83
  LLM_TOP_P = _config["llm"].get("top_p", 0.95)
84
-
85
- # Azure API key from environment variable (shared by both LLM and embedding endpoints)
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  AZURE_API_KEY = os.environ.get("AZURE_API_KEY")
87
  if not AZURE_API_KEY:
88
- logger.warning("AZURE_API_KEY is not set — LLM and embedding calls will fail.")
89
-
90
- # Prompt template loaded from file so it can be edited without touching application code
91
  _PROMPT_TEMPLATE_PATH = Path(__file__).parent / "prompts" / "rag_prompt.txt"
92
  RAG_PROMPT_TEMPLATE = _PROMPT_TEMPLATE_PATH.read_text(encoding="utf-8")
93
-
94
- # ---------------------------------------------------------------------------
95
- # Step 1: Embedding via Azure OpenAI
96
- # ---------------------------------------------------------------------------
97
- # Embeddings are generated by calling the Azure OpenAI /embeddings endpoint.
98
- # No local model is loaded — the API handles all inference.
99
-
100
  logger.info(f"Embedding model configured: {EMBEDDING_MODEL_NAME} via Azure OpenAI")
101
-
102
  # ---------------------------------------------------------------------------
103
- # Step 2: Initialize the Vector Store (ChromaDB)
104
  # ---------------------------------------------------------------------------
105
- # ChromaDB runs in-process with persistent storage. This is ideal for HF Spaces
106
- # because it requires no external database service.
107
-
108
  logger.info(f"Initializing ChromaDB at: {CHROMA_PERSIST_DIR}")
109
  chroma_client = chromadb.PersistentClient(
110
  path=CHROMA_PERSIST_DIR,
@@ -115,48 +144,95 @@ collection = chroma_client.get_or_create_collection(
115
  metadata={"hnsw:space": "cosine"},
116
  )
117
  logger.info(f"ChromaDB collection '{COLLECTION_NAME}' ready. Documents: {collection.count()}")
118
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  # ---------------------------------------------------------------------------
120
- # Step 3: LLM Configuration (Azure Foundry GPT-5)
121
  # ---------------------------------------------------------------------------
122
- # We call the Azure OpenAI-compatible endpoint directly via requests.
123
- # Endpoint URL and model are configured in config.json.
124
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  logger.info(f"LLM configured: {LLM_MODEL_NAME} via {LLM_ENDPOINT_URL}")
126
-
127
-
128
  # ---------------------------------------------------------------------------
129
  # Helper Functions
130
  # ---------------------------------------------------------------------------
131
-
132
  def extract_text_from_pdf(pdf_path: Path) -> str:
133
- """
134
- Extract text content from a PDF file using pypdf.
135
-
136
- This demonstrates how to convert PDF documents into plain text
137
- for downstream embedding. Each page is extracted sequentially
138
- and concatenated with page separators for traceability.
139
- """
140
  reader = PdfReader(str(pdf_path))
141
  pages_text = []
142
-
143
  for page_num, page in enumerate(reader.pages, start=1):
144
  text = page.extract_text()
145
  if text and text.strip():
146
  pages_text.append(f"[Page {page_num}]\n{text.strip()}")
147
-
148
  full_text = "\n\n".join(pages_text)
149
  logger.info(f"Extracted {len(reader.pages)} pages from PDF: {pdf_path.name} ({len(full_text)} chars)")
150
  return full_text
151
-
152
-
153
  def chunk_text(text: str, source: str = "unknown") -> list[dict]:
154
- """
155
- Split a document into smaller chunks for embedding.
156
-
157
- We use LangChain's RecursiveCharacterTextSplitter which intelligently
158
- splits on paragraph/sentence boundaries to preserve semantic meaning.
159
- """
160
  splitter = RecursiveCharacterTextSplitter(
161
  chunk_size=CHUNK_SIZE,
162
  chunk_overlap=CHUNK_OVERLAP,
@@ -164,365 +240,366 @@ def chunk_text(text: str, source: str = "unknown") -> list[dict]:
164
  )
165
  chunks = splitter.split_text(text)
166
  return [{"text": chunk, "source": source, "chunk_index": i} for i, chunk in enumerate(chunks)]
167
-
168
-
169
  def generate_embeddings(texts: list[str]) -> list[list[float]]:
170
- """
171
- Generate vector embeddings via the Azure OpenAI /embeddings endpoint.
172
-
173
- The endpoint, model name, and API key are loaded from config.json
174
- and the AZURE_API_KEY environment variable.
175
- """
176
- headers = {
177
- "api-key": AZURE_API_KEY,
178
- "Content-Type": "application/json",
179
- }
180
- payload = {
181
- "input": texts,
182
- "model": EMBEDDING_MODEL_NAME,
183
- }
184
  try:
185
- resp = http_requests.post(
186
- EMBEDDING_ENDPOINT_URL, headers=headers, json=payload, timeout=120,
187
- )
188
  resp.raise_for_status()
189
  data = resp.json()
190
  return [item["embedding"] for item in data["data"]]
191
  except http_requests.exceptions.HTTPError as e:
192
- logger.error(f"Embedding API call failed: {e} — {resp.text}")
193
  raise HTTPException(status_code=503, detail=f"Embedding service unavailable: {str(e)}")
194
- except (http_requests.exceptions.JSONDecodeError, ValueError) as e:
195
  logger.error(f"Embedding API returned non-JSON response (status {resp.status_code}): {repr(resp.text)}")
196
  raise HTTPException(status_code=502, detail="Embedding service returned an invalid response")
197
  except (KeyError, IndexError) as e:
198
- logger.error(f"Unexpected embedding response format: {e} — body: {resp.text}")
199
  raise HTTPException(status_code=502, detail="Unexpected response from embedding service")
200
-
201
-
202
  def add_documents_to_vectorstore(documents: list[dict]) -> int:
203
- """
204
- Save document embeddings to the ChromaDB vector store.
205
-
206
- This demonstrates Step 2c: How to persist embeddings for retrieval.
207
- Each document gets a unique ID, its embedding vector, the raw text,
208
- and metadata (source file, chunk index).
209
- """
210
  if not documents:
211
  return 0
212
-
213
  texts = [doc["text"] for doc in documents]
214
  embeddings = generate_embeddings(texts)
215
-
216
  existing_count = collection.count()
217
  ids = [f"doc_{existing_count + i}" for i in range(len(documents))]
218
  metadatas = [{"source": doc["source"], "chunk_index": doc["chunk_index"]} for doc in documents]
219
-
220
- collection.add(
221
- ids=ids,
222
- embeddings=embeddings,
223
- documents=texts,
224
- metadatas=metadatas,
225
- )
226
-
227
  logger.info(f"Added {len(documents)} chunks to vector store. Total: {collection.count()}")
 
228
  return len(documents)
229
-
230
-
231
  def retrieve_relevant_context(query: str, top_k: int = TOP_K_RESULTS) -> list[dict]:
232
  """
233
- Retrieve the most relevant document chunks for a given query.
234
-
235
- This is the "Retrieval" step in RAG:
236
- 1. Embed the user's query using the same embedding model
237
- 2. Search the vector store for the nearest neighbors
238
- 3. Return the top-k most similar chunks as context
239
  """
240
- if collection.count() == 0:
 
241
  return []
242
-
 
 
 
243
  query_embedding = generate_embeddings([query])[0]
244
-
245
- results = collection.query(
246
  query_embeddings=[query_embedding],
247
- n_results=min(top_k, collection.count()),
248
  include=["documents", "metadatas", "distances"],
249
  )
250
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  contexts = []
252
- for i in range(len(results["documents"][0])):
253
- contexts.append({
254
- "text": results["documents"][0][i],
255
- "source": results["metadatas"][0][i]["source"],
256
- "similarity_score": 1 - results["distances"][0][i],
257
- })
258
-
 
 
 
259
  return contexts
260
-
261
-
262
  def build_rag_prompt(query: str, contexts: list[dict]) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  """
264
- Construct the RAG prompt by combining retrieved context with the user question.
265
-
266
- The prompt template instructs the LLM to:
267
- - Answer based ONLY on the provided context
268
- - Acknowledge when information is insufficient
269
- - Cite sources when possible
270
  """
271
- context_text = "\n\n".join(
272
- f"[Source: {ctx['source']}]\n{ctx['text']}" for ctx in contexts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  )
274
-
275
- prompt = RAG_PROMPT_TEMPLATE.format(context=context_text, question=query)
276
- return prompt
277
-
278
-
 
 
 
 
 
279
  def rag_query(query: str, top_k: int = TOP_K_RESULTS) -> dict:
280
- """
281
- End-to-end RAG pipeline: Query → Retrieve → Generate.
282
-
283
- This demonstrates Step 2d: How to make a RAG system end-to-end.
284
-
285
- Pipeline steps:
286
- 1. Receive user query
287
- 2. Retrieve relevant context from vector store
288
- 3. Build augmented prompt with context
289
- 4. Call LLM to generate answer
290
- 5. Return answer with source metadata, explanation, token count, and timing
291
- """
292
  start_time = time.perf_counter()
293
-
294
- # Step 1: Retrieve relevant document chunks
295
  contexts = retrieve_relevant_context(query, top_k=top_k)
296
-
297
  if not contexts:
298
  elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
299
  return {
300
  "answer": "No documents have been ingested yet. Please upload documents first.",
301
- "sources": [],
302
- "explanation": "No documents found in the vector store to retrieve context from.",
303
- "total_token": 0,
304
- "prompt_tokens": 0,
305
- "completion_tokens": 0,
306
- "cached_tokens": 0,
307
- "co2_grams": None,
308
- "energy_kwh": None,
309
- "run_time_in_ms": elapsed_ms,
310
  }
311
-
312
- # Step 2: Build the augmented prompt
313
  prompt = build_rag_prompt(query, contexts)
314
-
315
- # Step 3: Generate answer from LLM (with token + CO2 metrics)
316
- llm_result = call_llm_with_metrics(
317
- prompt,
318
- endpoint_url=LLM_ENDPOINT_URL,
319
- api_key=AZURE_API_KEY,
320
- model=LLM_MODEL_NAME,
321
- max_completion_tokens=LLM_MAX_TOKENS,
322
- temperature=LLM_TEMPERATURE,
323
- top_p=LLM_TOP_P,
324
- )
325
- raw_content = llm_result["content"]
326
- tokens = llm_result["tokens"]
327
- total_token = tokens["total"]
328
-
329
- # Parse structured JSON response from LLM (handle markdown code fences)
330
- json_str = raw_content.strip()
331
- if json_str.startswith("```"):
332
- json_str = json_str.split("\n", 1)[-1]
333
- json_str = json_str.rsplit("```", 1)[0].strip()
334
-
335
- try:
336
- parsed = json.loads(json_str)
337
- answer = parsed["answer"]
338
- explanation = parsed["explanation"]
339
- except (json.JSONDecodeError, KeyError):
340
- answer = raw_content
341
- explanation = "LLM did not return a structured explanation."
342
-
 
 
 
 
 
 
 
343
  elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
344
-
345
- # Step 4: Return structured response
346
  return {
347
  "answer": answer,
348
- "sources": [{"source": ctx["source"], "score": ctx["similarity_score"], "ref_text": ctx["text"]} for ctx in contexts],
 
349
  "explanation": explanation,
350
- "total_token": total_token,
351
  "prompt_tokens": tokens["prompt"],
352
  "completion_tokens": tokens["completion"],
353
  "cached_tokens": tokens["cached"],
354
- "co2_grams": llm_result["co2_grams"],
355
- "energy_kwh": llm_result["energy_kwh"],
356
  "run_time_in_ms": elapsed_ms,
 
 
357
  }
358
-
359
-
360
  # ---------------------------------------------------------------------------
361
- # Step 4: Ingest Train Documents (on-demand)
362
  # ---------------------------------------------------------------------------
363
-
364
-
365
  def ingest_train_documents():
366
- """Load and embed training documents into the vector store."""
367
  if collection.count() > 0:
368
  logger.info("Vector store already has documents, skipping ingestion.")
369
  return
370
-
371
  if not TRAIN_DOCS_DIR.exists():
372
  logger.warning(f"No train_data directory found at: {TRAIN_DOCS_DIR}")
373
  return
374
-
375
- # Ingest plain text files (recursively in subdirectories)
376
  for file_path in TRAIN_DOCS_DIR.rglob("*.txt"):
377
- logger.info(f"Ingesting text file: {file_path.name}")
378
  text = file_path.read_text(encoding="utf-8")
379
- chunks = chunk_text(text, source=file_path.name)
380
- add_documents_to_vectorstore(chunks)
381
-
382
- # Ingest PDF files (recursively in subdirectories)
383
  for file_path in TRAIN_DOCS_DIR.rglob("*.pdf"):
384
- logger.info(f"Ingesting PDF file: {file_path.name}")
385
  text = extract_text_from_pdf(file_path)
386
  if text.strip():
387
- chunks = chunk_text(text, source=file_path.name)
388
- add_documents_to_vectorstore(chunks)
389
  else:
390
  logger.warning(f"No extractable text found in: {file_path.name}")
391
-
392
  logger.info(f"Train document ingestion complete. Total chunks: {collection.count()}")
393
-
394
-
 
 
 
 
 
395
  # ---------------------------------------------------------------------------
396
- # Step 5: FastAPI Application with /query Endpoint
397
  # ---------------------------------------------------------------------------
398
-
399
  app = FastAPI(
400
  title="RAG Chat API - Gustave Eiffel Hackathon 2026",
401
  description="A RAG system with /query endpoint for evaluation",
402
- version="1.0.0",
403
  )
404
-
405
-
406
  class QueryRequest(BaseModel):
407
- """Request schema for the /query endpoint."""
408
  query: str
409
  top_k: Optional[int] = TOP_K_RESULTS
410
-
411
-
412
  class IngestRequest(BaseModel):
413
- """Request schema for the /ingest endpoint."""
414
  text: str
415
  source: str = "user_upload"
416
-
417
-
418
  @app.post("/query")
419
  async def query_endpoint(request: QueryRequest):
420
- """
421
- RAG Query Endpoint - Called by the evaluation system.
422
-
423
- Accepts a user query, retrieves relevant context from the vector store,
424
- and generates an answer using the LLM.
425
-
426
- Request body:
427
- - query (str): The user's question
428
- - top_k (int, optional): Number of context chunks to retrieve (default: 3)
429
-
430
- Returns:
431
- - answer (str): The generated answer
432
- - sources (list): Source documents used for context
433
- - explanation (str): Explanation of the retrieval and answer logic
434
- - total_token (int): Total token count from the LLM call
435
- - prompt_tokens (int): Prompt token count
436
- - completion_tokens (int): Completion token count
437
- - cached_tokens (int): Cached prompt tokens (when reported by provider)
438
- - co2_grams (float | None): Estimated CO2 emission in grams (via ecologits)
439
- - energy_kwh (float | None): Estimated energy use in kWh (via ecologits)
440
- - run_time_in_ms (float): Pipeline execution time in milliseconds
441
- """
442
  result = rag_query(request.query, top_k=request.top_k)
443
  return JSONResponse(content=result)
444
-
445
-
446
  @app.post("/ingest")
447
  async def ingest_endpoint(request: IngestRequest):
448
- """
449
- Document Ingestion Endpoint.
450
-
451
- Accepts raw text, chunks it, generates embeddings, and stores in the vector store.
452
-
453
- Request body:
454
- - text (str): The document text to ingest
455
- - source (str, optional): Source identifier for the document
456
- """
457
  chunks = chunk_text(request.text, source=request.source)
458
  count = add_documents_to_vectorstore(chunks)
459
- return JSONResponse(content={
460
- "status": "success",
461
- "chunks_added": count,
462
- "total_chunks": collection.count(),
463
- })
464
-
465
-
466
  @app.get("/health")
467
  async def health_check():
468
- """Health check endpoint for monitoring."""
469
  return {
470
  "status": "healthy",
471
  "documents_in_store": collection.count(),
472
  "embedding_model": EMBEDDING_MODEL_NAME,
473
  "llm_model": LLM_MODEL_NAME,
 
 
 
474
  }
475
-
476
-
477
  # ---------------------------------------------------------------------------
478
- # Step 6: Gradio UI for Interactive Demo
479
  # ---------------------------------------------------------------------------
480
-
481
  def gradio_query(question: str) -> tuple[str, str, str, str, str]:
482
- """Handle queries from the Gradio chat interface."""
483
  if not question.strip():
484
  return "Please enter a question.", "", "", "", ""
485
  result = rag_query(question)
486
- sources_text = "\n".join(
487
- f" - {s['source']} (relevance: {s['score']:.2f})" for s in result["sources"]
488
- )
489
- answer = f"{result['answer']}\n\n📚 Sources:\n{sources_text}" if result["sources"] else result["answer"]
490
  explanation = result.get("explanation", "")
491
  token_info = str(result.get("total_token", 0))
492
  co2_value = result.get("co2_grams")
493
  co2_info = f"{co2_value:.4f} g" if isinstance(co2_value, (int, float)) else "N/A"
494
  run_time = f"{result.get('run_time_in_ms', 0)} ms"
495
  return answer, explanation, token_info, co2_info, run_time
496
-
497
-
498
  def gradio_ingest(text: str, source_name: str) -> str:
499
- """Handle document ingestion from the Gradio UI."""
500
  if not text.strip():
501
  return "Please provide text to ingest."
502
- chunks = chunk_text(text, source=source_name or "user_upload")
503
- count = add_documents_to_vectorstore(chunks)
504
  return f"✅ Ingested {count} chunks. Total documents in store: {collection.count()}"
505
-
506
-
507
  with gr.Blocks(title="RAG Chat API - Gustave Eiffel Hackathon") as demo:
508
  gr.Markdown("""
509
  # 🗼 RAG Chat API - Gustave Eiffel Hackathon 2026
510
-
511
- This application demonstrates a complete **Retrieval-Augmented Generation (RAG)** system.
512
-
513
- **API Endpoint:** Use `POST /query` with `{"query": "your question"}` for programmatic access.
514
-
515
  ---
516
  """)
517
-
518
  with gr.Tab("💬 Chat"):
519
- gr.Markdown("Ask questions about the ingested documents.")
520
  with gr.Row():
521
- query_input = gr.Textbox(
522
- label="Your Question",
523
- placeholder="e.g., What is the Eiffel Tower made of?",
524
- lines=2,
525
- )
526
  query_button = gr.Button("Ask", variant="primary")
527
  query_output = gr.Textbox(label="Answer", lines=8, interactive=False)
528
  query_explanation = gr.Textbox(label="Explanation", lines=3, interactive=False)
@@ -530,68 +607,32 @@ with gr.Blocks(title="RAG Chat API - Gustave Eiffel Hackathon") as demo:
530
  query_tokens = gr.Textbox(label="Total Tokens", interactive=False)
531
  query_co2 = gr.Textbox(label="CO2 Emission", interactive=False)
532
  query_runtime = gr.Textbox(label="Run Time", interactive=False)
533
- query_button.click(
534
- fn=gradio_query,
535
- inputs=query_input,
536
- outputs=[query_output, query_explanation, query_tokens, query_co2, query_runtime],
537
- )
538
-
539
  with gr.Tab("📄 Ingest Documents"):
540
  gr.Markdown("Add new documents to the knowledge base.")
541
- doc_text = gr.Textbox(
542
- label="Document Text",
543
- placeholder="Paste your document text here...",
544
- lines=10,
545
- )
546
- doc_source = gr.Textbox(
547
- label="Source Name",
548
- placeholder="e.g., my_document.txt",
549
- value="user_upload",
550
- )
551
  ingest_button = gr.Button("Ingest Document", variant="primary")
552
  ingest_output = gr.Textbox(label="Status", interactive=False)
553
  ingest_button.click(fn=gradio_ingest, inputs=[doc_text, doc_source], outputs=ingest_output)
554
-
555
  with gr.Tab("ℹ️ API Info"):
556
  gr.Markdown("""
557
  ## API Endpoints
558
-
559
  ### POST /query
560
  ```json
561
- {
562
- "query": "What is the Eiffel Tower?",
563
- "top_k": 3
564
- }
565
- ```
566
-
567
- **Response:**
568
- ```json
569
- {
570
- "answer": "The Eiffel Tower is...",
571
- "sources": [{"source": "eiffel_tower.txt", "score": 0.85}],
572
- "query": "What is the Eiffel Tower?"
573
- }
574
  ```
575
-
576
- ### POST /ingest
577
- ```json
578
- {
579
- "text": "Your document text here...",
580
- "source": "document_name.txt"
581
- }
582
- ```
583
-
584
  ### GET /health
585
- Returns system health and document count.
 
586
  """)
587
-
588
- # Mount Gradio onto FastAPI so both the UI and API endpoints are served
589
  app = gr.mount_gradio_app(app, demo, path="/")
590
-
591
- # ---------------------------------------------------------------------------
592
- # Entry Point
593
- # ---------------------------------------------------------------------------
594
-
595
  if __name__ == "__main__":
596
  import uvicorn
597
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
1
  """
2
  RAG Chat API - Gustave Eiffel Hackathon 2026
3
  =============================================
4
+ Pipeline RAG avance :
5
+ 1. Recherche HYBRIDE : vectoriel + BM25 fusionnes par RRF [v2bm25]
6
+ 2. Reranking par CROSS-ENCODER local (multilingue, gratuit, CPU) [v3rerank]
7
+ 3. ROUTING petit/gros modele (GPT-5-mini <-> GPT-5.1) [v3routing]
8
+ - Question courte / QCM ferme -> mini d'abord, escalade au gros si reponse faible
9
+ - Question longue / ouverte -> gros modele directement (evite la double facturation)
10
+
11
+ L'index BM25 et le cross-encoder se construisent au demarrage a partir des
12
+ chunks deja presents dans ChromaDB (pas de re-embedding necessaire).
 
 
 
13
  """
14
+
15
  import os
16
+ import re
17
  import json
18
  import logging
19
  import time
20
  from pathlib import Path
21
  from typing import Optional
22
+
 
 
23
  os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")
24
+
25
  import requests as http_requests
26
  import gradio as gr
27
  from fastapi import FastAPI, HTTPException
 
31
  from chromadb.config import Settings
32
  from langchain_text_splitters import RecursiveCharacterTextSplitter
33
  from pypdf import PdfReader
34
+
35
  from llm import call_llm as call_llm_with_metrics
36
+
37
+ # [v2bm25] BM25 keyword search (graceful fallback if not installed)
38
+ try:
39
+ from rank_bm25 import BM25Okapi
40
+ _BM25_AVAILABLE = True
41
+ except ImportError:
42
+ _BM25_AVAILABLE = False
43
+
44
+ # [v3rerank] Cross-encoder reranker (graceful fallback if not installed)
45
+ try:
46
+ from sentence_transformers import CrossEncoder
47
+ _CE_AVAILABLE = True
48
+ except ImportError:
49
+ _CE_AVAILABLE = False
50
+
51
  logging.basicConfig(level=logging.INFO)
52
  logger = logging.getLogger(__name__)
53
+
54
+ if not _BM25_AVAILABLE:
55
+ logger.warning("rank-bm25 not installed -- falling back to vector-only retrieval.")
56
+ if not _CE_AVAILABLE:
57
+ logger.warning("sentence-transformers not installed -- reranking disabled.")
58
+
59
  logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
60
+
61
  # ---------------------------------------------------------------------------
62
  # Configuration
63
  # ---------------------------------------------------------------------------
 
 
 
64
  DATA_DIR = Path("/data") if Path("/data").is_dir() else Path("./data")
65
+
66
  CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
67
  TRAIN_DOCS_DIR = Path("./train_data")
68
  COLLECTION_NAME = "rag_documents"
69
  CHUNK_SIZE = 512
70
  CHUNK_OVERLAP = 50
71
+ TOP_K_RESULTS = 2
72
+
73
+ # [v2bm25] hybrid retrieval candidates before fusion
74
+ RRF_CANDIDATES = 20
75
+ RRF_K = 60
76
+
77
+ # [v3rerank] number of candidates fed to the cross-encoder before final cut
78
+ RERANK_INPUT_K = 12
79
+ CROSS_ENCODER_MODEL = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
80
+
81
+ # [v3routing] question classification threshold (words)
82
+ SHORT_Q_MAX_WORDS = 25
83
+ # explicit "weak answer" markers that trigger escalation to the large model
84
+ _WEAK_MARKERS = [
85
+ "ne permet pas de répondre", "ne permet pas de repondre",
86
+ "don't have enough", "do not have enough", "i don't have enough",
87
+ "cannot answer", "i cannot answer",
88
+ "je ne sais pas",
89
+ ]
90
+
91
  _CONFIG_PATH = DATA_DIR / "config.json"
92
  if not _CONFIG_PATH.exists():
93
  _CONFIG_PATH = Path(__file__).parent / "config.json"
94
  logger.warning(
95
+ f"No config.json found in {DATA_DIR} -- falling back to root config.json (example file)."
 
96
  )
97
  with open(_CONFIG_PATH, encoding="utf-8") as _f:
98
  _config = json.load(_f)
99
+
100
  # Embedding model (Azure OpenAI)
101
  EMBEDDING_ENDPOINT_URL = _config["embedding"]["endpoint_url"]
102
  EMBEDDING_MODEL_NAME = _config["embedding"]["model"]
103
+
104
+ # LLM (large / default) -- Azure OpenAI
105
  LLM_ENDPOINT_URL = _config["llm"]["endpoint_url"]
106
  LLM_MODEL_NAME = _config["llm"]["model"]
107
  LLM_MAX_TOKENS = _config["llm"].get("max_completion_tokens", 512)
108
  LLM_TEMPERATURE = _config["llm"].get("temperature", 0.7)
109
  LLM_TOP_P = _config["llm"].get("top_p", 0.95)
110
+
111
+ # [v3routing] LLM small (mini) -- optional. If absent, routing is disabled.
112
+ _llm_small = _config.get("llm_small")
113
+ if _llm_small:
114
+ LLM_SMALL_ENDPOINT_URL = _llm_small["endpoint_url"]
115
+ LLM_SMALL_MODEL_NAME = _llm_small["model"]
116
+ LLM_SMALL_MAX_TOKENS = _llm_small.get("max_completion_tokens", 512)
117
+ LLM_SMALL_TEMPERATURE = _llm_small.get("temperature", 0.7)
118
+ LLM_SMALL_TOP_P = _llm_small.get("top_p", 0.95)
119
+ _ROUTING_ENABLED = True
120
+ logger.info(f"Routing enabled: small={LLM_SMALL_MODEL_NAME}, large={LLM_MODEL_NAME}")
121
+ else:
122
+ _ROUTING_ENABLED = False
123
+ logger.info("No llm_small in config -- routing disabled, large model only.")
124
+
125
  AZURE_API_KEY = os.environ.get("AZURE_API_KEY")
126
  if not AZURE_API_KEY:
127
+ logger.warning("AZURE_API_KEY is not set -- LLM and embedding calls will fail.")
128
+
 
129
  _PROMPT_TEMPLATE_PATH = Path(__file__).parent / "prompts" / "rag_prompt.txt"
130
  RAG_PROMPT_TEMPLATE = _PROMPT_TEMPLATE_PATH.read_text(encoding="utf-8")
131
+
 
 
 
 
 
 
132
  logger.info(f"Embedding model configured: {EMBEDDING_MODEL_NAME} via Azure OpenAI")
133
+
134
  # ---------------------------------------------------------------------------
135
+ # Vector Store (ChromaDB)
136
  # ---------------------------------------------------------------------------
 
 
 
137
  logger.info(f"Initializing ChromaDB at: {CHROMA_PERSIST_DIR}")
138
  chroma_client = chromadb.PersistentClient(
139
  path=CHROMA_PERSIST_DIR,
 
144
  metadata={"hnsw:space": "cosine"},
145
  )
146
  logger.info(f"ChromaDB collection '{COLLECTION_NAME}' ready. Documents: {collection.count()}")
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # [v2bm25] BM25 keyword index
150
+ # ---------------------------------------------------------------------------
151
+ _bm25_index = None
152
+ _bm25_ids: list[str] = []
153
+ _bm25_docs: list[str] = []
154
+ _bm25_metas: list[dict] = []
155
+
156
+
157
+ def _tokenize(text: str) -> list[str]:
158
+ return re.findall(r"\w+", text.lower())
159
+
160
+
161
+ def build_bm25_index() -> None:
162
+ global _bm25_index, _bm25_ids, _bm25_docs, _bm25_metas
163
+ if not _BM25_AVAILABLE:
164
+ return
165
+ try:
166
+ data = collection.get(include=["documents", "metadatas"])
167
+ _bm25_ids = data.get("ids", []) or []
168
+ _bm25_docs = data.get("documents", []) or []
169
+ _bm25_metas = data.get("metadatas", []) or []
170
+ if not _bm25_docs:
171
+ _bm25_index = None
172
+ logger.info("BM25 index empty (no documents in store yet).")
173
+ return
174
+ tokenized_corpus = [_tokenize(d) for d in _bm25_docs]
175
+ _bm25_index = BM25Okapi(tokenized_corpus)
176
+ logger.info(f"BM25 index built over {len(_bm25_docs)} chunks.")
177
+ except Exception as e:
178
+ _bm25_index = None
179
+ logger.warning(f"Failed to build BM25 index: {e}")
180
+
181
+
182
  # ---------------------------------------------------------------------------
183
+ # [v3rerank] Cross-encoder reranker
184
  # ---------------------------------------------------------------------------
185
+ _cross_encoder = None
186
+
187
+
188
+ def load_cross_encoder() -> None:
189
+ global _cross_encoder
190
+ if not _CE_AVAILABLE:
191
+ return
192
+ try:
193
+ logger.info(f"Loading cross-encoder '{CROSS_ENCODER_MODEL}' (first run downloads the model)...")
194
+ _cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL, max_length=512)
195
+ logger.info("Cross-encoder ready.")
196
+ except Exception as e:
197
+ _cross_encoder = None
198
+ logger.warning(f"Failed to load cross-encoder: {e}")
199
+
200
+
201
+ def rerank_contexts(query: str, contexts: list[dict], top_k: int) -> list[dict]:
202
+ """Rerank candidate chunks with the cross-encoder, keep the top_k best."""
203
+ if _cross_encoder is None or len(contexts) <= 1:
204
+ return contexts[:top_k]
205
+ try:
206
+ pairs = [[query, c["text"]] for c in contexts]
207
+ scores = _cross_encoder.predict(pairs)
208
+ for c, s in zip(contexts, scores):
209
+ c["rerank_score"] = float(s)
210
+ contexts.sort(key=lambda c: c.get("rerank_score", 0.0), reverse=True)
211
+ except Exception as e:
212
+ logger.warning(f"Reranking failed, falling back to fusion order: {e}")
213
+ return contexts[:top_k]
214
+
215
+
216
  logger.info(f"LLM configured: {LLM_MODEL_NAME} via {LLM_ENDPOINT_URL}")
217
+
218
+
219
  # ---------------------------------------------------------------------------
220
  # Helper Functions
221
  # ---------------------------------------------------------------------------
222
+
223
  def extract_text_from_pdf(pdf_path: Path) -> str:
 
 
 
 
 
 
 
224
  reader = PdfReader(str(pdf_path))
225
  pages_text = []
 
226
  for page_num, page in enumerate(reader.pages, start=1):
227
  text = page.extract_text()
228
  if text and text.strip():
229
  pages_text.append(f"[Page {page_num}]\n{text.strip()}")
 
230
  full_text = "\n\n".join(pages_text)
231
  logger.info(f"Extracted {len(reader.pages)} pages from PDF: {pdf_path.name} ({len(full_text)} chars)")
232
  return full_text
233
+
234
+
235
  def chunk_text(text: str, source: str = "unknown") -> list[dict]:
 
 
 
 
 
 
236
  splitter = RecursiveCharacterTextSplitter(
237
  chunk_size=CHUNK_SIZE,
238
  chunk_overlap=CHUNK_OVERLAP,
 
240
  )
241
  chunks = splitter.split_text(text)
242
  return [{"text": chunk, "source": source, "chunk_index": i} for i, chunk in enumerate(chunks)]
243
+
244
+
245
  def generate_embeddings(texts: list[str]) -> list[list[float]]:
246
+ headers = {"api-key": AZURE_API_KEY, "Content-Type": "application/json"}
247
+ payload = {"input": texts, "model": EMBEDDING_MODEL_NAME}
 
 
 
 
 
 
 
 
 
 
 
 
248
  try:
249
+ resp = http_requests.post(EMBEDDING_ENDPOINT_URL, headers=headers, json=payload, timeout=120)
 
 
250
  resp.raise_for_status()
251
  data = resp.json()
252
  return [item["embedding"] for item in data["data"]]
253
  except http_requests.exceptions.HTTPError as e:
254
+ logger.error(f"Embedding API call failed: {e} -- {resp.text}")
255
  raise HTTPException(status_code=503, detail=f"Embedding service unavailable: {str(e)}")
256
+ except (http_requests.exceptions.JSONDecodeError, ValueError):
257
  logger.error(f"Embedding API returned non-JSON response (status {resp.status_code}): {repr(resp.text)}")
258
  raise HTTPException(status_code=502, detail="Embedding service returned an invalid response")
259
  except (KeyError, IndexError) as e:
260
+ logger.error(f"Unexpected embedding response format: {e} -- body: {resp.text}")
261
  raise HTTPException(status_code=502, detail="Unexpected response from embedding service")
262
+
263
+
264
  def add_documents_to_vectorstore(documents: list[dict]) -> int:
 
 
 
 
 
 
 
265
  if not documents:
266
  return 0
 
267
  texts = [doc["text"] for doc in documents]
268
  embeddings = generate_embeddings(texts)
 
269
  existing_count = collection.count()
270
  ids = [f"doc_{existing_count + i}" for i in range(len(documents))]
271
  metadatas = [{"source": doc["source"], "chunk_index": doc["chunk_index"]} for doc in documents]
272
+ collection.add(ids=ids, embeddings=embeddings, documents=texts, metadatas=metadatas)
 
 
 
 
 
 
 
273
  logger.info(f"Added {len(documents)} chunks to vector store. Total: {collection.count()}")
274
+ build_bm25_index() # [v2bm25] keep keyword index in sync
275
  return len(documents)
276
+
277
+
278
  def retrieve_relevant_context(query: str, top_k: int = TOP_K_RESULTS) -> list[dict]:
279
  """
280
+ Hybrid retrieval (vector + BM25, RRF) -> optional cross-encoder rerank -> top_k.
 
 
 
 
 
281
  """
282
+ n = collection.count()
283
+ if n == 0:
284
  return []
285
+
286
+ candidate_k = min(max(top_k * 5, RRF_CANDIDATES), n)
287
+
288
+ # --- 1) Vector search (semantic) ---
289
  query_embedding = generate_embeddings([query])[0]
290
+ vres = collection.query(
 
291
  query_embeddings=[query_embedding],
292
+ n_results=candidate_k,
293
  include=["documents", "metadatas", "distances"],
294
  )
295
+ v_ids = vres["ids"][0]
296
+ v_docs = vres["documents"][0]
297
+ v_metas = vres["metadatas"][0]
298
+
299
+ doc_lookup: dict[str, tuple[str, str]] = {}
300
+ vector_rank: dict[str, int] = {}
301
+ for rank, (did, dtext, dmeta) in enumerate(zip(v_ids, v_docs, v_metas)):
302
+ vector_rank[did] = rank
303
+ doc_lookup[did] = (dtext, (dmeta or {}).get("source", "unknown"))
304
+
305
+ # --- 2) BM25 search (keywords) ---
306
+ bm25_rank: dict[str, int] = {}
307
+ if _bm25_index is not None:
308
+ scores = _bm25_index.get_scores(_tokenize(query))
309
+ top_positions = sorted(range(len(scores)), key=lambda p: scores[p], reverse=True)[:candidate_k]
310
+ for rank, pos in enumerate(top_positions):
311
+ did = _bm25_ids[pos]
312
+ bm25_rank[did] = rank
313
+ if did not in doc_lookup:
314
+ doc_lookup[did] = (_bm25_docs[pos], (_bm25_metas[pos] or {}).get("source", "unknown"))
315
+
316
+ # --- 3) Reciprocal Rank Fusion ---
317
+ fused: dict[str, float] = {}
318
+ for did, rank in vector_rank.items():
319
+ fused[did] = fused.get(did, 0.0) + 1.0 / (RRF_K + rank + 1)
320
+ for did, rank in bm25_rank.items():
321
+ fused[did] = fused.get(did, 0.0) + 1.0 / (RRF_K + rank + 1)
322
+
323
+ # keep more candidates if a reranker will refine them
324
+ pre_k = RERANK_INPUT_K if _cross_encoder is not None else top_k
325
+ ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:pre_k]
326
+
327
  contexts = []
328
+ for did, fused_score in ranked:
329
+ text, source = doc_lookup[did]
330
+ contexts.append({"text": text, "source": source, "similarity_score": round(fused_score, 4)})
331
+
332
+ # --- 4) [v3rerank] Cross-encoder reranking ---
333
+ if _cross_encoder is not None:
334
+ contexts = rerank_contexts(query, contexts, top_k)
335
+ else:
336
+ contexts = contexts[:top_k]
337
+
338
  return contexts
339
+
340
+
341
  def build_rag_prompt(query: str, contexts: list[dict]) -> str:
342
+ context_text = "\n\n".join(f"[Source: {ctx['source']}]\n{ctx['text']}" for ctx in contexts)
343
+ return RAG_PROMPT_TEMPLATE.format(context=context_text, question=query)
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # [v3routing] Question classification + model calls
348
+ # ---------------------------------------------------------------------------
349
+
350
+ _QCM_OPTION_RE = re.compile(r"(?:^|\s)[A-Da-d]\s*[\)\.\-]") # "A)" "B." "c -" ...
351
+ _QCM_KEYWORDS = ["parmi les", "laquelle", "lesquelles", "vrai ou faux",
352
+ "cochez", "réponse correcte", "reponse correcte",
353
+ "proposition", "qcm"]
354
+
355
+
356
+ def classify_question(query: str) -> str:
357
  """
358
+ Return "short" (QCM / short closed question -> mini first)
359
+ or "long" (open question -> large model directly).
 
 
 
 
360
  """
361
+ q = query.strip()
362
+ low = q.lower()
363
+
364
+ # QCM markers => treat as short/closed
365
+ option_hits = len(_QCM_OPTION_RE.findall(q))
366
+ if option_hits >= 2 or any(k in low for k in _QCM_KEYWORDS):
367
+ return "short"
368
+
369
+ # otherwise decide by length
370
+ if len(q.split()) <= SHORT_Q_MAX_WORDS:
371
+ return "short"
372
+ return "long"
373
+
374
+
375
+ def _parse_llm_json(raw_content: str):
376
+ """Return (answer, explanation, parsed_ok)."""
377
+ json_str = raw_content.strip()
378
+ if json_str.startswith("```"):
379
+ json_str = json_str.split("\n", 1)[-1]
380
+ json_str = json_str.rsplit("```", 1)[0].strip()
381
+ try:
382
+ parsed = json.loads(json_str)
383
+ return parsed["answer"], parsed["explanation"], True
384
+ except (json.JSONDecodeError, KeyError, TypeError):
385
+ return raw_content, "LLM did not return a structured explanation.", False
386
+
387
+
388
+ def _is_weak_answer(answer_text: str, parsed_ok: bool) -> bool:
389
+ if not parsed_ok:
390
+ return True
391
+ if not answer_text or not str(answer_text).strip():
392
+ return True
393
+ low = str(answer_text).lower()
394
+ return any(m in low for m in _WEAK_MARKERS)
395
+
396
+
397
+ def _call_model(prompt: str, which: str) -> dict:
398
+ """which = 'small' or 'large'."""
399
+ if which == "small" and _ROUTING_ENABLED:
400
+ return call_llm_with_metrics(
401
+ prompt, endpoint_url=LLM_SMALL_ENDPOINT_URL, api_key=AZURE_API_KEY,
402
+ model=LLM_SMALL_MODEL_NAME, max_completion_tokens=LLM_SMALL_MAX_TOKENS,
403
+ temperature=LLM_SMALL_TEMPERATURE, top_p=LLM_SMALL_TOP_P,
404
+ )
405
+ return call_llm_with_metrics(
406
+ prompt, endpoint_url=LLM_ENDPOINT_URL, api_key=AZURE_API_KEY,
407
+ model=LLM_MODEL_NAME, max_completion_tokens=LLM_MAX_TOKENS,
408
+ temperature=LLM_TEMPERATURE, top_p=LLM_TOP_P,
409
  )
410
+
411
+
412
+ def _empty_tokens() -> dict:
413
+ return {"prompt": 0, "completion": 0, "cached": 0, "total": 0}
414
+
415
+
416
+ def _add_tokens(a: dict, b: dict) -> dict:
417
+ return {k: (a.get(k, 0) or 0) + (b.get(k, 0) or 0) for k in ("prompt", "completion", "cached", "total")}
418
+
419
+
420
  def rag_query(query: str, top_k: int = TOP_K_RESULTS) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
421
  start_time = time.perf_counter()
422
+
 
423
  contexts = retrieve_relevant_context(query, top_k=top_k)
424
+
425
  if not contexts:
426
  elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
427
  return {
428
  "answer": "No documents have been ingested yet. Please upload documents first.",
429
+ "sources": [], "explanation": "No documents found in the vector store.",
430
+ "total_token": 0, "prompt_tokens": 0, "completion_tokens": 0, "cached_tokens": 0,
431
+ "co2_grams": None, "energy_kwh": None, "run_time_in_ms": elapsed_ms,
432
+ "model_used": "none", "question_type": "n/a",
 
 
 
 
 
433
  }
434
+
 
435
  prompt = build_rag_prompt(query, contexts)
436
+
437
+ # [v3routing] decide path
438
+ qtype = classify_question(query) if _ROUTING_ENABLED else "long"
439
+ tokens = _empty_tokens()
440
+ co2 = 0.0
441
+ energy = 0.0
442
+ models_used = []
443
+
444
+ def _accumulate(res):
445
+ nonlocal tokens, co2, energy
446
+ tokens = _add_tokens(tokens, res.get("tokens", {}))
447
+ if isinstance(res.get("co2_grams"), (int, float)):
448
+ co2 += res["co2_grams"]
449
+ if isinstance(res.get("energy_kwh"), (int, float)):
450
+ energy += res["energy_kwh"]
451
+
452
+ if qtype == "short" and _ROUTING_ENABLED:
453
+ # mini first
454
+ small_res = _call_model(prompt, "small")
455
+ _accumulate(small_res)
456
+ models_used.append(LLM_SMALL_MODEL_NAME)
457
+ answer, explanation, parsed_ok = _parse_llm_json(small_res["content"])
458
+
459
+ if _is_weak_answer(answer, parsed_ok):
460
+ # escalate to large
461
+ large_res = _call_model(prompt, "large")
462
+ _accumulate(large_res)
463
+ models_used.append(LLM_MODEL_NAME)
464
+ answer, explanation, _ = _parse_llm_json(large_res["content"])
465
+ else:
466
+ # long/open question -> large directly (no double billing)
467
+ large_res = _call_model(prompt, "large")
468
+ _accumulate(large_res)
469
+ models_used.append(LLM_MODEL_NAME)
470
+ answer, explanation, _ = _parse_llm_json(large_res["content"])
471
+
472
  elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
473
+
 
474
  return {
475
  "answer": answer,
476
+ "sources": [{"source": c["source"], "score": c.get("rerank_score", c["similarity_score"]),
477
+ "ref_text": c["text"]} for c in contexts],
478
  "explanation": explanation,
479
+ "total_token": tokens["total"],
480
  "prompt_tokens": tokens["prompt"],
481
  "completion_tokens": tokens["completion"],
482
  "cached_tokens": tokens["cached"],
483
+ "co2_grams": co2 if co2 else None,
484
+ "energy_kwh": energy if energy else None,
485
  "run_time_in_ms": elapsed_ms,
486
+ "model_used": " -> ".join(models_used), # e.g. "gpt-5-mini -> gpt-5.1" if escalated
487
+ "question_type": qtype,
488
  }
489
+
490
+
491
  # ---------------------------------------------------------------------------
492
+ # Ingest Train Documents (on-demand)
493
  # ---------------------------------------------------------------------------
494
+
 
495
  def ingest_train_documents():
 
496
  if collection.count() > 0:
497
  logger.info("Vector store already has documents, skipping ingestion.")
498
  return
 
499
  if not TRAIN_DOCS_DIR.exists():
500
  logger.warning(f"No train_data directory found at: {TRAIN_DOCS_DIR}")
501
  return
 
 
502
  for file_path in TRAIN_DOCS_DIR.rglob("*.txt"):
 
503
  text = file_path.read_text(encoding="utf-8")
504
+ add_documents_to_vectorstore(chunk_text(text, source=file_path.name))
 
 
 
505
  for file_path in TRAIN_DOCS_DIR.rglob("*.pdf"):
 
506
  text = extract_text_from_pdf(file_path)
507
  if text.strip():
508
+ add_documents_to_vectorstore(chunk_text(text, source=file_path.name))
 
509
  else:
510
  logger.warning(f"No extractable text found in: {file_path.name}")
 
511
  logger.info(f"Train document ingestion complete. Total chunks: {collection.count()}")
512
+
513
+
514
+ # Build indexes at startup from existing ChromaDB chunks
515
+ build_bm25_index() # [v2bm25]
516
+ load_cross_encoder() # [v3rerank]
517
+
518
+
519
  # ---------------------------------------------------------------------------
520
+ # FastAPI Application
521
  # ---------------------------------------------------------------------------
 
522
  app = FastAPI(
523
  title="RAG Chat API - Gustave Eiffel Hackathon 2026",
524
  description="A RAG system with /query endpoint for evaluation",
525
+ version="3.0.0",
526
  )
527
+
528
+
529
  class QueryRequest(BaseModel):
 
530
  query: str
531
  top_k: Optional[int] = TOP_K_RESULTS
532
+
533
+
534
  class IngestRequest(BaseModel):
 
535
  text: str
536
  source: str = "user_upload"
537
+
538
+
539
  @app.post("/query")
540
  async def query_endpoint(request: QueryRequest):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  result = rag_query(request.query, top_k=request.top_k)
542
  return JSONResponse(content=result)
543
+
544
+
545
  @app.post("/ingest")
546
  async def ingest_endpoint(request: IngestRequest):
 
 
 
 
 
 
 
 
 
547
  chunks = chunk_text(request.text, source=request.source)
548
  count = add_documents_to_vectorstore(chunks)
549
+ return JSONResponse(content={"status": "success", "chunks_added": count, "total_chunks": collection.count()})
550
+
551
+
 
 
 
 
552
  @app.get("/health")
553
  async def health_check():
 
554
  return {
555
  "status": "healthy",
556
  "documents_in_store": collection.count(),
557
  "embedding_model": EMBEDDING_MODEL_NAME,
558
  "llm_model": LLM_MODEL_NAME,
559
+ "bm25_enabled": _bm25_index is not None,
560
+ "reranker_enabled": _cross_encoder is not None,
561
+ "routing_enabled": _ROUTING_ENABLED,
562
  }
563
+
564
+
565
  # ---------------------------------------------------------------------------
566
+ # Gradio UI
567
  # ---------------------------------------------------------------------------
568
+
569
  def gradio_query(question: str) -> tuple[str, str, str, str, str]:
 
570
  if not question.strip():
571
  return "Please enter a question.", "", "", "", ""
572
  result = rag_query(question)
573
+ sources_text = "\n".join(f" - {s['source']} (relevance: {s['score']:.2f})" for s in result["sources"])
574
+ routing_info = f"\n\n🔀 Modèle: {result.get('model_used','?')} (type: {result.get('question_type','?')})"
575
+ answer = f"{result['answer']}\n\n📚 Sources:\n{sources_text}{routing_info}" if result["sources"] else result["answer"]
 
576
  explanation = result.get("explanation", "")
577
  token_info = str(result.get("total_token", 0))
578
  co2_value = result.get("co2_grams")
579
  co2_info = f"{co2_value:.4f} g" if isinstance(co2_value, (int, float)) else "N/A"
580
  run_time = f"{result.get('run_time_in_ms', 0)} ms"
581
  return answer, explanation, token_info, co2_info, run_time
582
+
583
+
584
  def gradio_ingest(text: str, source_name: str) -> str:
 
585
  if not text.strip():
586
  return "Please provide text to ingest."
587
+ count = add_documents_to_vectorstore(chunk_text(text, source=source_name or "user_upload"))
 
588
  return f"✅ Ingested {count} chunks. Total documents in store: {collection.count()}"
589
+
590
+
591
  with gr.Blocks(title="RAG Chat API - Gustave Eiffel Hackathon") as demo:
592
  gr.Markdown("""
593
  # 🗼 RAG Chat API - Gustave Eiffel Hackathon 2026
594
+ Pipeline avancé : recherche hybride (vectoriel + BM25) → reranking cross-encoder → routing mini/gros.
595
+ **API Endpoint:** `POST /query` avec `{"query": "votre question"}`.
 
 
 
596
  ---
597
  """)
598
+
599
  with gr.Tab("💬 Chat"):
600
+ gr.Markdown("Posez une question sur les mémoires ingérés.")
601
  with gr.Row():
602
+ query_input = gr.Textbox(label="Your Question", placeholder="e.g., Qu'est-ce que le risk adjustment ?", lines=2)
 
 
 
 
603
  query_button = gr.Button("Ask", variant="primary")
604
  query_output = gr.Textbox(label="Answer", lines=8, interactive=False)
605
  query_explanation = gr.Textbox(label="Explanation", lines=3, interactive=False)
 
607
  query_tokens = gr.Textbox(label="Total Tokens", interactive=False)
608
  query_co2 = gr.Textbox(label="CO2 Emission", interactive=False)
609
  query_runtime = gr.Textbox(label="Run Time", interactive=False)
610
+ query_button.click(fn=gradio_query, inputs=query_input,
611
+ outputs=[query_output, query_explanation, query_tokens, query_co2, query_runtime])
612
+
 
 
 
613
  with gr.Tab("📄 Ingest Documents"):
614
  gr.Markdown("Add new documents to the knowledge base.")
615
+ doc_text = gr.Textbox(label="Document Text", placeholder="Paste your document text here...", lines=10)
616
+ doc_source = gr.Textbox(label="Source Name", placeholder="e.g., my_document.txt", value="user_upload")
 
 
 
 
 
 
 
 
617
  ingest_button = gr.Button("Ingest Document", variant="primary")
618
  ingest_output = gr.Textbox(label="Status", interactive=False)
619
  ingest_button.click(fn=gradio_ingest, inputs=[doc_text, doc_source], outputs=ingest_output)
620
+
621
  with gr.Tab("ℹ️ API Info"):
622
  gr.Markdown("""
623
  ## API Endpoints
 
624
  ### POST /query
625
  ```json
626
+ {"query": "What is the Eiffel Tower?", "top_k": 3}
 
 
 
 
 
 
 
 
 
 
 
 
627
  ```
 
 
 
 
 
 
 
 
 
628
  ### GET /health
629
+ Returns system health, document count, and which features are active
630
+ (bm25, reranker, routing).
631
  """)
632
+
 
633
  app = gr.mount_gradio_app(app, demo, path="/")
634
+
 
 
 
 
635
  if __name__ == "__main__":
636
  import uvicorn
637
  uvicorn.run(app, host="0.0.0.0", port=7860)
638
+
config.json CHANGED
@@ -1,14 +1,20 @@
1
  {
2
- "embedding": {
3
- "endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/text-embedding-3-small/embeddings?api-version=2024-12-01-preview",
4
- "model": "text-embedding-3-small"
5
- },
6
- "llm": {
7
- "endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/gpt-5.1/chat/completions?api-version=2024-12-01-preview",
8
- "model": "gpt-5.1",
9
- "max_completion_tokens": 512,
10
- "temperature": 0.7,
11
- "top_p": 0.95
12
- }
13
-
14
- }
 
 
 
 
 
 
 
1
  {
2
+ "embedding": {
3
+ "endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/text-embedding-3-small/embeddings?api-version=2024-12-01-preview",
4
+ "model": "text-embedding-3-small"
5
+ },
6
+ "llm": {
7
+ "endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/gpt-5.1/chat/completions?api-version=2024-12-01-preview",
8
+ "model": "gpt-5.1",
9
+ "max_completion_tokens": 512,
10
+ "temperature": 0.7,
11
+ "top_p": 0.95
12
+ },
13
+ "llm_small": {
14
+ "endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/gpt-5-mini/chat/completions?api-version=2024-12-01-preview",
15
+ "model": "gpt-5-mini",
16
+ "max_completion_tokens": 512,
17
+ "temperature": 1,
18
+ "top_p": 1
19
+ }
20
+ }
prompts/rag_prompt.txt CHANGED
@@ -2,17 +2,15 @@ Tu es un assistant expert en actuariat (assurance vie et non-vie, prévoyance, s
2
 
3
  Règles impératives :
4
  - Utilise UNIQUEMENT les informations présentes dans le contexte. N'invente jamais de chiffre, de définition, de formule ou de source.
5
- - Si le contexte ne contient pas de quoi répondre, réponds exactement : "Le contexte fourni ne permet pas de répondre à cette question."
6
- - Réponds dans la langue de la question (français par défaut).
7
  - Sois précis, factuel et concis ; emploie le vocabulaire actuariel exact.
8
- - Si la question est un QCM (propositions A, B, C, D...), donne la ou les bonnes réponses et justifie brièvement.
9
- - Ne fais aucune supposition au-delà du contexte ; n'ajoute pas de connaissances générales non étayées par les extraits.
10
 
11
- Tu DOIS répondre en JSON valide, avec EXACTEMENT ces deux champs, et AUCUN texte en dehors de l'objet JSON :
12
- - "answer" : ta réponse factuelle et concise à la question
13
- - "explanation" : un bref raisonnement indiquant quels passages du contexte tu as utilisés et pourquoi ils mènent à cette réponse
14
 
15
  Contexte :
16
  {context}
17
-
18
  Question : {question}
 
2
 
3
  Règles impératives :
4
  - Utilise UNIQUEMENT les informations présentes dans le contexte. N'invente jamais de chiffre, de définition, de formule ou de source.
5
+ - TRÈS IMPORTANT : si le contexte ne contient pas l'information nécessaire, tu NE DOIS PAS répondre ni deviner à partir de tes connaissances générales. Mets alors dans "answer" EXACTEMENT : "Le contexte fourni ne permet pas de répondre à cette question." (et explique brièvement dans "explanation" ce qui manque). Mieux vaut refuser de répondre que d'inventer.
6
+ - Réponds toujours en français.
7
  - Sois précis, factuel et concis ; emploie le vocabulaire actuariel exact.
8
+ - Si la question est un QCM (propositions A, B, C, D...), commence "answer" par la ou les lettres correctes (ex : "B" ou "A, C"), suivies d'une justification brève.
 
9
 
10
+ Tu DOIS répondre en JSON valide, avec EXACTEMENT ces deux champs et AUCUN texte en dehors de l'objet JSON :
11
+ - "answer" : ta réponse factuelle et concise
12
+ - "explanation" : un bref raisonnement indiquant quels passages du contexte tu as utilisés
13
 
14
  Contexte :
15
  {context}
 
16
  Question : {question}
requirements.txt CHANGED
@@ -8,3 +8,5 @@ pypdf==4.3.0
8
  python-multipart==0.0.9
9
  requests>=2.31.0
10
  ecologits>=0.5.0
 
 
 
8
  python-multipart==0.0.9
9
  requests>=2.31.0
10
  ecologits>=0.5.0
11
+ rank-bm25==0.2.2
12
+ sentence-transformers>=3.0.0