Spaces:
Sleeping
Sleeping
Pull code from source repo
Browse files- README.md +285 -8
- README_HF.md +172 -0
- app.py +598 -0
- config.json +14 -0
- ingest_train_data.py +197 -0
- llm.py +152 -0
- prompts/rag_prompt.txt +15 -0
- requirements.txt +15 -0
- team.yml +12 -0
README.md
CHANGED
|
@@ -1,13 +1,290 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Hackathon
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Example App Hackathon Gustave Eiffel 2026
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.1
|
| 8 |
+
python_version: '3.11'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
license: apache-2.0
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# RAG Chat API Sample Code for Gustave Eiffel Hackathon 2026
|
| 15 |
+
|
| 16 |
+
This is a sample code of **Retrieval-Augmented Generation (RAG)** API deployed to Hugging Face Spaces for the Gustave Eiffel Hackathon 2026. The goal of this hackathon is to build an AI assistant for actuarial questions using a document corpus. The core challenge
|
| 17 |
+
is to build an assistant that is:
|
| 18 |
+
|
| 19 |
+
- ✅ Accurate (Juste) – correct answers, no hallucinations
|
| 20 |
+
- 💲 Low cost (Pas cher) – minimize token usage
|
| 21 |
+
- 🌱 Sustainable (Sobre) – reduce CO2 impact
|
| 22 |
+
|
| 23 |
+
👉 Success = balance all three, not just performance!
|
| 24 |
+
|
| 25 |
+
This sample code demonstrates how to build a RAG system with FastAPI and Gradio, and deploy it to Hugging Face Spaces. It also includes the necessary configuration and structure to meet the hackathon requirements. It includes:
|
| 26 |
+
- `/query` API endpoint to interact with the user or the RAG evaluation system. Note that the output of this endpoint is designed for the RAG evaluation system, which will assess the correctness of the answer from your RAG. The response MUST have all the fields as follow (DO NOT change the response format, otherwise the evaluation framework will not be able to parse the response and evaluate the answer -- you will get 0 for the evaluation if the response is unparseable):
|
| 27 |
+
- answer (str): The generated answer from your RAG system
|
| 28 |
+
- sources (list): Source documents used for context in the answer generation, each source should have:
|
| 29 |
+
- source (str): File name of the source document
|
| 30 |
+
- score (float): Relevance score of the source document
|
| 31 |
+
- ref_text (str): The exact text span from the source document that is used in the answer generation (can be truncated if too long)
|
| 32 |
+
- explanation (str): Explanation of the retrieval and answer logic
|
| 33 |
+
- total_token (int): Total token count from the LLM call
|
| 34 |
+
- prompt_tokens (int): Prompt token count
|
| 35 |
+
- completion_tokens (int): Completion token count
|
| 36 |
+
- cached_tokens (int): Cached prompt tokens (when reported by model provider)
|
| 37 |
+
- co2_grams (float | None): Estimated CO2 emission in grams (via ecologits)
|
| 38 |
+
- energy_kwh (float | None): Estimated energy use in kWh (via ecologits)
|
| 39 |
+
- run_time_in_ms (float): Pipeline execution time in milliseconds
|
| 40 |
+
- `/health` API endpoint to check if the API is running or not. The return format should be `{"status": "healthy"}` when the API is healthy.
|
| 41 |
+
- Gradio UI and configuration to be deployed to Hugging Face Spaces. The UI allows users to chat with the RAG system and ingest new documents.
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Overview
|
| 46 |
+
|
| 47 |
+
This application demonstrates how to build a production-ready RAG system within the Hugging Face Spaces. It covers:
|
| 48 |
+
|
| 49 |
+
| Requirement | Provided Solution |
|
| 50 |
+
|---|---|
|
| 51 |
+
| LLM API calls | Azure OpenAI (`gpt-5.1` via REST API) |
|
| 52 |
+
| Text → Embeddings | Azure OpenAI (`text-embedding-3-small` via REST API) |
|
| 53 |
+
| Prompt engineering | Custom prompts for RAG system in /prompts/rag_prompts.txt |
|
| 54 |
+
| Vector Store | ChromaDB (persistent in /data folder, runs in-process) |
|
| 55 |
+
|
| 56 |
+
Hackathon participants can use this code as a starting point and customize any solutions (the LLM calls, embeddings, prompts, and vector store) as they see fit to optimize for accuracy, cost, and sustainability. The provided code is modular and well-documented to facilitate easy modifications.
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Architecture
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
┌──────────────────────────────────────────────────────────────┐
|
| 64 |
+
│ Hugging Face Space │
|
| 65 |
+
│ │
|
| 66 |
+
│ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
|
| 67 |
+
│ │ Gradio │ │ FastAPI │ │ ChromaDB │ │
|
| 68 |
+
│ │ UI │────▶│ /query │────▶│ Vector Store │ │
|
| 69 |
+
│ │ │ │ /ingest │ │ (persistent in │ │
|
| 70 |
+
│ │ │ │ /health │ │ /data folder) │ │
|
| 71 |
+
│ └──────────┘ └──────┬───────┘ └────────────────┘ │
|
| 72 |
+
│ │ ▲ │
|
| 73 |
+
│ ▼ │ │
|
| 74 |
+
│ ┌──────────────────┐ ┌─────────────────┐ │
|
| 75 |
+
│ │ Azure OpenAI │ │ Azure OpenAI │ │
|
| 76 |
+
│ │ GPT-5.1 (LLM) │ │ text-embedding │ │
|
| 77 |
+
│ │ │ │ -3-small │ │
|
| 78 |
+
│ └──────────────────┘ └─────────────────┘ │
|
| 79 |
+
└──────────────────────────────────────────────────────────────┘
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
## Setup & Deployment
|
| 84 |
+
|
| 85 |
+
### 1. Register for Hugging Face and Create a Space
|
| 86 |
+
|
| 87 |
+
If you have not created a Hugging Face Space, follow the instructions in the [Hugging Face Quickstart](README_HF.md) to create an account and set up a new Space and clone the sample code to your local machine.
|
| 88 |
+
|
| 89 |
+
### 2. Set Up a Python Virtual Environment
|
| 90 |
+
|
| 91 |
+
Download and set up a Python virtual environment to manage package dependencies.
|
| 92 |
+
|
| 93 |
+
To download Python, visit the [official Python website](https://www.python.org/downloads/) and follow the installation instructions for your operating system. Make sure to install Python 3.10 or 3.11. There is no need to install the latest Python version if it is above 3.11, as some dependencies may not be compatible with versions higher than 3.11.
|
| 94 |
+
|
| 95 |
+
To create a virtual environment, run the following command in your terminal (you can replace `hackathon-eiffel` with any name you prefer for your virtual environment):
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
python -m venv ~/.venv/hackathon-eiffel
|
| 99 |
+
|
| 100 |
+
# Activate the virtual environment
|
| 101 |
+
# macOS / Linux
|
| 102 |
+
source ~/.venv/hackathon-eiffel/bin/activate
|
| 103 |
+
# Windows (PowerShell)
|
| 104 |
+
~\.venv\hackathon-eiffel\Scripts\Activate.ps1
|
| 105 |
+
# Windows (Command Prompt)
|
| 106 |
+
~\.venv\hackathon-eiffel\Scripts\activate.bat
|
| 107 |
+
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Once your virtual environment is activated, go to your project directory to install package dependencies and run the application:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# Install dependencies
|
| 114 |
+
pip install -r requirements.txt
|
| 115 |
+
|
| 116 |
+
# Set your Azure API key. Azure API key is a key to call the LLM APIs. The organizers will provide you one at the date of the hackathon.
|
| 117 |
+
# macOS / Linux
|
| 118 |
+
export AZURE_API_KEY="your_azure_api_key_here"
|
| 119 |
+
# Windows (PowerShell)
|
| 120 |
+
$env:AZURE_API_KEY = "your_azure_api_key_here"
|
| 121 |
+
|
| 122 |
+
# Run the application
|
| 123 |
+
python app.py
|
| 124 |
+
# Server starts at http://localhost:7860 . If you can open http://localhost:7860/health on browser, it means the API is running successfully.
|
| 125 |
+
|
| 126 |
+
# To make the RAG work, you first need to create embeddings , you can learn about it from README_RAG.md
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
> **Tip:** To deactivate the virtual environment when you are done, run `deactivate` (venv) or `conda deactivate` (conda).
|
| 130 |
+
|
| 131 |
---
|
| 132 |
|
| 133 |
+
### 3. Install the Hugging Face CLI
|
| 134 |
+
|
| 135 |
+
To manage binary files in the Hugging Face bucket (for storing large files like ChromaDB vector store, ingested documents, etc.), you need to install the Hugging Face CLI tool:
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
# Install the hf CLI (requires uv)
|
| 139 |
+
uv tool install "huggingface_hub[cli]"
|
| 140 |
+
|
| 141 |
+
# Try to login to Hugging Face with Hugging Face CLI (it needs Write access to the bucket)
|
| 142 |
+
export HF_TOKEN="hf_your_token_here"
|
| 143 |
+
# or interactively:
|
| 144 |
+
hf auth login
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### 4. Adding Binary Files to the HF Space
|
| 148 |
+
|
| 149 |
+
Large or binary files (PDFs, pre-built ChromaDB databases, datasets, model weights) have to be stored in a **Hugging Face bucket** and mounted into the Space container. The `hf sync` command keeps your local folder in sync with the bucket.
|
| 150 |
+
|
| 151 |
+
> **Note:** Git LFS is not supported for Hugging Face Spaces persistent storage. Use the bucket + `hf sync` workflow described here instead.
|
| 152 |
+
|
| 153 |
+
### 5. Persist files in the Shared Folder
|
| 154 |
+
|
| 155 |
+
Each Hugging Face Space has a **persistent storage bucket** that is automatically mounted at `/data` to your project root inside the running container server. This folder is the single shared location where the application reads and writes all persistent files — the ChromaDB vector store, display images, even secrets and any binary assets.
|
| 156 |
+
|
| 157 |
+
| Path in server | Purpose |
|
| 158 |
+
|---|---|
|
| 159 |
+
| `/data/chroma_db/` | ChromaDB vector store (survives Space restarts) |
|
| 160 |
+
|
| 161 |
+
**Default bucket naming convention:**
|
| 162 |
+
A Hugging Face Space at
|
| 163 |
+
`https://huggingface.co/spaces/<org>/<space-name>`
|
| 164 |
+
gets a default /data storage bucket at
|
| 165 |
+
`https://huggingface.co/buckets/<org>/<space-name>-storage`
|
| 166 |
+
|
| 167 |
+
Using this project as an example, the Space URL is:
|
| 168 |
+
- Space: `https://huggingface.co/spaces/millimanfrance/Example-App-Hackathon-Gustave-Eiffel-2026`
|
| 169 |
+
- Bucket: `https://huggingface.co/buckets/millimanfrance/Example-App-Hackathon-Gustave-Eiffel-2026-storage`
|
| 170 |
+
|
| 171 |
+
The `hf://` URI for use with the CLI is:
|
| 172 |
+
`hf://buckets/millimanfrance/Example-App-Hackathon-Gustave-Eiffel-2026-storage`
|
| 173 |
+
|
| 174 |
+
Note that by default, the bucket is empty and private when you create the Space. You need to upload files to it (via `hf sync`) before they appear in the Space server at `/data`.
|
| 175 |
+
|
| 176 |
+
To attach the Hugging Face Storage Bucket to Your Space:
|
| 177 |
+
|
| 178 |
+
1. Open your Space on huggingface.co
|
| 179 |
+
2. Go to **"Organization" → Your organization name → "+New" → "Bucket"**
|
| 180 |
+
3. Once the bucket is created. Go to your Hugging Face Space, click **"Settings"**. Scroll down to **"Storage Buckets" → "Mount a bucket"**, select the existing bucket **`<org>/<space-name>-storage`** to mount it to your Space of Month path `/data`.
|
| 181 |
+
4. "Mount bucket" — Hugging Face will mount the bucket at `/data` inside the Space container
|
| 182 |
+
|
| 183 |
+
To Sync Your Local `./data` with the Space Bucket
|
| 184 |
+
|
| 185 |
+
Place all persistent files under the `./data` folder of your project. The expected structure may look like this:
|
| 186 |
+
|
| 187 |
+
```
|
| 188 |
+
./data/
|
| 189 |
+
├── chroma_db/ # Binary files and ChromaDB vector store
|
| 190 |
+
│ └── chroma.sqlite3
|
| 191 |
+
./train_data/ # Hackathon train datasets (will be mounted at /train_data in the project with a bucket from the Hackathon organizer)
|
| 192 |
+
├── Automobile - Train/
|
| 193 |
+
├── Climatique - Train/
|
| 194 |
+
└── ...
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
**Push local file in ./data to Hugging Face bucket:**
|
| 198 |
+
```bash
|
| 199 |
+
# Mirror ./data to the bucket — removes remote files deleted locally
|
| 200 |
+
hf sync ./data hf://buckets/<org>/<space-name>-storage --delete
|
| 201 |
+
|
| 202 |
+
# Sync only a specific sub-folder (e.g., the vector store)
|
| 203 |
+
hf sync ./data/chroma_db hf://buckets/<org>/<space-name>-storage/chroma_db
|
| 204 |
+
|
| 205 |
+
# Sync only specific file types
|
| 206 |
+
hf sync ./data hf://buckets/<org>/<space-name>-storage \
|
| 207 |
+
--include "*.pdf" --include "*.sqlite3"
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
> **`--delete` flag:** Without it, `hf sync` only uploads new/changed files and never removes anything from the remote. Add `--delete` to make the bucket an exact mirror of your local `./data`.
|
| 211 |
+
|
| 212 |
+
`hf sync` is **incremental** — it computes checksums and only transfers files that have changed.
|
| 213 |
+
|
| 214 |
+
**Download the Bucket to another Machine's `./data` folder:**
|
| 215 |
+
|
| 216 |
+
To pull the latest bucket contents back to a local `./data` folder (e.g., on another team member's machine):
|
| 217 |
+
|
| 218 |
+
```bash
|
| 219 |
+
hf sync hf://buckets/<org>/<space-name>-storage ./data
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
### 6. Get Hackathon Train Datasets from the Organizer's Bucket
|
| 223 |
+
|
| 224 |
+
The train datasets for this hackathon are stored in a separate read-only bucket. Sync them to a local `./train_data` folder to save your Hugging Face bucket storage (the train datasets is about 1GB).
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
# Download all training data to local folder train_data/ from the organizer's bucket hf://buckets/millimanfrance/Hackathon2026TrainData
|
| 228 |
+
hf sync hf://buckets/millimanfrance/Hackathon2026TrainData ./train_data
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
> **Note:** `Hackathon2026TrainData` is a shared read-only bucket. Do not attempt to push to it.
|
| 232 |
+
|
| 233 |
+
### 7. Accessing Files in the Space Application
|
| 234 |
+
|
| 235 |
+
Once the bucket is mounted, files appear under `/data` inside the Space container. The application resolves the data root automatically via a single `DATA_DIR` constant in `app.py`. Here is an example of how to access the ChromaDB vector store and the environment file in your code:
|
| 236 |
+
|
| 237 |
+
```python
|
| 238 |
+
from pathlib import Path
|
| 239 |
+
|
| 240 |
+
# /data when running in HF Spaces (bucket mount), ./data for local dev
|
| 241 |
+
DATA_DIR = Path("/data") if Path("/data").is_dir() else Path("./data")
|
| 242 |
+
|
| 243 |
+
CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
|
| 244 |
+
ENV_FILE = str(DATA_DIR / ".env")
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
All persistent data — the vector store, sample documents, datasets — lives under `DATA_DIR` so a single `hf sync ./data ...` covers everything.
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
## Application Configuration
|
| 251 |
+
|
| 252 |
+
The application loads model configuration from **`./config.json`** (project root)
|
| 253 |
+
|
| 254 |
+
**NEVER put real credentials in the config file.** Use environment variables instead. The config file is meant for non-sensitive configuration parameters like model names, API endpoints, and other settings.
|
| 255 |
+
|
| 256 |
+
### Step 1 — Configure `./config.json`
|
| 257 |
+
|
| 258 |
+
Edit `./config.json` to different LLM and embedding models, or to add additional configuration parameters. The provided sample config is designed for Azure OpenAI REST API calls, but you can modify the structure and fields as needed for your implementation. Just make sure to update the code in `app.py` accordingly to read the new config format. The organizers will provide you with the Azure API key at the date of the hackathon, and two LLM models (**gpt-5-mini** & **gpt-5.1**) and embedding models (**text-embedding-3-large** & **text-embedding-3-small**) are available.
|
| 259 |
+
|
| 260 |
+
```json
|
| 261 |
+
{
|
| 262 |
+
"embedding": {
|
| 263 |
+
"endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/text-embedding-3-small/embeddings?api-version=2024-12-01-preview",
|
| 264 |
+
"model": "text-embedding-3-small"
|
| 265 |
+
},
|
| 266 |
+
"llm": {
|
| 267 |
+
"endpoint_url": "https://hackathon-eiffel-2026-apim.azure-api.net/ai/openai/deployments/gpt-5.1/chat/completions?api-version=2024-12-01-preview",
|
| 268 |
+
"model": "gpt-5.1",
|
| 269 |
+
"max_completion_tokens": 512,
|
| 270 |
+
"temperature": 0.7,
|
| 271 |
+
"top_p": 0.95
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
}
|
| 275 |
+
```
|
| 276 |
+
> **Note:** The `endpoint_url` for embeddings ends in `/embeddings` and the one for the LLM ends in `/chat/completions`. Keep those suffixes intact.
|
| 277 |
+
|
| 278 |
+
### Step 2 — Set the API Key
|
| 279 |
+
|
| 280 |
+
The API key **MUST NOT** be stored in `config.json`. Set it as an environment variable:
|
| 281 |
+
|
| 282 |
+
```bash
|
| 283 |
+
# macOS / Linux
|
| 284 |
+
export AZURE_API_KEY="your_azure_api_key_here"
|
| 285 |
+
|
| 286 |
+
# Windows (PowerShell)
|
| 287 |
+
$env:AZURE_API_KEY = "your_azure_api_key_here"
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
When deploying to Hugging Face Spaces, add it as a **Space Secret** (Go to your Hugging Face Space, click **Settings → Variables and secrets → New secret → name it `AZURE_API_KEY`**).
|
README_HF.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# How to Push Code to a New HuggingFace Space
|
| 2 |
+
|
| 3 |
+
## Prerequisites
|
| 4 |
+
|
| 5 |
+
- [Git](https://git-scm.com/) installed
|
| 6 |
+
- A [HuggingFace account](https://huggingface.co/join)
|
| 7 |
+
- A HuggingFace Access Token (create one at [Settings > Tokens](https://huggingface.co/settings/tokens) with **Write** permission)
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Steps
|
| 12 |
+
|
| 13 |
+
### 1. Create a New Space on HuggingFace
|
| 14 |
+
|
| 15 |
+
1. Go to [huggingface.co/new-space](https://huggingface.co/new-space)
|
| 16 |
+
2. Choose a **Space name** (e.g., `My_App`)
|
| 17 |
+
3. Select the **SDK** (Gradio, Streamlit, Docker, or Static)
|
| 18 |
+
4. Choose visibility (Public or Private)
|
| 19 |
+
5. Click **Create Space**
|
| 20 |
+
|
| 21 |
+
Your Space URL will be: `https://huggingface.co/spaces/<YOUR_USERNAME>/<SPACE_NAME>`
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
### 2. Clone the Empty Space Locally
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
git clone https://huggingface.co/spaces/<YOUR_USERNAME>/<SPACE_NAME>
|
| 29 |
+
cd <SPACE_NAME>
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
When prompted for credentials:
|
| 33 |
+
- **Username:** Your HuggingFace username
|
| 34 |
+
- **Password:** Your HuggingFace Access Token (NOT your account password)
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
### 3. Register Your Team
|
| 39 |
+
Create a `team.yml` file at the **root** of your Hugging Face Space folder with your team name and members. This is required for hackathon participation and helps organizers identify your team during evaluation.
|
| 40 |
+
|
| 41 |
+
```yaml
|
| 42 |
+
display_name: Your Team Name
|
| 43 |
+
members:
|
| 44 |
+
- name: Alice Chen, email: "alice@example.com"
|
| 45 |
+
- name: Bob Martin, email: "bob@example.com"
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Create a `README.md` file at the **root** of your Hugging Face Space folder with a brief description of your app. This is required for Hugging Face Spaces and helps organizers understand your app during evaluation. Replace [Your Team Name] with your actual team name. Also note that the `app_file` field should match the name of your main application file (e.g., `app.py`).
|
| 49 |
+
|
| 50 |
+
```markdown
|
| 51 |
+
title: [Your Team Name]'s RAG App
|
| 52 |
+
emoji: 🤖
|
| 53 |
+
colorFrom: blue
|
| 54 |
+
colorTo: purple
|
| 55 |
+
sdk: gradio
|
| 56 |
+
sdk_version: 4.44.1
|
| 57 |
+
python_version: '3.11'
|
| 58 |
+
app_file: app.py
|
| 59 |
+
pinned: false
|
| 60 |
+
license: apache-2.0
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
### 4. Add Codes
|
| 66 |
+
|
| 67 |
+
#### Pull the sample code Example-App-Hackathon-Gustave-Eiffel-2026 into the Space
|
| 68 |
+
|
| 69 |
+
To have a quick start, you can pull the sameple code Example-App-Hackathon-Gustave-Eiffel-2026 into your Space. You can do this by adding this repo as a second remote source and merging it into your Space:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Inside your cloned Space folder:
|
| 73 |
+
cd <SPACE_NAME>
|
| 74 |
+
|
| 75 |
+
# Add the source repo as a second remote
|
| 76 |
+
git remote add source https://huggingface.co/spaces/millimanfrance/Example-App-Hackathon-Gustave-Eiffel-2026
|
| 77 |
+
# or from GitHub:
|
| 78 |
+
# git remote add source https://github.com/<OWNER>/<REPO>.git
|
| 79 |
+
|
| 80 |
+
# Fetch all branches from the source
|
| 81 |
+
git fetch source
|
| 82 |
+
|
| 83 |
+
# Merge the source's main branch into your Space
|
| 84 |
+
git merge source/main --allow-unrelated-histories -m "Pull code from source repo"
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
#### Add your code into the Space
|
| 88 |
+
|
| 89 |
+
If you are working in a team, your teammate can simply copy/paste your code or files into the cloned Space folder.
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
#### Add a `.gitignore` to exclude unnecessary files:
|
| 94 |
+
|
| 95 |
+
Put the following content in a `.gitignore` file at the root of your Space to avoid pushing large or sensitive files:
|
| 96 |
+
```
|
| 97 |
+
.venv
|
| 98 |
+
__pycache__/
|
| 99 |
+
**/__pycache__/
|
| 100 |
+
*.sqlite3
|
| 101 |
+
chroma_db/
|
| 102 |
+
.env
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
### 5. Commit and Push
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
git add .
|
| 111 |
+
git commit -m "Initial commit"
|
| 112 |
+
git push origin main
|
| 113 |
+
git remote remove source (if you have added the sample code Example-App-Hackathon-Gustave-Eiffel-2026 as a second remote)
|
| 114 |
+
```
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
### 6. Deploy to Hugging Face Spaces
|
| 119 |
+
|
| 120 |
+
1. Once you have pushed the code, (IF NOT) Push this code to the Space repository.
|
| 121 |
+
2. Add `AZURE_API_KEY` as a Space Secret (Settings → Secrets). Azure API key is a key to call the LLM APIs. The organizers will provide you one at the date of the hackathon.
|
| 122 |
+
3. The Space automatically installs dependencies and starts the app.
|
| 123 |
+
4. The hackathon will be a competition on the RAG (Retrieval-Augmented Generation) application you built. You are encouraged to learn the basics of RAG.
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
## Troubleshooting reference
|
| 127 |
+
|
| 128 |
+
### Binary File Rejection
|
| 129 |
+
|
| 130 |
+
HuggingFace rejects pushes containing binary files (e.g., `.sqlite3`, `.pkl`, `.bin`).
|
| 131 |
+
|
| 132 |
+
**Fix:**
|
| 133 |
+
|
| 134 |
+
1. Add the binary files to `.gitignore`
|
| 135 |
+
2. Remove them from git tracking:
|
| 136 |
+
```bash
|
| 137 |
+
git rm -r --cached path/to/binary/file
|
| 138 |
+
```
|
| 139 |
+
3. Squash history to purge them completely:
|
| 140 |
+
```bash
|
| 141 |
+
git add -A
|
| 142 |
+
git reset --soft $(git rev-list --max-parents=0 HEAD)
|
| 143 |
+
git commit -m "Clean initial commit"
|
| 144 |
+
git push hfspace main --force
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### Authentication Failed
|
| 148 |
+
|
| 149 |
+
- HuggingFace does **not** accept account passwords for git. Use an **Access Token**.
|
| 150 |
+
- Make sure the token has **Write** permission.
|
| 151 |
+
- You can embed the token in the remote URL:
|
| 152 |
+
```bash
|
| 153 |
+
git remote set-url hfspace https://<USERNAME>:<TOKEN>@huggingface.co/spaces/<USERNAME>/<SPACE_NAME>
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### Wrong Branch Name
|
| 157 |
+
|
| 158 |
+
Some repos use `master` instead of `main`. Check with:
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
git branch
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
Push to whichever branch your Space expects (usually `main`).
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## Security Reminder
|
| 169 |
+
|
| 170 |
+
- **Never** commit your HF token or API keys to the repo.
|
| 171 |
+
- If a token is accidentally exposed, revoke it immediately at [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) and generate a new one.
|
| 172 |
+
- Use environment variables or HuggingFace Space **Secrets** (Settings > Variables and secrets) for sensitive values.
|
app.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 32 |
+
from fastapi.responses import JSONResponse
|
| 33 |
+
from pydantic import BaseModel
|
| 34 |
+
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,
|
| 111 |
+
settings=Settings(anonymized_telemetry=False),
|
| 112 |
+
)
|
| 113 |
+
collection = chroma_client.get_or_create_collection(
|
| 114 |
+
name=COLLECTION_NAME,
|
| 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,
|
| 163 |
+
separators=["\n\n", "\n", ". ", " ", ""],
|
| 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 |
+
logger.info(f"Query received: {request.query}")
|
| 443 |
+
result = rag_query(request.query, top_k=request.top_k)
|
| 444 |
+
return JSONResponse(content=result)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
@app.post("/ingest")
|
| 448 |
+
async def ingest_endpoint(request: IngestRequest):
|
| 449 |
+
"""
|
| 450 |
+
Document Ingestion Endpoint.
|
| 451 |
+
|
| 452 |
+
Accepts raw text, chunks it, generates embeddings, and stores in the vector store.
|
| 453 |
+
|
| 454 |
+
Request body:
|
| 455 |
+
- text (str): The document text to ingest
|
| 456 |
+
- source (str, optional): Source identifier for the document
|
| 457 |
+
"""
|
| 458 |
+
chunks = chunk_text(request.text, source=request.source)
|
| 459 |
+
count = add_documents_to_vectorstore(chunks)
|
| 460 |
+
return JSONResponse(content={
|
| 461 |
+
"status": "success",
|
| 462 |
+
"chunks_added": count,
|
| 463 |
+
"total_chunks": collection.count(),
|
| 464 |
+
})
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
@app.get("/health")
|
| 468 |
+
async def health_check():
|
| 469 |
+
"""Health check endpoint for monitoring."""
|
| 470 |
+
return {
|
| 471 |
+
"status": "healthy",
|
| 472 |
+
"documents_in_store": collection.count(),
|
| 473 |
+
"embedding_model": EMBEDDING_MODEL_NAME,
|
| 474 |
+
"llm_model": LLM_MODEL_NAME,
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
# ---------------------------------------------------------------------------
|
| 479 |
+
# Step 6: Gradio UI for Interactive Demo
|
| 480 |
+
# ---------------------------------------------------------------------------
|
| 481 |
+
|
| 482 |
+
def gradio_query(question: str) -> tuple[str, str, str, str, str]:
|
| 483 |
+
"""Handle queries from the Gradio chat interface."""
|
| 484 |
+
if not question.strip():
|
| 485 |
+
return "Please enter a question.", "", "", "", ""
|
| 486 |
+
result = rag_query(question)
|
| 487 |
+
sources_text = "\n".join(
|
| 488 |
+
f" - {s['source']} (relevance: {s['score']:.2f})" for s in result["sources"]
|
| 489 |
+
)
|
| 490 |
+
answer = f"{result['answer']}\n\n📚 Sources:\n{sources_text}" if result["sources"] else result["answer"]
|
| 491 |
+
explanation = result.get("explanation", "")
|
| 492 |
+
token_info = str(result.get("total_token", 0))
|
| 493 |
+
co2_value = result.get("co2_grams")
|
| 494 |
+
co2_info = f"{co2_value:.4f} g" if isinstance(co2_value, (int, float)) else "N/A"
|
| 495 |
+
run_time = f"{result.get('run_time_in_ms', 0)} ms"
|
| 496 |
+
return answer, explanation, token_info, co2_info, run_time
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def gradio_ingest(text: str, source_name: str) -> str:
|
| 500 |
+
"""Handle document ingestion from the Gradio UI."""
|
| 501 |
+
if not text.strip():
|
| 502 |
+
return "Please provide text to ingest."
|
| 503 |
+
chunks = chunk_text(text, source=source_name or "user_upload")
|
| 504 |
+
count = add_documents_to_vectorstore(chunks)
|
| 505 |
+
return f"✅ Ingested {count} chunks. Total documents in store: {collection.count()}"
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
with gr.Blocks(title="RAG Chat API - Gustave Eiffel Hackathon") as demo:
|
| 509 |
+
gr.Markdown("""
|
| 510 |
+
# 🗼 RAG Chat API - Gustave Eiffel Hackathon 2026
|
| 511 |
+
|
| 512 |
+
This application demonstrates a complete **Retrieval-Augmented Generation (RAG)** system.
|
| 513 |
+
|
| 514 |
+
**API Endpoint:** Use `POST /query` with `{"query": "your question"}` for programmatic access.
|
| 515 |
+
|
| 516 |
+
---
|
| 517 |
+
""")
|
| 518 |
+
|
| 519 |
+
with gr.Tab("💬 Chat"):
|
| 520 |
+
gr.Markdown("Ask questions about the ingested documents.")
|
| 521 |
+
with gr.Row():
|
| 522 |
+
query_input = gr.Textbox(
|
| 523 |
+
label="Your Question",
|
| 524 |
+
placeholder="e.g., What is the Eiffel Tower made of?",
|
| 525 |
+
lines=2,
|
| 526 |
+
)
|
| 527 |
+
query_button = gr.Button("Ask", variant="primary")
|
| 528 |
+
query_output = gr.Textbox(label="Answer", lines=8, interactive=False)
|
| 529 |
+
query_explanation = gr.Textbox(label="Explanation", lines=3, interactive=False)
|
| 530 |
+
with gr.Row():
|
| 531 |
+
query_tokens = gr.Textbox(label="Total Tokens", interactive=False)
|
| 532 |
+
query_co2 = gr.Textbox(label="CO2 Emission", interactive=False)
|
| 533 |
+
query_runtime = gr.Textbox(label="Run Time", interactive=False)
|
| 534 |
+
query_button.click(
|
| 535 |
+
fn=gradio_query,
|
| 536 |
+
inputs=query_input,
|
| 537 |
+
outputs=[query_output, query_explanation, query_tokens, query_co2, query_runtime],
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
with gr.Tab("📄 Ingest Documents"):
|
| 541 |
+
gr.Markdown("Add new documents to the knowledge base.")
|
| 542 |
+
doc_text = gr.Textbox(
|
| 543 |
+
label="Document Text",
|
| 544 |
+
placeholder="Paste your document text here...",
|
| 545 |
+
lines=10,
|
| 546 |
+
)
|
| 547 |
+
doc_source = gr.Textbox(
|
| 548 |
+
label="Source Name",
|
| 549 |
+
placeholder="e.g., my_document.txt",
|
| 550 |
+
value="user_upload",
|
| 551 |
+
)
|
| 552 |
+
ingest_button = gr.Button("Ingest Document", variant="primary")
|
| 553 |
+
ingest_output = gr.Textbox(label="Status", interactive=False)
|
| 554 |
+
ingest_button.click(fn=gradio_ingest, inputs=[doc_text, doc_source], outputs=ingest_output)
|
| 555 |
+
|
| 556 |
+
with gr.Tab("ℹ️ API Info"):
|
| 557 |
+
gr.Markdown("""
|
| 558 |
+
## API Endpoints
|
| 559 |
+
|
| 560 |
+
### POST /query
|
| 561 |
+
```json
|
| 562 |
+
{
|
| 563 |
+
"query": "What is the Eiffel Tower?",
|
| 564 |
+
"top_k": 3
|
| 565 |
+
}
|
| 566 |
+
```
|
| 567 |
+
|
| 568 |
+
**Response:**
|
| 569 |
+
```json
|
| 570 |
+
{
|
| 571 |
+
"answer": "The Eiffel Tower is...",
|
| 572 |
+
"sources": [{"source": "eiffel_tower.txt", "score": 0.85}],
|
| 573 |
+
"query": "What is the Eiffel Tower?"
|
| 574 |
+
}
|
| 575 |
+
```
|
| 576 |
+
|
| 577 |
+
### POST /ingest
|
| 578 |
+
```json
|
| 579 |
+
{
|
| 580 |
+
"text": "Your document text here...",
|
| 581 |
+
"source": "document_name.txt"
|
| 582 |
+
}
|
| 583 |
+
```
|
| 584 |
+
|
| 585 |
+
### GET /health
|
| 586 |
+
Returns system health and document count.
|
| 587 |
+
""")
|
| 588 |
+
|
| 589 |
+
# Mount Gradio onto FastAPI so both the UI and API endpoints are served
|
| 590 |
+
app = gr.mount_gradio_app(app, demo, path="/")
|
| 591 |
+
|
| 592 |
+
# ---------------------------------------------------------------------------
|
| 593 |
+
# Entry Point
|
| 594 |
+
# ---------------------------------------------------------------------------
|
| 595 |
+
|
| 596 |
+
if __name__ == "__main__":
|
| 597 |
+
import uvicorn
|
| 598 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
config.json
ADDED
|
@@ -0,0 +1,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 |
+
|
| 14 |
+
}
|
ingest_train_data.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ingest Training Data Script
|
| 3 |
+
============================
|
| 4 |
+
|
| 5 |
+
Standalone script to convert all documents in ./train_data into embeddings
|
| 6 |
+
and store them in the ChromaDB vector store.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python ingest_train_data.py
|
| 10 |
+
|
| 11 |
+
Requires:
|
| 12 |
+
- AZURE_API_KEY environment variable set
|
| 13 |
+
- data/config.json (or root config.json as fallback) with endpoint URLs
|
| 14 |
+
- Documents (PDF/TXT) in ./train_data/<category>/
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import time
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")
|
| 25 |
+
|
| 26 |
+
import requests as http_requests
|
| 27 |
+
import chromadb
|
| 28 |
+
from chromadb.config import Settings
|
| 29 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 30 |
+
from pypdf import PdfReader
|
| 31 |
+
|
| 32 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Configuration
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
PROJECT_ROOT = Path(__file__).parent
|
| 40 |
+
DATA_DIR = Path("/data") if Path("/data").is_dir() else PROJECT_ROOT / "data"
|
| 41 |
+
TRAIN_DATA_DIR = Path("/train_data") if Path("/train_data").is_dir() else PROJECT_ROOT / "train_data"
|
| 42 |
+
|
| 43 |
+
CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db")
|
| 44 |
+
COLLECTION_NAME = "rag_documents"
|
| 45 |
+
CHUNK_SIZE = 512
|
| 46 |
+
CHUNK_OVERLAP = 50
|
| 47 |
+
EMBEDDING_BATCH_SIZE = 16
|
| 48 |
+
|
| 49 |
+
_CONFIG_PATH = DATA_DIR / "config.json"
|
| 50 |
+
if not _CONFIG_PATH.exists():
|
| 51 |
+
_CONFIG_PATH = PROJECT_ROOT / "config.json"
|
| 52 |
+
logger.warning(f"No config.json in {DATA_DIR} — using root config.json as fallback.")
|
| 53 |
+
|
| 54 |
+
with open(_CONFIG_PATH, encoding="utf-8") as _f:
|
| 55 |
+
_config = json.load(_f)
|
| 56 |
+
|
| 57 |
+
EMBEDDING_ENDPOINT_URL = _config["embedding"]["endpoint_url"]
|
| 58 |
+
EMBEDDING_MODEL_NAME = _config["embedding"]["model"]
|
| 59 |
+
|
| 60 |
+
AZURE_API_KEY = os.environ.get("AZURE_API_KEY")
|
| 61 |
+
if not AZURE_API_KEY:
|
| 62 |
+
logger.error("AZURE_API_KEY is not set. Export it before running this script.")
|
| 63 |
+
sys.exit(1)
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Helpers
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def extract_text_from_pdf(pdf_path: Path) -> str:
|
| 71 |
+
reader = PdfReader(str(pdf_path))
|
| 72 |
+
pages_text = []
|
| 73 |
+
for page_num, page in enumerate(reader.pages, start=1):
|
| 74 |
+
text = page.extract_text()
|
| 75 |
+
if text and text.strip():
|
| 76 |
+
pages_text.append(f"[Page {page_num}]\n{text.strip()}")
|
| 77 |
+
return "\n\n".join(pages_text)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def chunk_text(text: str, source: str) -> list[dict]:
|
| 81 |
+
splitter = RecursiveCharacterTextSplitter(
|
| 82 |
+
chunk_size=CHUNK_SIZE,
|
| 83 |
+
chunk_overlap=CHUNK_OVERLAP,
|
| 84 |
+
separators=["\n\n", "\n", ". ", " ", ""],
|
| 85 |
+
)
|
| 86 |
+
chunks = splitter.split_text(text)
|
| 87 |
+
return [{"text": chunk, "source": source, "chunk_index": i} for i, chunk in enumerate(chunks)]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def generate_embeddings(texts: list[str]) -> list[list[float]]:
|
| 91 |
+
headers = {"api-key": AZURE_API_KEY, "Content-Type": "application/json"}
|
| 92 |
+
payload = {"input": texts, "model": EMBEDDING_MODEL_NAME}
|
| 93 |
+
resp = http_requests.post(EMBEDDING_ENDPOINT_URL, headers=headers, json=payload, timeout=120)
|
| 94 |
+
resp.raise_for_status()
|
| 95 |
+
data = resp.json()
|
| 96 |
+
return [item["embedding"] for item in data["data"]]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def generate_embeddings_batched(texts: list[str]) -> list[list[float]]:
|
| 100 |
+
all_embeddings = []
|
| 101 |
+
for i in range(0, len(texts), EMBEDDING_BATCH_SIZE):
|
| 102 |
+
batch = texts[i:i + EMBEDDING_BATCH_SIZE]
|
| 103 |
+
logger.info(f" Embedding batch {i // EMBEDDING_BATCH_SIZE + 1} ({len(batch)} chunks)...")
|
| 104 |
+
embeddings = generate_embeddings(batch)
|
| 105 |
+
all_embeddings.extend(embeddings)
|
| 106 |
+
time.sleep(0.5) # Rate-limit courtesy
|
| 107 |
+
return all_embeddings
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Main
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def main():
|
| 116 |
+
if not TRAIN_DATA_DIR.exists():
|
| 117 |
+
logger.error(f"Training data directory not found: {TRAIN_DATA_DIR}")
|
| 118 |
+
sys.exit(1)
|
| 119 |
+
|
| 120 |
+
logger.info(f"Training data directory: {TRAIN_DATA_DIR}")
|
| 121 |
+
logger.info(f"ChromaDB path: {CHROMA_PERSIST_DIR}")
|
| 122 |
+
logger.info(f"Embedding model: {EMBEDDING_MODEL_NAME}")
|
| 123 |
+
|
| 124 |
+
chroma_client = chromadb.PersistentClient(
|
| 125 |
+
path=CHROMA_PERSIST_DIR,
|
| 126 |
+
settings=Settings(anonymized_telemetry=False),
|
| 127 |
+
)
|
| 128 |
+
collection = chroma_client.get_or_create_collection(
|
| 129 |
+
name=COLLECTION_NAME,
|
| 130 |
+
metadata={"hnsw:space": "cosine"},
|
| 131 |
+
)
|
| 132 |
+
logger.info(f"ChromaDB collection '{COLLECTION_NAME}' — existing documents: {collection.count()}")
|
| 133 |
+
|
| 134 |
+
# Gather all PDF and TXT files recursively
|
| 135 |
+
files = sorted(
|
| 136 |
+
list(TRAIN_DATA_DIR.rglob("*.pdf")) + list(TRAIN_DATA_DIR.rglob("*.txt"))
|
| 137 |
+
)
|
| 138 |
+
logger.info(f"Found {len(files)} files to process.")
|
| 139 |
+
|
| 140 |
+
total_chunks_added = 0
|
| 141 |
+
|
| 142 |
+
for idx, file_path in enumerate(files, start=1):
|
| 143 |
+
relative = file_path.relative_to(TRAIN_DATA_DIR)
|
| 144 |
+
# Use category/filename as the source identifier
|
| 145 |
+
source = str(relative)
|
| 146 |
+
logger.info(f"[{idx}/{len(files)}] Processing: {source}")
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
if file_path.suffix.lower() == ".pdf":
|
| 150 |
+
text = extract_text_from_pdf(file_path)
|
| 151 |
+
else:
|
| 152 |
+
text = file_path.read_text(encoding="utf-8")
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.warning(f" Skipped (read error): {e}")
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
if not text.strip():
|
| 158 |
+
logger.warning(f" Skipped (no extractable text)")
|
| 159 |
+
continue
|
| 160 |
+
|
| 161 |
+
chunks = chunk_text(text, source=source)
|
| 162 |
+
if not chunks:
|
| 163 |
+
logger.warning(f" Skipped (no chunks produced)")
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
logger.info(f" {len(chunks)} chunks extracted")
|
| 167 |
+
|
| 168 |
+
texts = [c["text"] for c in chunks]
|
| 169 |
+
try:
|
| 170 |
+
embeddings = generate_embeddings_batched(texts)
|
| 171 |
+
except http_requests.exceptions.HTTPError as e:
|
| 172 |
+
logger.error(f" Embedding failed: {e}")
|
| 173 |
+
continue
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f" Unexpected embedding error: {e}")
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
existing_count = collection.count()
|
| 179 |
+
ids = [f"doc_{existing_count + i}" for i in range(len(chunks))]
|
| 180 |
+
metadatas = [{"source": c["source"], "chunk_index": c["chunk_index"]} for c in chunks]
|
| 181 |
+
|
| 182 |
+
collection.add(
|
| 183 |
+
ids=ids,
|
| 184 |
+
embeddings=embeddings,
|
| 185 |
+
documents=texts,
|
| 186 |
+
metadatas=metadatas,
|
| 187 |
+
)
|
| 188 |
+
total_chunks_added += len(chunks)
|
| 189 |
+
logger.info(f" Added {len(chunks)} chunks (total in store: {collection.count()})")
|
| 190 |
+
|
| 191 |
+
logger.info("=" * 60)
|
| 192 |
+
logger.info(f"Ingestion complete. Chunks added this run: {total_chunks_added}")
|
| 193 |
+
logger.info(f"Total documents in vector store: {collection.count()}")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
main()
|
llm.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM wrapper with token accounting and CO2 emission estimation.
|
| 3 |
+
|
| 4 |
+
Wraps an Azure OpenAI-compatible chat/completions call and returns:
|
| 5 |
+
- content: the generated text
|
| 6 |
+
- tokens: prompt / completion / cached / total
|
| 7 |
+
- energy_kwh, co2_grams: environmental impact for the call
|
| 8 |
+
|
| 9 |
+
CO2 emissions are estimated with `ecologits`, which uses a model registry
|
| 10 |
+
(parameter counts, hardware assumptions) plus output token count and request
|
| 11 |
+
latency to compute global warming potential (kgCO2eq) and energy (kWh).
|
| 12 |
+
We chose `ecologits` over `codecarbon` because the LLM runs on a remote
|
| 13 |
+
endpoint — `codecarbon` measures local process energy and would only count
|
| 14 |
+
the client overhead, not the inference itself.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import time
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
import requests
|
| 22 |
+
from fastapi import HTTPException
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from ecologits.tracers.utils import llm_impacts
|
| 28 |
+
_ECOLOGITS_AVAILABLE = True
|
| 29 |
+
except ImportError:
|
| 30 |
+
_ECOLOGITS_AVAILABLE = False
|
| 31 |
+
logger.warning("ecologits not installed — CO2 emission will be reported as None.")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _to_scalar(value) -> Optional[float]:
|
| 35 |
+
"""
|
| 36 |
+
Normalize an ecologits impact value to a single float.
|
| 37 |
+
|
| 38 |
+
ecologits returns either a plain float or a RangeValue(min, max) depending
|
| 39 |
+
on the model registry entry. For RangeValue we return the midpoint so a
|
| 40 |
+
single representative number flows through the API/UI.
|
| 41 |
+
"""
|
| 42 |
+
if value is None:
|
| 43 |
+
return None
|
| 44 |
+
if hasattr(value, "min") and hasattr(value, "max"):
|
| 45 |
+
return (float(value.min) + float(value.max)) / 2.0
|
| 46 |
+
try:
|
| 47 |
+
return float(value)
|
| 48 |
+
except (TypeError, ValueError):
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def call_llm(
|
| 53 |
+
prompt: str,
|
| 54 |
+
*,
|
| 55 |
+
endpoint_url: str,
|
| 56 |
+
api_key: str,
|
| 57 |
+
model: str,
|
| 58 |
+
max_completion_tokens: int = 512,
|
| 59 |
+
temperature: float = 0.7,
|
| 60 |
+
top_p: float = 0.95,
|
| 61 |
+
provider: str = "openai",
|
| 62 |
+
timeout: Optional[float] = None,
|
| 63 |
+
) -> dict:
|
| 64 |
+
"""
|
| 65 |
+
Call an Azure OpenAI-compatible chat/completions endpoint and return the
|
| 66 |
+
response together with token counts and CO2 emission estimate.
|
| 67 |
+
|
| 68 |
+
Returns a dict:
|
| 69 |
+
{
|
| 70 |
+
"content": str,
|
| 71 |
+
"tokens": {
|
| 72 |
+
"prompt": int,
|
| 73 |
+
"completion": int,
|
| 74 |
+
"cached": int,
|
| 75 |
+
"total": int,
|
| 76 |
+
},
|
| 77 |
+
"energy_kwh": float | None,
|
| 78 |
+
"co2_grams": float | None,
|
| 79 |
+
"latency_s": float,
|
| 80 |
+
}
|
| 81 |
+
"""
|
| 82 |
+
headers = {
|
| 83 |
+
"api-key": api_key,
|
| 84 |
+
"Content-Type": "application/json",
|
| 85 |
+
}
|
| 86 |
+
payload = {
|
| 87 |
+
"model": model,
|
| 88 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 89 |
+
"max_completion_tokens": max_completion_tokens,
|
| 90 |
+
"temperature": temperature,
|
| 91 |
+
"top_p": top_p,
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
start = time.perf_counter()
|
| 95 |
+
try:
|
| 96 |
+
resp = requests.post(endpoint_url, headers=headers, json=payload, timeout=timeout)
|
| 97 |
+
resp.raise_for_status()
|
| 98 |
+
data = resp.json()
|
| 99 |
+
except requests.exceptions.HTTPError as e:
|
| 100 |
+
logger.error(f"LLM API call failed: {e} — {resp.text}")
|
| 101 |
+
raise HTTPException(status_code=503, detail=f"LLM service unavailable: {str(e)}")
|
| 102 |
+
except (requests.exceptions.JSONDecodeError, ValueError):
|
| 103 |
+
logger.error(
|
| 104 |
+
f"LLM API returned non-JSON response (status {resp.status_code}): {repr(resp.text)}"
|
| 105 |
+
)
|
| 106 |
+
raise HTTPException(status_code=502, detail="LLM service returned an invalid response")
|
| 107 |
+
latency_s = time.perf_counter() - start
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
content = data["choices"][0]["message"]["content"].strip()
|
| 111 |
+
except (KeyError, IndexError) as e:
|
| 112 |
+
logger.error(f"Unexpected LLM response format: {e} — body: {data!r}")
|
| 113 |
+
raise HTTPException(status_code=502, detail="Unexpected response from LLM service")
|
| 114 |
+
|
| 115 |
+
usage = data.get("usage") or {}
|
| 116 |
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
| 117 |
+
completion_tokens = usage.get("completion_tokens", 0)
|
| 118 |
+
total_tokens = usage.get("total_tokens", prompt_tokens + completion_tokens)
|
| 119 |
+
|
| 120 |
+
prompt_details = usage.get("prompt_tokens_details") or {}
|
| 121 |
+
cached_tokens = prompt_details.get("cached_tokens", usage.get("cached_tokens", 0))
|
| 122 |
+
|
| 123 |
+
energy_kwh: Optional[float] = None
|
| 124 |
+
co2_grams: Optional[float] = None
|
| 125 |
+
if _ECOLOGITS_AVAILABLE:
|
| 126 |
+
try:
|
| 127 |
+
impacts = llm_impacts(
|
| 128 |
+
provider=provider,
|
| 129 |
+
model_name=model,
|
| 130 |
+
output_token_count=completion_tokens,
|
| 131 |
+
request_latency=latency_s,
|
| 132 |
+
)
|
| 133 |
+
if impacts is not None:
|
| 134 |
+
energy_kwh = _to_scalar(impacts.energy.value)
|
| 135 |
+
# ecologits returns gwp in kgCO2eq; convert to grams
|
| 136 |
+
gwp_kg = _to_scalar(impacts.gwp.value)
|
| 137 |
+
co2_grams = gwp_kg * 1000.0 if gwp_kg is not None else None
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.warning(f"ecologits impact calc failed for model={model}: {e}")
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
"content": content,
|
| 143 |
+
"tokens": {
|
| 144 |
+
"prompt": prompt_tokens,
|
| 145 |
+
"completion": completion_tokens,
|
| 146 |
+
"cached": cached_tokens,
|
| 147 |
+
"total": total_tokens,
|
| 148 |
+
},
|
| 149 |
+
"energy_kwh": energy_kwh,
|
| 150 |
+
"co2_grams": co2_grams,
|
| 151 |
+
"latency_s": latency_s,
|
| 152 |
+
}
|
prompts/rag_prompt.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a helpful assistant. Answer the user's question based ONLY on the provided context.
|
| 2 |
+
If the context does not contain enough information to answer, say "I don't have enough information to answer this question."
|
| 3 |
+
Always be concise and factual.
|
| 4 |
+
|
| 5 |
+
You MUST respond in valid JSON with exactly two fields:
|
| 6 |
+
- "answer": your concise factual answer to the question
|
| 7 |
+
- "explanation": a brief explanation of your reasoning, describing which parts of the context you used and why they led to your answer
|
| 8 |
+
|
| 9 |
+
Do NOT include any text outside the JSON object.
|
| 10 |
+
|
| 11 |
+
Context:
|
| 12 |
+
{context}
|
| 13 |
+
|
| 14 |
+
Question: {question}
|
| 15 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.0
|
| 3 |
+
gradio==4.44.1
|
| 4 |
+
chromadb==0.5.0
|
| 5 |
+
huggingface-hub==0.25.0
|
| 6 |
+
langchain==0.3.0
|
| 7 |
+
langchain-community==0.3.0
|
| 8 |
+
langchain-huggingface==0.1.0
|
| 9 |
+
langchain-chroma==0.2.0
|
| 10 |
+
langchain-text-splitters==0.3.0
|
| 11 |
+
pypdf==4.3.0
|
| 12 |
+
python-multipart==0.0.9
|
| 13 |
+
pydantic==2.9.0
|
| 14 |
+
requests>=2.31.0
|
| 15 |
+
ecologits>=0.5.0
|
team.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
display_name: "Team Baseline"
|
| 2 |
+
members:
|
| 3 |
+
- name: "Member One"
|
| 4 |
+
email: "quincy.hsieh@example.com"
|
| 5 |
+
- name: "Member Two"
|
| 6 |
+
email: "muhammad.umer@example.com"
|
| 7 |
+
- name: "Member Three"
|
| 8 |
+
email: "francois.hu@@example.com"
|
| 9 |
+
- name: "Member Four"
|
| 10 |
+
email: "chloe.rolland@@example.com"
|
| 11 |
+
- name: "Member Five"
|
| 12 |
+
email: "bertille.tierny@@example.com"
|