lisapin commited on
Commit
d8b11da
·
2 Parent(s): 11596c2c173ec7

Initial commit

Browse files
Files changed (9) hide show
  1. README.md +16 -0
  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,4 +1,5 @@
1
  ---
 
2
  title: Hackathon
3
  emoji: 👁
4
  colorFrom: blue
@@ -11,3 +12,18 @@ pinned: false
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ <<<<<<< HEAD
3
  title: Hackathon
4
  emoji: 👁
5
  colorFrom: blue
 
12
  ---
13
 
14
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
+ =======
16
+ title: Example App Hackathon Gustave Eiffel 2026
17
+ emoji: 🤖
18
+ colorFrom: blue
19
+ colorTo: purple
20
+ sdk: gradio
21
+ sdk_version: 4.44.1
22
+ python_version: '3.11'
23
+ app_file: app.py
24
+ pinned: false
25
+ license: apache-2.0
26
+ ---
27
+
28
+ # 🗼 RAG Chat API Sample Code for Gustave Eiffel Hackathon 2026
29
+ >>>>>>> source/main
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: "Undefined Team Name"
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"