Melissa Mehaoued commited on
Commit
e2bcbe7
·
2 Parent(s): 0cade06dfdf6d5

Pull code from source repo

Browse files
Files changed (9) hide show
  1. README.md +285 -8
  2. README_HF.md +172 -0
  3. app.py +598 -0
  4. config.json +14 -0
  5. ingest_train_data.py +197 -0
  6. llm.py +152 -0
  7. prompts/rag_prompt.txt +15 -0
  8. requirements.txt +15 -0
  9. team.yml +12 -0
README.md CHANGED
@@ -1,13 +1,290 @@
1
- ---
2
- title: Hackathon IA Team
3
- emoji: 🐢
4
- colorFrom: gray
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"