Commit ·
8f6bb93
1
Parent(s): 41e79c8
live evaluations
Browse files- .dockerignore +12 -0
- Dockerfile +21 -15
- README.md +81 -3
- app.py +164 -59
- requirements.txt +3 -7
- src/agent.py +19 -16
- src/embeddings.py +33 -0
- src/evaluator.py +105 -0
- src/file_processor.py +17 -42
- src/main.py +112 -40
- src/prefetch_models.py +14 -0
- src/rag_engine.py +15 -33
- start.sh +24 -0
- tests/evaluate.py +213 -85
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.git/
|
| 6 |
+
.gitignore
|
| 7 |
+
.env
|
| 8 |
+
*.log
|
| 9 |
+
evaluation_report.csv
|
| 10 |
+
.DS_Store
|
| 11 |
+
tests/
|
| 12 |
+
assets/
|
Dockerfile
CHANGED
|
@@ -1,23 +1,29 @@
|
|
| 1 |
-
#
|
| 2 |
FROM python:3.10-slim
|
| 3 |
|
| 4 |
-
#
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
COPY requirements.txt .
|
| 9 |
|
| 10 |
-
# Install
|
| 11 |
-
|
|
|
|
| 12 |
|
| 13 |
-
# Copy the
|
| 14 |
-
COPY . .
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
|
| 19 |
-
#
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 23 |
-
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
|
|
| 1 |
+
# Docker image for HuggingFace Spaces (SDK: docker, app_port: 7860)
|
| 2 |
FROM python:3.10-slim
|
| 3 |
|
| 4 |
+
# HF Spaces run containers as a non-root user; create one with a writable home
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
+
USER user
|
| 7 |
+
|
| 8 |
+
ENV HOME=/home/user \
|
| 9 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 10 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 11 |
+
PYTHONUNBUFFERED=1 \
|
| 12 |
+
TOKENIZERS_PARALLELISM=false
|
| 13 |
|
| 14 |
+
WORKDIR /app
|
|
|
|
| 15 |
|
| 16 |
+
# Install Python dependencies
|
| 17 |
+
COPY --chown=user:user requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 19 |
|
| 20 |
+
# Copy the application code
|
| 21 |
+
COPY --chown=user:user . .
|
| 22 |
|
| 23 |
+
# Bake the embedding + NLI models into the image (fast, offline cold starts)
|
| 24 |
+
RUN python -m src.prefetch_models
|
| 25 |
|
| 26 |
+
# Gradio UI (public) runs on 7860; FastAPI backend runs internally on 8000
|
| 27 |
+
EXPOSE 7860
|
| 28 |
|
| 29 |
+
CMD ["bash", "start.sh"]
|
|
|
README.md
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
|
|
| 1 |
# Agentic RAG Knowledge Search
|
| 2 |
|
| 3 |
An Autonomous AI Microservice with Hybrid Retrieval & Self-Evaluation. Built with FastAPI, LangChain, Docker, and Google Gemini.
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
## Overview
|
| 6 |
|
| 7 |
This project is a Production-Grade AI Microservice designed to solve the "Knowledge Silo" problem. Unlike traditional RAG systems that only look at internal documents, this Agentic System intelligently decides where to find the answer.
|
|
@@ -33,6 +40,35 @@ The system follows a Hybrid RAG architecture. The Agent acts as the central brai
|
|
| 33 |
|
| 34 |
- REST API: Fully documented API using FastAPI.
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
## Demo & Outputs
|
| 37 |
|
| 38 |
1. Interactive API (Swagger UI)
|
|
@@ -58,7 +94,13 @@ A generated CSV report scoring the agent's performance against ground truth data
|
|
| 58 |
- src/rag_engine.py: Handles PDF ingestion and Vector Database (FAISS).
|
| 59 |
- src/agent.py: Defines the Agent, Tools, and LangChain logic.
|
| 60 |
- src/main.py: The FastAPI server entry point.
|
| 61 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
### Prerequisites
|
| 64 |
|
|
@@ -73,8 +115,10 @@ A generated CSV report scoring the agent's performance against ground truth data
|
|
| 73 |
|
| 74 |
2. Configure Environment
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
```GOOGLE_API_KEY=your_actual_api_key_here```
|
| 79 |
|
| 80 |
3. Add Data
|
|
@@ -109,6 +153,40 @@ A generated CSV report scoring the agent's performance against ground truth data
|
|
| 109 |
|
| 110 |
This isolates the application and ensures it runs consistently on any machine.
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
## Running Evaluations
|
| 113 |
|
| 114 |
This project prioritizes reliability. You can run the evaluation suite to test the agent against a "Golden Dataset" of questions and ground truths.
|
|
|
|
| 1 |
+
|
| 2 |
# Agentic RAG Knowledge Search
|
| 3 |
|
| 4 |
An Autonomous AI Microservice with Hybrid Retrieval & Self-Evaluation. Built with FastAPI, LangChain, Docker, and Google Gemini.
|
| 5 |
|
| 6 |
+
> **Deploying on HuggingFace Spaces:** create a **Docker** Space and push this repo. No API-key
|
| 7 |
+
> secret is required — the app uses **Bring Your Own Key (BYOK)**: each visitor enters their own
|
| 8 |
+
> Google Gemini key in the UI, so the public demo never spends your quota. The container runs the
|
| 9 |
+
> FastAPI backend (internal, port 8000) and the Gradio UI (public, port 7860) together via
|
| 10 |
+
> `start.sh`. Models are baked into the image at build time, so cold starts are fast.
|
| 11 |
+
|
| 12 |
## Overview
|
| 13 |
|
| 14 |
This project is a Production-Grade AI Microservice designed to solve the "Knowledge Silo" problem. Unlike traditional RAG systems that only look at internal documents, this Agentic System intelligently decides where to find the answer.
|
|
|
|
| 40 |
|
| 41 |
- REST API: Fully documented API using FastAPI.
|
| 42 |
|
| 43 |
+
- User Document Upload: Users can upload their own files (PDF, DOCX, TXT, MD, CSV) at runtime. Uploaded documents are indexed instantly and searched first; if nothing is uploaded, the agent falls back to the built-in legal/policy document.
|
| 44 |
+
|
| 45 |
+
- Live, No-Cost Evaluation Metrics: Every answer is scored in real time using local models only (no extra API calls) — see [Live Evaluation Metrics](#live-evaluation-metrics) below.
|
| 46 |
+
|
| 47 |
+
- Single-Image Deployment: The FastAPI backend and Gradio UI run together from one Docker image (`start.sh`), ready for HuggingFace Spaces.
|
| 48 |
+
|
| 49 |
+
## User Document Upload
|
| 50 |
+
|
| 51 |
+
The Gradio UI includes an upload panel. Users can drag in one or more files and click **Process & Index Files**.
|
| 52 |
+
|
| 53 |
+
- Supported formats: `.pdf`, `.docx`, `.txt`, `.md`, `.csv`
|
| 54 |
+
- Multiple files can be uploaded; new uploads are merged into the existing index.
|
| 55 |
+
- **Clear Uploaded Documents** resets the index back to the built-in default.
|
| 56 |
+
- If no files are uploaded, the agent uses the bundled `data/policy.pdf` as the default knowledge base.
|
| 57 |
+
|
| 58 |
+
## Live Evaluation Metrics
|
| 59 |
+
|
| 60 |
+
After every response, three metrics are computed **locally — no extra LLM/API calls** — and shown in the UI. This keeps hallucination/quality monitoring free and fast, even on CPU-only HuggingFace Spaces.
|
| 61 |
+
|
| 62 |
+
| Metric | Always shown? | How it works | What it catches |
|
| 63 |
+
|---|---|---|---|
|
| 64 |
+
| **Faithfulness** | Yes | NLI entailment (`cross-encoder/nli-deberta-v3-small`): each answer sentence is checked for *entailment* against the best-matching source passage it actually used (documents **or** web results). | Hallucinations and contradictions — not just topic drift. A claim that contradicts the source scores near 0. |
|
| 65 |
+
| **Answer Relevance** | Yes | Cosine similarity between the question and the answer (`all-MiniLM-L6-v2`). Needs no reference. | Off-topic or evasive answers. |
|
| 66 |
+
| **Accuracy** | Only with a reference | ROUGE-L F1 between the answer and a user-supplied reference answer. | Drift from a known-correct answer. |
|
| 67 |
+
|
| 68 |
+
> **Why NLI instead of plain similarity?** Cosine similarity measures *topical* overlap, so "the treaty can be terminated" and "the treaty cannot be terminated" score nearly identically despite opposite meaning. The NLI model checks logical *entailment*, so it correctly flags contradictions as unfaithful.
|
| 69 |
+
|
| 70 |
+
The offline `tests/evaluate.py` pipeline additionally uses an LLM-as-a-Judge for a second opinion against a golden dataset (see [Running Evaluations](#running-evaluations)).
|
| 71 |
+
|
| 72 |
## Demo & Outputs
|
| 73 |
|
| 74 |
1. Interactive API (Swagger UI)
|
|
|
|
| 94 |
- src/rag_engine.py: Handles PDF ingestion and Vector Database (FAISS).
|
| 95 |
- src/agent.py: Defines the Agent, Tools, and LangChain logic.
|
| 96 |
- src/main.py: The FastAPI server entry point.
|
| 97 |
+
- src/file_processor.py: Indexes user-uploaded files (PDF/DOCX/TXT/MD/CSV) at runtime.
|
| 98 |
+
- src/embeddings.py: Shared, single-load embedding model reused by RAG and the evaluator.
|
| 99 |
+
- src/evaluator.py: Local evaluation metrics (NLI faithfulness, relevance, accuracy).
|
| 100 |
+
- src/prefetch_models.py: Downloads models at image-build time for fast cold starts.
|
| 101 |
+
- app.py: The Gradio user interface (chat, file upload, live metrics).
|
| 102 |
+
- start.sh: Launches the FastAPI backend and Gradio UI together (used by Docker).
|
| 103 |
+
- data/: Place your default PDF documents here. (used when user didn't upload any files)
|
| 104 |
|
| 105 |
### Prerequisites
|
| 106 |
|
|
|
|
| 115 |
|
| 116 |
2. Configure Environment
|
| 117 |
|
| 118 |
+
The app uses **BYOK** — you enter your Gemini key directly in the UI, so no `.env` is needed to chat.
|
| 119 |
+
|
| 120 |
+
A `.env` file is only required to run the **offline evaluation** suite (`tests/evaluate.py`):
|
| 121 |
+
|
| 122 |
```GOOGLE_API_KEY=your_actual_api_key_here```
|
| 123 |
|
| 124 |
3. Add Data
|
|
|
|
| 153 |
|
| 154 |
This isolates the application and ensures it runs consistently on any machine.
|
| 155 |
|
| 156 |
+
6. Option C: Run the full app (UI + API together)
|
| 157 |
+
|
| 158 |
+
To run the Gradio UI and FastAPI backend together exactly as they run in the container:
|
| 159 |
+
|
| 160 |
+
```bash start.sh```
|
| 161 |
+
|
| 162 |
+
Then open the UI at http://localhost:7860 (the API stays internal on port 8000).
|
| 163 |
+
To run them separately during development, use two terminals: `python -m src.main` and `python app.py`.
|
| 164 |
+
|
| 165 |
+
## Deploying on HuggingFace Spaces
|
| 166 |
+
|
| 167 |
+
This repo is configured as a **Docker** Space (see the YAML frontmatter at the top of this file).
|
| 168 |
+
|
| 169 |
+
1. Create a new Space → choose **Docker** as the SDK.
|
| 170 |
+
2. Push this repository to the Space.
|
| 171 |
+
3. HuggingFace builds the image and serves the Gradio UI at the Space URL.
|
| 172 |
+
|
| 173 |
+
No API-key secret is needed: the app uses **Bring Your Own Key (BYOK)** — each visitor enters their own Google Gemini key in the UI (see below).
|
| 174 |
+
|
| 175 |
+
The container runs the FastAPI backend (internal, port 8000) and the Gradio UI (public, port 7860) together via `start.sh`. The embedding and NLI models are baked into the image at build time, so cold starts are fast and require no network access for models.
|
| 176 |
+
|
| 177 |
+
## API Key Strategy for Public Deployment
|
| 178 |
+
|
| 179 |
+
The Gemini free tier allows roughly **1,500 requests/day**, and each user question costs **2 calls** (one to route, one to answer) — about **750 questions/day total, shared across everyone**. If you publish a Space using your personal key, public traffic will exhaust it quickly and run on *your* quota.
|
| 180 |
+
|
| 181 |
+
**This app uses Bring Your Own Key (BYOK).** Each visitor enters their own Google Gemini API key in the UI:
|
| 182 |
+
|
| 183 |
+
- The key is sent only with that visitor's requests and is **never stored** on the server.
|
| 184 |
+
- A new free key takes seconds to create at [Google AI Studio](https://aistudio.google.com/apikey).
|
| 185 |
+
- The public Space therefore costs **you nothing** and can never exhaust your personal quota.
|
| 186 |
+
- A question cannot be submitted until a key is provided; an invalid key returns a clear error.
|
| 187 |
+
|
| 188 |
+
This is the standard pattern for public LLM demos. (Alternatives, if you ever want them: keep your own key as a Space secret with rate limiting, make the Space private, or upgrade to a paid Gemini plan.)
|
| 189 |
+
|
| 190 |
## Running Evaluations
|
| 191 |
|
| 192 |
This project prioritizes reliability. You can run the evaluation suite to test the agent against a "Golden Dataset" of questions and ground truths.
|
app.py
CHANGED
|
@@ -1,13 +1,9 @@
|
|
| 1 |
import os
|
| 2 |
-
import logging
|
| 3 |
import gradio as gr
|
| 4 |
import requests
|
| 5 |
|
| 6 |
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 7 |
|
| 8 |
-
logging.basicConfig(level=logging.INFO)
|
| 9 |
-
logger = logging.getLogger(__name__)
|
| 10 |
-
|
| 11 |
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://127.0.0.1:8000")
|
| 12 |
CHAT_ENDPOINT = f"{FASTAPI_URL}/chat"
|
| 13 |
UPLOAD_ENDPOINT = f"{FASTAPI_URL}/upload"
|
|
@@ -15,6 +11,18 @@ RESET_ENDPOINT = f"{FASTAPI_URL}/reset"
|
|
| 15 |
|
| 16 |
SUPPORTED_TYPES = [".pdf", ".docx", ".txt", ".md", ".csv"]
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
def upload_files(files) -> str:
|
| 20 |
if not files:
|
|
@@ -23,10 +31,8 @@ def upload_files(files) -> str:
|
|
| 23 |
multipart = []
|
| 24 |
for f in files:
|
| 25 |
path = f if isinstance(f, str) else f.name
|
| 26 |
-
filename = os.path.basename(path)
|
| 27 |
with open(path, "rb") as fp:
|
| 28 |
-
multipart.append(("files", (
|
| 29 |
-
|
| 30 |
resp = requests.post(UPLOAD_ENDPOINT, files=multipart, timeout=120)
|
| 31 |
resp.raise_for_status()
|
| 32 |
return resp.json().get("status", "Files processed.")
|
|
@@ -43,101 +49,200 @@ def reset_documents() -> str:
|
|
| 43 |
return f"Reset failed: {e}"
|
| 44 |
|
| 45 |
|
| 46 |
-
def
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
return "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
if not message.strip():
|
| 57 |
-
return chat_history, "Please enter a question."
|
| 58 |
try:
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
|
|
|
| 66 |
chat_history.append({"role": "user", "content": message})
|
| 67 |
-
chat_history.append({"role": "assistant", "content":
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
except requests.exceptions.RequestException as e:
|
| 70 |
error = f"Request failed: {e}"
|
| 71 |
chat_history.append({"role": "user", "content": message})
|
| 72 |
chat_history.append({"role": "assistant", "content": f"ERROR: {error}"})
|
| 73 |
-
return chat_history, error
|
| 74 |
|
| 75 |
|
| 76 |
-
def clear_chat() -> tuple[list, str]:
|
| 77 |
-
return [], ""
|
| 78 |
|
| 79 |
|
| 80 |
-
# --- UI ---
|
| 81 |
with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
| 82 |
gr.Markdown("# Agentic RAG Knowledge Search")
|
| 83 |
gr.Markdown(
|
| 84 |
-
"Upload your documents
|
| 85 |
-
"The agent searches your files first, then
|
|
|
|
| 86 |
)
|
| 87 |
|
| 88 |
-
# File upload panel
|
| 89 |
with gr.Group():
|
| 90 |
-
gr.Markdown(
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
with gr.Row():
|
| 99 |
-
upload_btn = gr.Button("Process Files", variant="primary")
|
| 100 |
reset_btn = gr.Button("Clear Uploaded Documents", variant="secondary")
|
| 101 |
-
upload_status = gr.Textbox(label="Upload Status", interactive=False, lines=2
|
|
|
|
| 102 |
|
| 103 |
gr.Markdown("---")
|
| 104 |
|
| 105 |
-
# Chat panel
|
| 106 |
with gr.Group():
|
| 107 |
-
gr.Markdown(
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
height=420,
|
| 111 |
)
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
user_input = gr.Textbox(
|
| 114 |
placeholder="Ask anything about your documents or the web...",
|
| 115 |
-
|
| 116 |
-
lines=2,
|
| 117 |
-
scale=4,
|
| 118 |
)
|
| 119 |
-
submit_btn = gr.Button("Submit", variant="primary", scale=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
with gr.Row():
|
| 121 |
-
clear_btn = gr.Button("Clear Chat", variant="secondary")
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
# Wiring
|
| 125 |
upload_btn.click(fn=upload_files, inputs=[file_input], outputs=[upload_status])
|
| 126 |
reset_btn.click(fn=reset_documents, inputs=[], outputs=[upload_status])
|
| 127 |
|
| 128 |
submit_btn.click(
|
| 129 |
fn=process_query,
|
| 130 |
-
inputs=[user_input, chatbot],
|
| 131 |
-
outputs=[chatbot, status_output],
|
| 132 |
).then(fn=lambda: "", inputs=[], outputs=[user_input])
|
| 133 |
|
| 134 |
user_input.submit(
|
| 135 |
fn=process_query,
|
| 136 |
-
inputs=[user_input, chatbot],
|
| 137 |
-
outputs=[chatbot, status_output],
|
| 138 |
).then(fn=lambda: "", inputs=[], outputs=[user_input])
|
| 139 |
|
| 140 |
-
clear_btn.click(fn=clear_chat, inputs=[], outputs=[chatbot, status_output])
|
| 141 |
|
| 142 |
if __name__ == "__main__":
|
| 143 |
-
demo.launch(server_name="0.0.0.0", server_port=7860, share=False, theme=gr.themes.Soft())
|
|
|
|
| 1 |
import os
|
|
|
|
| 2 |
import gradio as gr
|
| 3 |
import requests
|
| 4 |
|
| 5 |
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://127.0.0.1:8000")
|
| 8 |
CHAT_ENDPOINT = f"{FASTAPI_URL}/chat"
|
| 9 |
UPLOAD_ENDPOINT = f"{FASTAPI_URL}/upload"
|
|
|
|
| 11 |
|
| 12 |
SUPPORTED_TYPES = [".pdf", ".docx", ".txt", ".md", ".csv"]
|
| 13 |
|
| 14 |
+
CSS = """
|
| 15 |
+
#submit-btn { margin-top: auto; margin-bottom: auto; height: 80px; }
|
| 16 |
+
#input-row { align-items: center; }
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
SOURCE_LABELS = {
|
| 20 |
+
"rag": "Uploaded Documents (RAG)",
|
| 21 |
+
"web": "Web Search",
|
| 22 |
+
"rag+web": "Documents + Web Search",
|
| 23 |
+
"unknown": "Unknown",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
|
| 27 |
def upload_files(files) -> str:
|
| 28 |
if not files:
|
|
|
|
| 31 |
multipart = []
|
| 32 |
for f in files:
|
| 33 |
path = f if isinstance(f, str) else f.name
|
|
|
|
| 34 |
with open(path, "rb") as fp:
|
| 35 |
+
multipart.append(("files", (os.path.basename(path), fp.read(), "application/octet-stream")))
|
|
|
|
| 36 |
resp = requests.post(UPLOAD_ENDPOINT, files=multipart, timeout=120)
|
| 37 |
resp.raise_for_status()
|
| 38 |
return resp.json().get("status", "Files processed.")
|
|
|
|
| 49 |
return f"Reset failed: {e}"
|
| 50 |
|
| 51 |
|
| 52 |
+
def _bar(value: float, width: int = 12) -> str:
|
| 53 |
+
filled = round(value * width)
|
| 54 |
+
return "█" * filled + "░" * (width - filled)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _fmt(label: str, value: float, hint: str) -> str:
|
| 58 |
+
return f"**{label}** \n`{_bar(value)} {value:.2f} / 1.0` \n_{hint}_\n"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _faith_hint(score: float, source: str) -> str:
|
| 62 |
+
src = SOURCE_LABELS.get(source, source)
|
| 63 |
+
what = "web results" if source == "web" else "retrieved documents"
|
| 64 |
+
if score >= 0.75:
|
| 65 |
+
return f"Answer is well-grounded in the {what}"
|
| 66 |
+
if score >= 0.50:
|
| 67 |
+
return f"Answer is mostly grounded in the {what}, minor unsupported details possible"
|
| 68 |
+
return f"Low grounding — answer may contain content not present in the {what}"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _relevance_hint(score: float) -> str:
|
| 72 |
+
if score >= 0.70:
|
| 73 |
+
return "Answer directly addresses the question"
|
| 74 |
+
if score >= 0.45:
|
| 75 |
+
return "Answer is related but may not fully address the question"
|
| 76 |
+
return "Answer may be off-topic or incomplete"
|
| 77 |
+
|
| 78 |
|
| 79 |
+
def _accuracy_hint(score: float) -> str:
|
| 80 |
+
if score >= 0.75:
|
| 81 |
+
return "Strong match with your reference answer"
|
| 82 |
+
if score >= 0.40:
|
| 83 |
+
return "Partial match — some key points differ from your reference"
|
| 84 |
+
return "Low overlap with your reference answer"
|
| 85 |
|
| 86 |
+
|
| 87 |
+
def _format_metrics(source: str, faithfulness, answer_relevance, accuracy) -> str:
|
| 88 |
+
if faithfulness is None and answer_relevance is None:
|
| 89 |
+
return ""
|
| 90 |
+
|
| 91 |
+
src_label = SOURCE_LABELS.get(source, source)
|
| 92 |
+
lines = [f"**Answer source: {src_label}**", "---"]
|
| 93 |
+
|
| 94 |
+
if faithfulness is not None:
|
| 95 |
+
faith_label = "Faithfulness — grounded in documents" if source != "web" else "Faithfulness — grounded in web results"
|
| 96 |
+
lines.append(_fmt(faith_label, faithfulness, _faith_hint(faithfulness, source)))
|
| 97 |
+
|
| 98 |
+
if answer_relevance is not None:
|
| 99 |
+
lines.append(_fmt("Answer Relevance — addresses the question", answer_relevance, _relevance_hint(answer_relevance)))
|
| 100 |
+
|
| 101 |
+
if accuracy is not None:
|
| 102 |
+
lines.append(_fmt("Accuracy — matches your reference", accuracy, _accuracy_hint(accuracy)))
|
| 103 |
+
else:
|
| 104 |
+
lines.append("_Accuracy: provide a reference answer above to see this score._")
|
| 105 |
+
|
| 106 |
+
return "\n".join(lines)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def process_query(message: str, api_key: str, reference: str, chat_history: list) -> tuple[list, str, str]:
|
| 110 |
+
if not api_key.strip():
|
| 111 |
+
return chat_history, "Enter your Google Gemini API key above to start.", ""
|
| 112 |
if not message.strip():
|
| 113 |
+
return chat_history, "Please enter a question.", ""
|
| 114 |
try:
|
| 115 |
+
payload = {"query": message, "api_key": api_key.strip()}
|
| 116 |
+
if reference.strip():
|
| 117 |
+
payload["reference"] = reference.strip()
|
| 118 |
+
|
| 119 |
+
resp = requests.post(CHAT_ENDPOINT, json=payload, timeout=180)
|
| 120 |
+
|
| 121 |
+
if resp.status_code in (400, 401, 429):
|
| 122 |
+
detail = resp.json().get("detail", "Request could not be completed.")
|
| 123 |
+
chat_history.append({"role": "user", "content": message})
|
| 124 |
+
chat_history.append({"role": "assistant", "content": f"Sorry, I can't respond right now:\n\n{detail}"})
|
| 125 |
+
return chat_history, detail, ""
|
| 126 |
|
| 127 |
+
resp.raise_for_status()
|
| 128 |
+
data = resp.json()
|
| 129 |
+
response_text = data.get("response", "")
|
| 130 |
+
source = data.get("source", "unknown")
|
| 131 |
|
| 132 |
+
src_label = SOURCE_LABELS.get(source, source)
|
| 133 |
chat_history.append({"role": "user", "content": message})
|
| 134 |
+
chat_history.append({"role": "assistant", "content": f"{response_text}\n\n--- Source: {src_label} ---"})
|
| 135 |
+
|
| 136 |
+
metrics_md = _format_metrics(
|
| 137 |
+
source,
|
| 138 |
+
data.get("faithfulness"),
|
| 139 |
+
data.get("answer_relevance"),
|
| 140 |
+
data.get("accuracy"),
|
| 141 |
+
)
|
| 142 |
+
return chat_history, f"Done — answered via {src_label}", metrics_md
|
| 143 |
+
|
| 144 |
except requests.exceptions.RequestException as e:
|
| 145 |
error = f"Request failed: {e}"
|
| 146 |
chat_history.append({"role": "user", "content": message})
|
| 147 |
chat_history.append({"role": "assistant", "content": f"ERROR: {error}"})
|
| 148 |
+
return chat_history, error, ""
|
| 149 |
|
| 150 |
|
| 151 |
+
def clear_chat() -> tuple[list, str, str]:
|
| 152 |
+
return [], "", ""
|
| 153 |
|
| 154 |
|
|
|
|
| 155 |
with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
| 156 |
gr.Markdown("# Agentic RAG Knowledge Search")
|
| 157 |
gr.Markdown(
|
| 158 |
+
"Upload your own documents and ask questions. "
|
| 159 |
+
"The agent searches your files via RAG first, then falls back to web search if needed. "
|
| 160 |
+
"**No files uploaded?** The agent uses a built-in legal/policy document as the default knowledge base."
|
| 161 |
)
|
| 162 |
|
|
|
|
| 163 |
with gr.Group():
|
| 164 |
+
gr.Markdown(
|
| 165 |
+
"### Your Gemini API Key (required)\n"
|
| 166 |
+
"This app uses **your own** Google Gemini key — it is sent only with your requests and never stored. "
|
| 167 |
+
"Get a free key at [Google AI Studio](https://aistudio.google.com/apikey)."
|
| 168 |
+
)
|
| 169 |
+
api_key_input = gr.Textbox(
|
| 170 |
+
label="Google Gemini API Key",
|
| 171 |
+
placeholder="Paste your API key here (starts with AIza...)",
|
| 172 |
+
type="password",
|
| 173 |
+
lines=1,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
with gr.Group():
|
| 177 |
+
gr.Markdown(
|
| 178 |
+
f"### Upload Documents\n"
|
| 179 |
+
f"Supported: **{', '.join(SUPPORTED_TYPES)}** — multiple files allowed, new uploads add to the index."
|
| 180 |
+
)
|
| 181 |
+
file_input = gr.File(label="Select Files", file_count="multiple", file_types=SUPPORTED_TYPES)
|
| 182 |
with gr.Row():
|
| 183 |
+
upload_btn = gr.Button("Process & Index Files", variant="primary")
|
| 184 |
reset_btn = gr.Button("Clear Uploaded Documents", variant="secondary")
|
| 185 |
+
upload_status = gr.Textbox(label="Upload Status", interactive=False, lines=2,
|
| 186 |
+
placeholder="Upload status will appear here...")
|
| 187 |
|
| 188 |
gr.Markdown("---")
|
| 189 |
|
|
|
|
| 190 |
with gr.Group():
|
| 191 |
+
gr.Markdown(
|
| 192 |
+
"### Ask a Question\n"
|
| 193 |
+
"> Press **Enter** or click **Submit**. The agent decides whether to search your documents, the web, or both."
|
|
|
|
| 194 |
)
|
| 195 |
+
chatbot = gr.Chatbot(label="Conversation", height=420)
|
| 196 |
+
|
| 197 |
+
with gr.Row(elem_id="input-row"):
|
| 198 |
user_input = gr.Textbox(
|
| 199 |
placeholder="Ask anything about your documents or the web...",
|
| 200 |
+
lines=2, scale=5, show_label=False, container=False,
|
|
|
|
|
|
|
| 201 |
)
|
| 202 |
+
submit_btn = gr.Button("Submit", variant="primary", scale=1, elem_id="submit-btn")
|
| 203 |
+
|
| 204 |
+
reference_input = gr.Textbox(
|
| 205 |
+
label="Reference Answer (optional)",
|
| 206 |
+
placeholder="Paste an expected correct answer here to also see an Accuracy score...",
|
| 207 |
+
lines=2,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
with gr.Row():
|
| 211 |
+
clear_btn = gr.Button("Clear Chat", variant="secondary", scale=1)
|
| 212 |
+
|
| 213 |
+
status_output = gr.Textbox(label="Status", interactive=False, lines=1,
|
| 214 |
+
placeholder="Status will appear here...")
|
| 215 |
+
|
| 216 |
+
gr.Markdown("---")
|
| 217 |
+
|
| 218 |
+
with gr.Group():
|
| 219 |
+
gr.Markdown(
|
| 220 |
+
"### Evaluation Metrics\n"
|
| 221 |
+
"Computed automatically after every response — **no extra API calls, no reference needed** for the first two.\n\n"
|
| 222 |
+
"| Metric | Always shown? | What it measures |\n"
|
| 223 |
+
"|---|---|---|\n"
|
| 224 |
+
"| **Faithfulness** | Yes | Is the answer grounded in the source it used? (docs or web results) |\n"
|
| 225 |
+
"| **Answer Relevance** | Yes | Does the answer actually address your question? |\n"
|
| 226 |
+
"| **Accuracy** | Only with reference | Does the answer match an expected correct answer? |"
|
| 227 |
+
)
|
| 228 |
+
metrics_output = gr.Markdown()
|
| 229 |
|
|
|
|
| 230 |
upload_btn.click(fn=upload_files, inputs=[file_input], outputs=[upload_status])
|
| 231 |
reset_btn.click(fn=reset_documents, inputs=[], outputs=[upload_status])
|
| 232 |
|
| 233 |
submit_btn.click(
|
| 234 |
fn=process_query,
|
| 235 |
+
inputs=[user_input, api_key_input, reference_input, chatbot],
|
| 236 |
+
outputs=[chatbot, status_output, metrics_output],
|
| 237 |
).then(fn=lambda: "", inputs=[], outputs=[user_input])
|
| 238 |
|
| 239 |
user_input.submit(
|
| 240 |
fn=process_query,
|
| 241 |
+
inputs=[user_input, api_key_input, reference_input, chatbot],
|
| 242 |
+
outputs=[chatbot, status_output, metrics_output],
|
| 243 |
).then(fn=lambda: "", inputs=[], outputs=[user_input])
|
| 244 |
|
| 245 |
+
clear_btn.click(fn=clear_chat, inputs=[], outputs=[chatbot, status_output, metrics_output])
|
| 246 |
|
| 247 |
if __name__ == "__main__":
|
| 248 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, share=False, theme=gr.themes.Soft(), css=CSS)
|
requirements.txt
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
|
| 2 |
fastapi>=0.115.0
|
| 3 |
uvicorn>=0.30.0
|
| 4 |
python-dotenv>=1.0.1
|
|
@@ -7,18 +6,15 @@ langchain-community>=0.3.7
|
|
| 7 |
langchain-core>=0.3.15
|
| 8 |
langchain-text-splitters>=0.3.2
|
| 9 |
langchain-google-genai>=2.0.4
|
| 10 |
-
|
| 11 |
-
google-generativeai==0.8.2
|
| 12 |
-
|
| 13 |
langgraph>=0.2.53
|
| 14 |
langchain-huggingface>=0.1.2
|
| 15 |
sentence-transformers>=3.2.0
|
|
|
|
| 16 |
faiss-cpu>=1.7.4
|
| 17 |
pypdf>=5.1.0
|
| 18 |
docx2txt>=0.8
|
| 19 |
-
|
|
|
|
| 20 |
pydantic>=2.9.0
|
| 21 |
requests>=2.32.0
|
| 22 |
-
datasets>=3.1.0
|
| 23 |
-
ddgs
|
| 24 |
gradio>=4.44.0
|
|
|
|
|
|
|
| 1 |
fastapi>=0.115.0
|
| 2 |
uvicorn>=0.30.0
|
| 3 |
python-dotenv>=1.0.1
|
|
|
|
| 6 |
langchain-core>=0.3.15
|
| 7 |
langchain-text-splitters>=0.3.2
|
| 8 |
langchain-google-genai>=2.0.4
|
|
|
|
|
|
|
|
|
|
| 9 |
langgraph>=0.2.53
|
| 10 |
langchain-huggingface>=0.1.2
|
| 11 |
sentence-transformers>=3.2.0
|
| 12 |
+
sentencepiece>=0.2.0
|
| 13 |
faiss-cpu>=1.7.4
|
| 14 |
pypdf>=5.1.0
|
| 15 |
docx2txt>=0.8
|
| 16 |
+
rouge-score>=0.1.2
|
| 17 |
+
ddgs
|
| 18 |
pydantic>=2.9.0
|
| 19 |
requests>=2.32.0
|
|
|
|
|
|
|
| 20 |
gradio>=4.44.0
|
src/agent.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 3 |
from langchain_core.tools import tool
|
| 4 |
from langchain_community.tools import DuckDuckGoSearchRun
|
|
@@ -9,19 +10,16 @@ from src.file_processor import FileProcessor
|
|
| 9 |
|
| 10 |
load_dotenv()
|
| 11 |
|
| 12 |
-
# --- Shared state ---
|
| 13 |
-
# file_processor is imported and mutated by main.py's /upload endpoint
|
| 14 |
file_processor = FileProcessor()
|
| 15 |
|
| 16 |
-
|
| 17 |
-
_PDF_PATH = os.path.join("data", "policy.pdf")
|
| 18 |
-
_fallback_kb = KnowledgeBase(pdf_path=_PDF_PATH)
|
| 19 |
try:
|
| 20 |
_fallback_kb.load_and_index()
|
| 21 |
except Exception as e:
|
| 22 |
print(f"Fallback KB skipped: {e}")
|
| 23 |
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
@tool
|
| 27 |
def lookup_documents(query: str) -> str:
|
|
@@ -31,25 +29,30 @@ def lookup_documents(query: str) -> str:
|
|
| 31 |
result = file_processor.retrieve(query)
|
| 32 |
if result:
|
| 33 |
return result
|
| 34 |
-
# Fall back to original KB when no uploads exist
|
| 35 |
return _fallback_kb.retrieve(query)
|
| 36 |
|
| 37 |
-
search_tool = DuckDuckGoSearchRun()
|
| 38 |
|
| 39 |
@tool
|
| 40 |
def search_web(query: str) -> str:
|
| 41 |
"""Search the web for current events, news, or general knowledge not in uploaded documents."""
|
| 42 |
try:
|
| 43 |
-
return
|
| 44 |
except Exception as e:
|
| 45 |
return f"Search failed: {e}"
|
| 46 |
|
| 47 |
-
# --- Agent factory ---
|
| 48 |
-
|
| 49 |
-
def get_agent_executor():
|
| 50 |
-
if not os.getenv("GOOGLE_API_KEY"):
|
| 51 |
-
raise ValueError("GOOGLE_API_KEY not found in .env file")
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
return create_react_agent(llm, [lookup_documents, search_web])
|
|
|
|
| 1 |
import os
|
| 2 |
+
from functools import lru_cache
|
| 3 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 4 |
from langchain_core.tools import tool
|
| 5 |
from langchain_community.tools import DuckDuckGoSearchRun
|
|
|
|
| 10 |
|
| 11 |
load_dotenv()
|
| 12 |
|
|
|
|
|
|
|
| 13 |
file_processor = FileProcessor()
|
| 14 |
|
| 15 |
+
_fallback_kb = KnowledgeBase(pdf_path=os.path.join("data", "policy.pdf"))
|
|
|
|
|
|
|
| 16 |
try:
|
| 17 |
_fallback_kb.load_and_index()
|
| 18 |
except Exception as e:
|
| 19 |
print(f"Fallback KB skipped: {e}")
|
| 20 |
|
| 21 |
+
_search_tool = DuckDuckGoSearchRun()
|
| 22 |
+
|
| 23 |
|
| 24 |
@tool
|
| 25 |
def lookup_documents(query: str) -> str:
|
|
|
|
| 29 |
result = file_processor.retrieve(query)
|
| 30 |
if result:
|
| 31 |
return result
|
|
|
|
| 32 |
return _fallback_kb.retrieve(query)
|
| 33 |
|
|
|
|
| 34 |
|
| 35 |
@tool
|
| 36 |
def search_web(query: str) -> str:
|
| 37 |
"""Search the web for current events, news, or general knowledge not in uploaded documents."""
|
| 38 |
try:
|
| 39 |
+
return _search_tool.run(query)
|
| 40 |
except Exception as e:
|
| 41 |
return f"Search failed: {e}"
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
@lru_cache(maxsize=32)
|
| 45 |
+
def get_agent_executor(api_key: str):
|
| 46 |
+
"""Build (and cache) a LangGraph agent for the given Gemini API key.
|
| 47 |
+
|
| 48 |
+
Each visitor supplies their own key (BYOK), so the LLM is created per key.
|
| 49 |
+
The shared tools, embeddings, and RAG index are module-level and reused.
|
| 50 |
+
Cached by key so repeat requests from the same user don't rebuild the graph."""
|
| 51 |
+
if not api_key or not api_key.strip():
|
| 52 |
+
raise ValueError("A Google Gemini API key is required.")
|
| 53 |
+
llm = ChatGoogleGenerativeAI(
|
| 54 |
+
model="gemini-2.0-flash",
|
| 55 |
+
temperature=0,
|
| 56 |
+
google_api_key=api_key.strip(),
|
| 57 |
+
)
|
| 58 |
return create_react_agent(llm, [lookup_documents, search_web])
|
src/embeddings.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared embedding model — loaded once and reused everywhere.
|
| 3 |
+
|
| 4 |
+
The same all-MiniLM-L6-v2 model is needed by:
|
| 5 |
+
- the RAG vector stores (via langchain's HuggingFaceEmbeddings)
|
| 6 |
+
- the evaluator's cosine-similarity metrics (via the raw SentenceTransformer)
|
| 7 |
+
|
| 8 |
+
Loading it a single time keeps memory and cold-start cost low on HF Spaces.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 12 |
+
|
| 13 |
+
_MODEL_NAME = "all-MiniLM-L6-v2"
|
| 14 |
+
_embeddings = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_embeddings() -> HuggingFaceEmbeddings:
|
| 18 |
+
"""Shared langchain embeddings object for FAISS vector stores."""
|
| 19 |
+
global _embeddings
|
| 20 |
+
if _embeddings is None:
|
| 21 |
+
_embeddings = HuggingFaceEmbeddings(model_name=_MODEL_NAME)
|
| 22 |
+
return _embeddings
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_sentence_transformer():
|
| 26 |
+
"""The underlying SentenceTransformer, reused by the evaluator.
|
| 27 |
+
Falls back to a direct load if langchain's internal attribute changes."""
|
| 28 |
+
embeddings = get_embeddings()
|
| 29 |
+
model = getattr(embeddings, "client", None)
|
| 30 |
+
if model is None:
|
| 31 |
+
from sentence_transformers import SentenceTransformer
|
| 32 |
+
model = SentenceTransformer(_MODEL_NAME)
|
| 33 |
+
return model
|
src/evaluator.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Local evaluation metrics — no LLM API calls, CPU-friendly for HF Spaces.
|
| 3 |
+
|
| 4 |
+
faithfulness : NLI entailment of each answer sentence against the best-matching
|
| 5 |
+
source passage (docs or web results it actually used).
|
| 6 |
+
Detects contradictions / unsupported claims, not just topic overlap.
|
| 7 |
+
Falls back to cosine similarity if the NLI model can't load.
|
| 8 |
+
answer_relevance: cosine similarity between question and answer — does the answer
|
| 9 |
+
address the question? Needs no reference.
|
| 10 |
+
accuracy : ROUGE-L F1 vs a user-supplied reference answer. Only when provided.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import logging
|
| 15 |
+
import numpy as np
|
| 16 |
+
from rouge_score import rouge_scorer
|
| 17 |
+
from sentence_transformers import util
|
| 18 |
+
from src.embeddings import get_sentence_transformer
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
_NLI_MODEL_NAME = "cross-encoder/nli-deberta-v3-small"
|
| 23 |
+
# Label index for "entailment" in this model's output (0=contradiction, 1=entailment, 2=neutral)
|
| 24 |
+
_ENTAILMENT_IDX = 1
|
| 25 |
+
|
| 26 |
+
_nli_model = None
|
| 27 |
+
_rouge = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# --- helpers ---------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
def _nli():
|
| 33 |
+
global _nli_model
|
| 34 |
+
if _nli_model is None:
|
| 35 |
+
from sentence_transformers import CrossEncoder
|
| 36 |
+
_nli_model = CrossEncoder(_NLI_MODEL_NAME)
|
| 37 |
+
return _nli_model
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _softmax(logits: np.ndarray) -> np.ndarray:
|
| 41 |
+
logits = np.atleast_2d(logits)
|
| 42 |
+
exp = np.exp(logits - logits.max(axis=1, keepdims=True))
|
| 43 |
+
return exp / exp.sum(axis=1, keepdims=True)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _cosine(text_a: str, text_b: str) -> float:
|
| 47 |
+
if not text_a.strip() or not text_b.strip():
|
| 48 |
+
return 0.0
|
| 49 |
+
m = get_sentence_transformer()
|
| 50 |
+
return round(float(util.cos_sim(
|
| 51 |
+
m.encode(text_a, convert_to_tensor=True),
|
| 52 |
+
m.encode(text_b, convert_to_tensor=True),
|
| 53 |
+
)), 3)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _split_sentences(text: str) -> list[str]:
|
| 57 |
+
parts = re.split(r"(?<=[.!?])\s+", text.strip())
|
| 58 |
+
return [p.strip() for p in parts if len(p.strip()) > 15]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _split_passages(context: str) -> list[str]:
|
| 62 |
+
# retrieve() joins passages with blank lines
|
| 63 |
+
parts = [p.strip() for p in context.split("\n\n") if p.strip()]
|
| 64 |
+
return parts or [context.strip()]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# --- metrics ---------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def faithfulness_score(answer: str, source_context: str) -> float:
|
| 70 |
+
"""Mean NLI entailment of answer claims given the source they were drawn from.
|
| 71 |
+
|
| 72 |
+
For each answer sentence we pick the most similar source passage (so the NLI
|
| 73 |
+
input stays short and on-point), then ask whether that passage entails the
|
| 74 |
+
sentence. Returns the mean entailment probability across sentences (0–1)."""
|
| 75 |
+
if not answer.strip() or not source_context.strip():
|
| 76 |
+
return 0.0
|
| 77 |
+
|
| 78 |
+
sentences = _split_sentences(answer) or [answer.strip()]
|
| 79 |
+
passages = _split_passages(source_context)
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
model = get_sentence_transformer()
|
| 83 |
+
pas_emb = model.encode(passages, convert_to_tensor=True)
|
| 84 |
+
sen_emb = model.encode(sentences, convert_to_tensor=True)
|
| 85 |
+
best_idx = util.cos_sim(sen_emb, pas_emb).argmax(dim=1).tolist()
|
| 86 |
+
pairs = [(passages[best_idx[i]], sentences[i]) for i in range(len(sentences))]
|
| 87 |
+
|
| 88 |
+
logits = _nli().predict(pairs)
|
| 89 |
+
entail = _softmax(np.asarray(logits))[:, _ENTAILMENT_IDX]
|
| 90 |
+
return round(float(entail.mean()), 3)
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.warning(f"NLI faithfulness unavailable ({e}); falling back to cosine.")
|
| 93 |
+
return _cosine(answer, source_context)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def answer_relevance_score(question: str, answer: str) -> float:
|
| 97 |
+
"""Does the answer address the question? Cosine similarity (0–1)."""
|
| 98 |
+
return _cosine(question, answer)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def accuracy_score(answer: str, reference: str) -> float:
|
| 102 |
+
"""ROUGE-L F1 vs a user-supplied reference answer (0–1)."""
|
| 103 |
+
if not answer.strip() or not reference.strip():
|
| 104 |
+
return 0.0
|
| 105 |
+
return round(_rouge.score(reference, answer)["rougeL"].fmeasure, 3)
|
src/file_processor.py
CHANGED
|
@@ -1,87 +1,66 @@
|
|
| 1 |
import logging
|
| 2 |
import os
|
| 3 |
-
from langchain_community.document_loaders import
|
| 4 |
-
PyPDFLoader,
|
| 5 |
-
TextLoader,
|
| 6 |
-
CSVLoader,
|
| 7 |
-
Docx2txtLoader,
|
| 8 |
-
)
|
| 9 |
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 10 |
from langchain_community.vectorstores import FAISS
|
| 11 |
-
from
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
SUPPORTED_EXTENSIONS = {".pdf", ".txt", ".md", ".csv", ".docx"}
|
| 16 |
|
| 17 |
|
| 18 |
-
def _loader_for(
|
| 19 |
-
ext = os.path.splitext(
|
| 20 |
if ext == ".pdf":
|
| 21 |
-
return PyPDFLoader(
|
| 22 |
if ext in (".txt", ".md"):
|
| 23 |
-
return TextLoader(
|
| 24 |
if ext == ".csv":
|
| 25 |
-
return CSVLoader(
|
| 26 |
if ext == ".docx":
|
| 27 |
-
return Docx2txtLoader(
|
| 28 |
return None
|
| 29 |
|
| 30 |
|
| 31 |
class FileProcessor:
|
| 32 |
-
def __init__(self
|
| 33 |
-
self.embeddings =
|
| 34 |
self.vector_store = None
|
| 35 |
self.status = "No files processed"
|
| 36 |
-
self._splitter = RecursiveCharacterTextSplitter(
|
| 37 |
-
chunk_size=1000, chunk_overlap=200
|
| 38 |
-
)
|
| 39 |
|
| 40 |
def process_files(self, file_paths: list[str]) -> str:
|
| 41 |
-
"""Index a list of file paths into the FAISS vector store."""
|
| 42 |
if not file_paths:
|
| 43 |
return "No files provided."
|
| 44 |
|
| 45 |
-
all_docs = []
|
| 46 |
-
skipped = []
|
| 47 |
-
|
| 48 |
for path in file_paths:
|
| 49 |
-
|
| 50 |
-
if ext not in SUPPORTED_EXTENSIONS:
|
| 51 |
skipped.append(os.path.basename(path))
|
| 52 |
continue
|
| 53 |
try:
|
| 54 |
-
|
| 55 |
-
docs = loader.load()
|
| 56 |
for d in docs:
|
| 57 |
d.metadata["source_file"] = os.path.basename(path)
|
| 58 |
all_docs.extend(docs)
|
| 59 |
-
logger.info(f"Loaded {len(docs)} page(s) from {os.path.basename(path)}")
|
| 60 |
except Exception as e:
|
| 61 |
logger.error(f"Failed to load {path}: {e}")
|
| 62 |
skipped.append(os.path.basename(path))
|
| 63 |
|
| 64 |
if not all_docs:
|
| 65 |
self.status = "No supported files could be loaded"
|
| 66 |
-
return (
|
| 67 |
-
f"Could not load any files. "
|
| 68 |
-
f"Supported types: {', '.join(sorted(SUPPORTED_EXTENSIONS))}. "
|
| 69 |
-
+ (f"Skipped: {', '.join(skipped)}" if skipped else "")
|
| 70 |
-
)
|
| 71 |
|
| 72 |
chunks = self._splitter.split_documents(all_docs)
|
| 73 |
-
|
| 74 |
if self.vector_store is None:
|
| 75 |
self.vector_store = FAISS.from_documents(chunks, self.embeddings)
|
| 76 |
else:
|
| 77 |
-
|
| 78 |
-
new_store = FAISS.from_documents(chunks, self.embeddings)
|
| 79 |
-
self.vector_store.merge_from(new_store)
|
| 80 |
|
| 81 |
file_count = len(file_paths) - len(skipped)
|
| 82 |
self.status = f"Indexed {len(chunks)} chunks from {file_count} file(s)"
|
| 83 |
note = f" (skipped: {', '.join(skipped)})" if skipped else ""
|
| 84 |
-
logger.info(self.status)
|
| 85 |
return f"Done: {self.status}{note}"
|
| 86 |
|
| 87 |
def retrieve(self, query: str, k: int = 4) -> str:
|
|
@@ -89,11 +68,8 @@ class FileProcessor:
|
|
| 89 |
return ""
|
| 90 |
try:
|
| 91 |
docs = self.vector_store.similarity_search(query, k=k)
|
| 92 |
-
if not docs:
|
| 93 |
-
return ""
|
| 94 |
return "\n\n".join(
|
| 95 |
-
f"[File: {d.metadata.get('source_file', '?')}] {d.page_content}"
|
| 96 |
-
for d in docs
|
| 97 |
)
|
| 98 |
except Exception as e:
|
| 99 |
logger.error(f"Retrieval error: {e}")
|
|
@@ -108,4 +84,3 @@ class FileProcessor:
|
|
| 108 |
def reset(self) -> None:
|
| 109 |
self.vector_store = None
|
| 110 |
self.status = "No files processed"
|
| 111 |
-
logger.info("FileProcessor reset")
|
|
|
|
| 1 |
import logging
|
| 2 |
import os
|
| 3 |
+
from langchain_community.document_loaders import PyPDFLoader, TextLoader, CSVLoader, Docx2txtLoader
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 5 |
from langchain_community.vectorstores import FAISS
|
| 6 |
+
from src.embeddings import get_embeddings
|
| 7 |
|
| 8 |
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
SUPPORTED_EXTENSIONS = {".pdf", ".txt", ".md", ".csv", ".docx"}
|
| 11 |
|
| 12 |
|
| 13 |
+
def _loader_for(path: str):
|
| 14 |
+
ext = os.path.splitext(path)[1].lower()
|
| 15 |
if ext == ".pdf":
|
| 16 |
+
return PyPDFLoader(path)
|
| 17 |
if ext in (".txt", ".md"):
|
| 18 |
+
return TextLoader(path, encoding="utf-8")
|
| 19 |
if ext == ".csv":
|
| 20 |
+
return CSVLoader(path)
|
| 21 |
if ext == ".docx":
|
| 22 |
+
return Docx2txtLoader(path)
|
| 23 |
return None
|
| 24 |
|
| 25 |
|
| 26 |
class FileProcessor:
|
| 27 |
+
def __init__(self):
|
| 28 |
+
self.embeddings = get_embeddings()
|
| 29 |
self.vector_store = None
|
| 30 |
self.status = "No files processed"
|
| 31 |
+
self._splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
|
|
|
|
|
|
|
| 32 |
|
| 33 |
def process_files(self, file_paths: list[str]) -> str:
|
|
|
|
| 34 |
if not file_paths:
|
| 35 |
return "No files provided."
|
| 36 |
|
| 37 |
+
all_docs, skipped = [], []
|
|
|
|
|
|
|
| 38 |
for path in file_paths:
|
| 39 |
+
if os.path.splitext(path)[1].lower() not in SUPPORTED_EXTENSIONS:
|
|
|
|
| 40 |
skipped.append(os.path.basename(path))
|
| 41 |
continue
|
| 42 |
try:
|
| 43 |
+
docs = _loader_for(path).load()
|
|
|
|
| 44 |
for d in docs:
|
| 45 |
d.metadata["source_file"] = os.path.basename(path)
|
| 46 |
all_docs.extend(docs)
|
|
|
|
| 47 |
except Exception as e:
|
| 48 |
logger.error(f"Failed to load {path}: {e}")
|
| 49 |
skipped.append(os.path.basename(path))
|
| 50 |
|
| 51 |
if not all_docs:
|
| 52 |
self.status = "No supported files could be loaded"
|
| 53 |
+
return f"Could not load any files. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
chunks = self._splitter.split_documents(all_docs)
|
|
|
|
| 56 |
if self.vector_store is None:
|
| 57 |
self.vector_store = FAISS.from_documents(chunks, self.embeddings)
|
| 58 |
else:
|
| 59 |
+
self.vector_store.merge_from(FAISS.from_documents(chunks, self.embeddings))
|
|
|
|
|
|
|
| 60 |
|
| 61 |
file_count = len(file_paths) - len(skipped)
|
| 62 |
self.status = f"Indexed {len(chunks)} chunks from {file_count} file(s)"
|
| 63 |
note = f" (skipped: {', '.join(skipped)})" if skipped else ""
|
|
|
|
| 64 |
return f"Done: {self.status}{note}"
|
| 65 |
|
| 66 |
def retrieve(self, query: str, k: int = 4) -> str:
|
|
|
|
| 68 |
return ""
|
| 69 |
try:
|
| 70 |
docs = self.vector_store.similarity_search(query, k=k)
|
|
|
|
|
|
|
| 71 |
return "\n\n".join(
|
| 72 |
+
f"[File: {d.metadata.get('source_file', '?')}] {d.page_content}" for d in docs
|
|
|
|
| 73 |
)
|
| 74 |
except Exception as e:
|
| 75 |
logger.error(f"Retrieval error: {e}")
|
|
|
|
| 84 |
def reset(self) -> None:
|
| 85 |
self.vector_store = None
|
| 86 |
self.status = "No files processed"
|
|
|
src/main.py
CHANGED
|
@@ -1,50 +1,86 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import shutil
|
| 3 |
import tempfile
|
| 4 |
import logging
|
|
|
|
| 5 |
from fastapi import FastAPI, HTTPException, UploadFile, File
|
| 6 |
from fastapi.responses import JSONResponse
|
| 7 |
from pydantic import BaseModel
|
| 8 |
-
from typing import List
|
| 9 |
from src.agent import get_agent_executor, file_processor
|
|
|
|
| 10 |
|
| 11 |
logging.basicConfig(level=logging.INFO)
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
-
app = FastAPI(
|
| 15 |
-
title="Agentic RAG Service",
|
| 16 |
-
description="AI microservice that routes between uploaded documents and web search.",
|
| 17 |
-
version="3.0",
|
| 18 |
-
)
|
| 19 |
-
|
| 20 |
-
try:
|
| 21 |
-
agent_executor = get_agent_executor()
|
| 22 |
-
except Exception as e:
|
| 23 |
-
logger.error(f"Failed to initialize agent: {e}")
|
| 24 |
-
agent_executor = None
|
| 25 |
|
| 26 |
|
| 27 |
class QueryRequest(BaseModel):
|
| 28 |
query: str
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
class QueryResponse(BaseModel):
|
| 32 |
response: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
@app.get("/")
|
| 36 |
async def root():
|
| 37 |
-
return {
|
| 38 |
-
|
| 39 |
-
"service": "Agentic Knowledge Search",
|
| 40 |
-
"docs_url": "/docs",
|
| 41 |
-
"uploaded_docs": file_processor.get_status(),
|
| 42 |
-
}
|
| 43 |
|
| 44 |
|
| 45 |
@app.post("/upload")
|
| 46 |
async def upload_files(files: List[UploadFile] = File(...)):
|
| 47 |
-
"""Accept uploaded files, index them for RAG, return a status message."""
|
| 48 |
tmp_dir = tempfile.mkdtemp()
|
| 49 |
try:
|
| 50 |
saved_paths = []
|
|
@@ -53,13 +89,9 @@ async def upload_files(files: List[UploadFile] = File(...)):
|
|
| 53 |
with open(dest, "wb") as f:
|
| 54 |
shutil.copyfileobj(upload.file, f)
|
| 55 |
saved_paths.append(dest)
|
| 56 |
-
logger.info(f"Saved upload: {upload.filename}")
|
| 57 |
-
|
| 58 |
status = file_processor.process_files(saved_paths)
|
| 59 |
-
logger.info(f"Processing result: {status}")
|
| 60 |
return JSONResponse({"status": status})
|
| 61 |
except Exception as e:
|
| 62 |
-
logger.error(f"Upload error: {e}")
|
| 63 |
raise HTTPException(status_code=500, detail=str(e))
|
| 64 |
finally:
|
| 65 |
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
@@ -67,33 +99,73 @@ async def upload_files(files: List[UploadFile] = File(...)):
|
|
| 67 |
|
| 68 |
@app.post("/reset")
|
| 69 |
async def reset_documents():
|
| 70 |
-
"""Clear all uploaded documents from the index."""
|
| 71 |
file_processor.reset()
|
| 72 |
return {"status": "Uploaded documents cleared."}
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
@app.post("/chat", response_model=QueryResponse)
|
| 76 |
async def chat(request: QueryRequest):
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
try:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
if __name__ == "__main__":
|
|
|
|
| 1 |
import os
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
import shutil
|
| 5 |
import tempfile
|
| 6 |
import logging
|
| 7 |
+
from typing import List, Optional
|
| 8 |
from fastapi import FastAPI, HTTPException, UploadFile, File
|
| 9 |
from fastapi.responses import JSONResponse
|
| 10 |
from pydantic import BaseModel
|
|
|
|
| 11 |
from src.agent import get_agent_executor, file_processor
|
| 12 |
+
from src.evaluator import faithfulness_score, answer_relevance_score, accuracy_score
|
| 13 |
|
| 14 |
logging.basicConfig(level=logging.INFO)
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
+
app = FastAPI(title="Agentic RAG Service", version="3.0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class QueryRequest(BaseModel):
|
| 21 |
query: str
|
| 22 |
+
api_key: Optional[str] = None # BYOK: each visitor supplies their own key
|
| 23 |
+
reference: Optional[str] = None
|
| 24 |
|
| 25 |
|
| 26 |
class QueryResponse(BaseModel):
|
| 27 |
response: str
|
| 28 |
+
source: str # "rag" | "web" | "rag+web"
|
| 29 |
+
faithfulness: Optional[float] = None
|
| 30 |
+
answer_relevance: Optional[float] = None
|
| 31 |
+
accuracy: Optional[float] = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _retry_delay(error_str: str) -> float:
|
| 35 |
+
match = re.search(r"retryDelay.*?(\d+\.?\d*)s", error_str)
|
| 36 |
+
return float(match.group(1)) if match else 0.0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _extract_content(message) -> str:
|
| 40 |
+
content = message.content
|
| 41 |
+
if isinstance(content, list):
|
| 42 |
+
content = " ".join(
|
| 43 |
+
block["text"] if isinstance(block, dict) else str(block)
|
| 44 |
+
for block in content
|
| 45 |
+
if not isinstance(block, dict) or block.get("type") == "text"
|
| 46 |
+
)
|
| 47 |
+
return str(content)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _parse_tool_results(messages: list) -> tuple[str, str]:
|
| 51 |
+
"""Return (source_type, combined_tool_output) from the agent message chain.
|
| 52 |
+
|
| 53 |
+
source_type is 'rag', 'web', or 'rag+web'.
|
| 54 |
+
combined_tool_output is the actual text the agent received from its tools,
|
| 55 |
+
which is what faithfulness should be measured against.
|
| 56 |
+
"""
|
| 57 |
+
rag_parts, web_parts = [], []
|
| 58 |
+
for msg in messages:
|
| 59 |
+
# ToolMessage objects carry a .name attribute
|
| 60 |
+
name = getattr(msg, "name", None)
|
| 61 |
+
content = getattr(msg, "content", "") or ""
|
| 62 |
+
if name == "lookup_documents":
|
| 63 |
+
rag_parts.append(content)
|
| 64 |
+
elif name == "search_web":
|
| 65 |
+
web_parts.append(content)
|
| 66 |
+
|
| 67 |
+
if rag_parts and web_parts:
|
| 68 |
+
return "rag+web", " ".join(rag_parts + web_parts)
|
| 69 |
+
if rag_parts:
|
| 70 |
+
return "rag", " ".join(rag_parts)
|
| 71 |
+
if web_parts:
|
| 72 |
+
return "web", " ".join(web_parts)
|
| 73 |
+
return "unknown", ""
|
| 74 |
|
| 75 |
|
| 76 |
@app.get("/")
|
| 77 |
async def root():
|
| 78 |
+
return {"status": "active", "service": "Agentic Knowledge Search",
|
| 79 |
+
"docs_url": "/docs", "uploaded_docs": file_processor.get_status()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
@app.post("/upload")
|
| 83 |
async def upload_files(files: List[UploadFile] = File(...)):
|
|
|
|
| 84 |
tmp_dir = tempfile.mkdtemp()
|
| 85 |
try:
|
| 86 |
saved_paths = []
|
|
|
|
| 89 |
with open(dest, "wb") as f:
|
| 90 |
shutil.copyfileobj(upload.file, f)
|
| 91 |
saved_paths.append(dest)
|
|
|
|
|
|
|
| 92 |
status = file_processor.process_files(saved_paths)
|
|
|
|
| 93 |
return JSONResponse({"status": status})
|
| 94 |
except Exception as e:
|
|
|
|
| 95 |
raise HTTPException(status_code=500, detail=str(e))
|
| 96 |
finally:
|
| 97 |
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
| 99 |
|
| 100 |
@app.post("/reset")
|
| 101 |
async def reset_documents():
|
|
|
|
| 102 |
file_processor.reset()
|
| 103 |
return {"status": "Uploaded documents cleared."}
|
| 104 |
|
| 105 |
|
| 106 |
+
def _is_invalid_key(error_str: str) -> bool:
|
| 107 |
+
markers = ("API_KEY_INVALID", "API key not valid", "PERMISSION_DENIED", "API key expired")
|
| 108 |
+
return any(m in error_str for m in markers)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
@app.post("/chat", response_model=QueryResponse)
|
| 112 |
async def chat(request: QueryRequest):
|
| 113 |
+
# BYOK: a key must be supplied with every request
|
| 114 |
+
if not request.api_key or not request.api_key.strip():
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
status_code=400,
|
| 117 |
+
detail="Please enter your Google Gemini API key to ask a question.",
|
| 118 |
+
)
|
| 119 |
|
| 120 |
try:
|
| 121 |
+
agent = get_agent_executor(request.api_key.strip())
|
| 122 |
+
except Exception as e:
|
| 123 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 124 |
+
|
| 125 |
+
for attempt in range(3):
|
| 126 |
+
try:
|
| 127 |
+
logger.info(f"Query (attempt {attempt + 1}): {request.query}")
|
| 128 |
+
result = agent.invoke({"messages": [("user", request.query)]})
|
| 129 |
+
answer = _extract_content(result["messages"][-1])
|
| 130 |
+
|
| 131 |
+
source, tool_output = _parse_tool_results(result["messages"])
|
| 132 |
+
|
| 133 |
+
# Faithfulness: answer vs the actual source content the agent used
|
| 134 |
+
faith = faithfulness_score(answer, tool_output) if tool_output else None
|
| 135 |
+
|
| 136 |
+
# Answer relevance: always computed, no reference needed
|
| 137 |
+
relevance = answer_relevance_score(request.query, answer)
|
| 138 |
+
|
| 139 |
+
# Accuracy: only when user provides a reference
|
| 140 |
+
acc = accuracy_score(answer, request.reference) if request.reference else None
|
| 141 |
+
|
| 142 |
+
return QueryResponse(
|
| 143 |
+
response=answer,
|
| 144 |
+
source=source,
|
| 145 |
+
faithfulness=faith,
|
| 146 |
+
answer_relevance=relevance,
|
| 147 |
+
accuracy=acc,
|
| 148 |
)
|
| 149 |
|
| 150 |
+
except Exception as e:
|
| 151 |
+
error_str = str(e)
|
| 152 |
+
if _is_invalid_key(error_str):
|
| 153 |
+
raise HTTPException(
|
| 154 |
+
status_code=401,
|
| 155 |
+
detail="Invalid or unauthorized API key. Please check your Google Gemini API key.",
|
| 156 |
+
)
|
| 157 |
+
if "RESOURCE_EXHAUSTED" not in error_str:
|
| 158 |
+
logger.error(f"Chat error: {error_str}")
|
| 159 |
+
raise HTTPException(status_code=500, detail=error_str)
|
| 160 |
+
delay = _retry_delay(error_str)
|
| 161 |
+
if delay and delay <= 120 and attempt < 2:
|
| 162 |
+
logger.warning(f"Rate limited — retrying in {delay:.0f}s...")
|
| 163 |
+
time.sleep(delay + 1)
|
| 164 |
+
continue
|
| 165 |
+
raise HTTPException(
|
| 166 |
+
status_code=429,
|
| 167 |
+
detail="Daily API quota exhausted. Please wait until tomorrow or upgrade your Gemini API plan.",
|
| 168 |
+
)
|
| 169 |
|
| 170 |
|
| 171 |
if __name__ == "__main__":
|
src/prefetch_models.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download the embedding + NLI models at image-build time so the running
|
| 3 |
+
container starts fast and never needs network access for models at runtime.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from src.embeddings import get_sentence_transformer
|
| 7 |
+
from src.evaluator import _nli
|
| 8 |
+
|
| 9 |
+
if __name__ == "__main__":
|
| 10 |
+
print("Prefetching embedding model (all-MiniLM-L6-v2)...")
|
| 11 |
+
get_sentence_transformer()
|
| 12 |
+
print("Prefetching NLI model (cross-encoder/nli-deberta-v3-small)...")
|
| 13 |
+
_nli()
|
| 14 |
+
print("Models cached.")
|
src/rag_engine.py
CHANGED
|
@@ -1,64 +1,46 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
from langchain_community.document_loaders import PyPDFLoader
|
| 3 |
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 4 |
from langchain_community.vectorstores import FAISS
|
| 5 |
-
from
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
|
| 8 |
load_dotenv()
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
class KnowledgeBase:
|
| 11 |
def __init__(self, pdf_path: str):
|
| 12 |
self.pdf_path = pdf_path
|
| 13 |
self.vector_store = None
|
| 14 |
-
# Initialize Local Embeddings
|
| 15 |
-
print("Initializing Local Embeddings (all-MiniLM-L6-v2)...")
|
| 16 |
-
self.embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
|
| 17 |
self.index_path = "faiss_index"
|
| 18 |
-
|
|
|
|
| 19 |
def load_and_index(self):
|
| 20 |
-
# 1. Try to load existing index
|
| 21 |
if os.path.exists(self.index_path):
|
| 22 |
-
print("Found cached index on disk. Loading...")
|
| 23 |
try:
|
| 24 |
self.vector_store = FAISS.load_local(
|
| 25 |
-
self.index_path,
|
| 26 |
-
self.embeddings,
|
| 27 |
-
allow_dangerous_deserialization=True
|
| 28 |
)
|
| 29 |
-
|
| 30 |
return
|
| 31 |
except Exception as e:
|
| 32 |
-
|
| 33 |
|
| 34 |
-
# 2. Check PDF
|
| 35 |
if not os.path.exists(self.pdf_path):
|
| 36 |
-
|
| 37 |
return
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
loader = PyPDFLoader(self.pdf_path)
|
| 42 |
-
docs = loader.load()
|
| 43 |
-
|
| 44 |
-
print("Splitting text into chunks...")
|
| 45 |
-
text_splitter = RecursiveCharacterTextSplitter(
|
| 46 |
-
chunk_size=1000,
|
| 47 |
-
chunk_overlap=200
|
| 48 |
-
)
|
| 49 |
-
chunks = text_splitter.split_documents(docs)
|
| 50 |
-
|
| 51 |
-
# 4. Create Index (Local)
|
| 52 |
-
print(f"Indexing {len(chunks)} chunks locally...")
|
| 53 |
self.vector_store = FAISS.from_documents(chunks, self.embeddings)
|
| 54 |
-
|
| 55 |
-
# 5. Save
|
| 56 |
self.vector_store.save_local(self.index_path)
|
| 57 |
-
|
| 58 |
|
| 59 |
def retrieve(self, query: str, k: int = 4) -> str:
|
| 60 |
if not self.vector_store:
|
| 61 |
return "No internal documents have been indexed."
|
| 62 |
-
|
| 63 |
docs = self.vector_store.similarity_search(query, k=k)
|
| 64 |
-
return "\n\n".join(
|
|
|
|
| 1 |
import os
|
| 2 |
+
import logging
|
| 3 |
from langchain_community.document_loaders import PyPDFLoader
|
| 4 |
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 5 |
from langchain_community.vectorstores import FAISS
|
| 6 |
+
from src.embeddings import get_embeddings
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
|
| 9 |
load_dotenv()
|
| 10 |
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
class KnowledgeBase:
|
| 15 |
def __init__(self, pdf_path: str):
|
| 16 |
self.pdf_path = pdf_path
|
| 17 |
self.vector_store = None
|
|
|
|
|
|
|
|
|
|
| 18 |
self.index_path = "faiss_index"
|
| 19 |
+
self.embeddings = get_embeddings()
|
| 20 |
+
|
| 21 |
def load_and_index(self):
|
|
|
|
| 22 |
if os.path.exists(self.index_path):
|
|
|
|
| 23 |
try:
|
| 24 |
self.vector_store = FAISS.load_local(
|
| 25 |
+
self.index_path, self.embeddings, allow_dangerous_deserialization=True
|
|
|
|
|
|
|
| 26 |
)
|
| 27 |
+
logger.info("Loaded FAISS index from disk.")
|
| 28 |
return
|
| 29 |
except Exception as e:
|
| 30 |
+
logger.warning(f"Could not load cached index: {e}. Re-indexing...")
|
| 31 |
|
|
|
|
| 32 |
if not os.path.exists(self.pdf_path):
|
| 33 |
+
logger.warning(f"PDF not found: {self.pdf_path}")
|
| 34 |
return
|
| 35 |
|
| 36 |
+
docs = PyPDFLoader(self.pdf_path).load()
|
| 37 |
+
chunks = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200).split_documents(docs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
self.vector_store = FAISS.from_documents(chunks, self.embeddings)
|
|
|
|
|
|
|
| 39 |
self.vector_store.save_local(self.index_path)
|
| 40 |
+
logger.info(f"Indexed {len(chunks)} chunks and saved to disk.")
|
| 41 |
|
| 42 |
def retrieve(self, query: str, k: int = 4) -> str:
|
| 43 |
if not self.vector_store:
|
| 44 |
return "No internal documents have been indexed."
|
|
|
|
| 45 |
docs = self.vector_store.similarity_search(query, k=k)
|
| 46 |
+
return "\n\n".join(f"[Source: Page {d.metadata.get('page', '?')}] {d.page_content}" for d in docs)
|
start.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
# Start the FastAPI backend (internal, not exposed publicly)
|
| 5 |
+
uvicorn src.main:app --host 0.0.0.0 --port 8000 &
|
| 6 |
+
|
| 7 |
+
# Wait until the backend is ready before launching the UI,
|
| 8 |
+
# so the first query never races against model loading.
|
| 9 |
+
echo "Waiting for FastAPI backend on :8000 ..."
|
| 10 |
+
python - <<'PY'
|
| 11 |
+
import time, urllib.request
|
| 12 |
+
for _ in range(120):
|
| 13 |
+
try:
|
| 14 |
+
urllib.request.urlopen("http://127.0.0.1:8000/", timeout=2)
|
| 15 |
+
print("Backend is up.")
|
| 16 |
+
break
|
| 17 |
+
except Exception:
|
| 18 |
+
time.sleep(2)
|
| 19 |
+
else:
|
| 20 |
+
print("Backend did not become ready in time; starting UI anyway.")
|
| 21 |
+
PY
|
| 22 |
+
|
| 23 |
+
# Start the Gradio UI on the public HF Spaces port
|
| 24 |
+
exec python app.py
|
tests/evaluate.py
CHANGED
|
@@ -1,104 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
| 2 |
import os
|
|
|
|
| 3 |
import pandas as pd
|
| 4 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 5 |
-
from
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
# Add 'src' to path
|
| 8 |
-
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
try:
|
| 10 |
-
from src.agent import get_agent_executor
|
| 11 |
except ImportError:
|
| 12 |
-
print("
|
| 13 |
sys.exit(1)
|
| 14 |
|
| 15 |
-
def run_evaluation():
|
| 16 |
-
print("Starting Custom Evaluation Pipeline (LLM-as-a-Judge)...")
|
| 17 |
-
|
| 18 |
-
# 1. Setup the Judge (Gemini)
|
| 19 |
-
eval_llm = ChatGoogleGenerativeAI(
|
| 20 |
-
model="gemini-2.5-flash",
|
| 21 |
-
temperature=0
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
# 2. Define the Grading Logic
|
| 25 |
-
grading_prompt = PromptTemplate.from_template(
|
| 26 |
-
"""
|
| 27 |
-
You are a strict teacher grading a student's answer.
|
| 28 |
-
|
| 29 |
-
Question: {question}
|
| 30 |
-
Ground Truth: {ground_truth}
|
| 31 |
-
Student Answer: {answer}
|
| 32 |
-
|
| 33 |
-
On a scale of 1-10, how accurate is the student's answer compared to the ground truth?
|
| 34 |
-
|
| 35 |
-
IMPORTANT:
|
| 36 |
-
- If the Student Answer matches the meaning of the Ground Truth, give a high score (8-10).
|
| 37 |
-
- If the Student Answer adds extra correct details from the document, that is GOOD.
|
| 38 |
-
- Only penalize if the information is factually wrong or completely unrelated.
|
| 39 |
-
|
| 40 |
-
Format:
|
| 41 |
-
Score: [1-10]
|
| 42 |
-
Reason: [Text]
|
| 43 |
-
"""
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
# 3. Test Data
|
| 47 |
-
questions = [
|
| 48 |
-
"What are the reporting requirements for State Parties?",
|
| 49 |
-
"What happens if a State Party denounces the Protocol?",
|
| 50 |
-
]
|
| 51 |
-
|
| 52 |
-
ground_truths = [
|
| 53 |
-
"State Parties must submit a comprehensive report initially, followed by further information included in reports to the Committee on the Rights of the Child. Other State Parties need to submit reports every five years.",
|
| 54 |
-
"Denunciation does not affect acts or situations occurring before the denunciation becomes effective. It also does not prejudice the continued consideration of matters already under consideration."
|
| 55 |
-
]
|
| 56 |
-
|
| 57 |
-
results = []
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
try:
|
| 62 |
-
agent = get_agent_executor()
|
| 63 |
except Exception as e:
|
| 64 |
-
print(f"
|
| 65 |
return
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
agent_answer = result["messages"][-1].content
|
| 74 |
-
print(f" Agent Answer: {agent_answer[:100]}...")
|
| 75 |
-
|
| 76 |
-
# B. Grade the Answer
|
| 77 |
-
grade_request = grading_prompt.format(
|
| 78 |
-
question=q,
|
| 79 |
-
ground_truth=ground_truths[i],
|
| 80 |
-
answer=agent_answer
|
| 81 |
-
)
|
| 82 |
-
grading_result = eval_llm.invoke(grade_request).content
|
| 83 |
-
|
| 84 |
-
# C. Store Result
|
| 85 |
-
results.append({
|
| 86 |
-
"Question": q,
|
| 87 |
-
"Agent Answer": agent_answer,
|
| 88 |
-
"Ground Truth": ground_truths[i],
|
| 89 |
-
"Grading": grading_result
|
| 90 |
-
})
|
| 91 |
-
print(f" Judge: {grading_result.splitlines()[0]}")
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
except Exception as e:
|
| 94 |
-
print(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
# 5. Save Report
|
| 97 |
-
if results:
|
| 98 |
-
df = pd.DataFrame(results)
|
| 99 |
-
df.to_csv("evaluation_report.csv", index=False)
|
| 100 |
-
print("\nEvaluation Complete! Report saved to 'evaluation_report.csv'")
|
| 101 |
-
print(df[["Question", "Grading"]])
|
| 102 |
|
| 103 |
if __name__ == "__main__":
|
| 104 |
-
run_evaluation()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation pipeline — two metrics per question:
|
| 3 |
+
1. Faithfulness : is the answer grounded in the retrieved context? (hallucination check)
|
| 4 |
+
2. Accuracy : does the answer correctly match the ground truth?
|
| 5 |
+
Run from the project root: python -m tests.evaluate
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
import sys
|
| 9 |
import os
|
| 10 |
+
import re
|
| 11 |
import pandas as pd
|
| 12 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 16 |
+
load_dotenv()
|
| 17 |
|
|
|
|
|
|
|
| 18 |
try:
|
| 19 |
+
from src.agent import get_agent_executor, file_processor, _fallback_kb
|
| 20 |
except ImportError:
|
| 21 |
+
print("Run this from the project root: python -m tests.evaluate")
|
| 22 |
sys.exit(1)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Judge prompts
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
FAITHFULNESS_PROMPT = """\
|
| 30 |
+
You are evaluating whether an AI answer is grounded in the provided source context.
|
| 31 |
+
|
| 32 |
+
Question: {question}
|
| 33 |
+
Retrieved Context: {context}
|
| 34 |
+
AI Answer: {answer}
|
| 35 |
+
|
| 36 |
+
Does the answer contain claims NOT supported by the retrieved context?
|
| 37 |
+
A faithful answer only uses information present in the context.
|
| 38 |
+
A hallucinated answer invents facts or adds information not in the context.
|
| 39 |
+
|
| 40 |
+
Score 1-10 where:
|
| 41 |
+
9-10 = fully grounded, no unsupported claims
|
| 42 |
+
6-8 = mostly grounded, minor additions
|
| 43 |
+
3-5 = several unsupported claims
|
| 44 |
+
1-2 = mostly fabricated
|
| 45 |
+
|
| 46 |
+
Format:
|
| 47 |
+
Score: [1-10]
|
| 48 |
+
Reason: [one sentence]"""
|
| 49 |
+
|
| 50 |
+
ACCURACY_PROMPT = """\
|
| 51 |
+
You are a strict teacher grading a student's answer.
|
| 52 |
+
|
| 53 |
+
Question: {question}
|
| 54 |
+
Ground Truth: {ground_truth}
|
| 55 |
+
Student Answer: {answer}
|
| 56 |
+
|
| 57 |
+
On a scale of 1-10, how accurate and complete is the student's answer compared to the ground truth?
|
| 58 |
+
- 9-10: matches ground truth meaning, may add correct extra details
|
| 59 |
+
- 6-8: partially correct, missing some key points
|
| 60 |
+
- 3-5: relevant but significantly incomplete or partially wrong
|
| 61 |
+
- 1-2: incorrect or completely off-topic
|
| 62 |
+
|
| 63 |
+
Format:
|
| 64 |
+
Score: [1-10]
|
| 65 |
+
Reason: [one sentence]"""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Helpers
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def extract_content(message) -> str:
|
| 73 |
+
content = message.content
|
| 74 |
+
if isinstance(content, list):
|
| 75 |
+
content = " ".join(
|
| 76 |
+
block["text"] if isinstance(block, dict) else str(block)
|
| 77 |
+
for block in content
|
| 78 |
+
if not isinstance(block, dict) or block.get("type") == "text"
|
| 79 |
+
)
|
| 80 |
+
return str(content)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def parse_score(text: str) -> int:
|
| 84 |
+
match = re.search(r"Score:\s*(\d+)", text)
|
| 85 |
+
return int(match.group(1)) if match else 0
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def get_context(question: str) -> str:
|
| 89 |
+
"""Retrieve the same context the agent would use for RAG questions."""
|
| 90 |
+
if file_processor.has_documents():
|
| 91 |
+
ctx = file_processor.retrieve(question)
|
| 92 |
+
if ctx:
|
| 93 |
+
return ctx
|
| 94 |
+
return _fallback_kb.retrieve(question)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# Test cases
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# Add your own Q&A pairs here. For web-search questions, leave ground_truth
|
| 101 |
+
# as None — only faithfulness will be skipped (no ground truth to compare).
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
TEST_CASES = [
|
| 105 |
+
{
|
| 106 |
+
"question": "What are the reporting requirements for State Parties?",
|
| 107 |
+
"ground_truth": (
|
| 108 |
+
"State Parties must submit a comprehensive report initially, followed by further "
|
| 109 |
+
"information included in reports to the Committee on the Rights of the Child. "
|
| 110 |
+
"Other State Parties need to submit reports every five years."
|
| 111 |
+
),
|
| 112 |
+
"source": "rag",
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"question": "What happens if a State Party denounces the Protocol?",
|
| 116 |
+
"ground_truth": (
|
| 117 |
+
"Denunciation does not affect acts or situations occurring before the denunciation "
|
| 118 |
+
"becomes effective. It also does not prejudice the continued consideration of matters "
|
| 119 |
+
"already under consideration."
|
| 120 |
+
),
|
| 121 |
+
"source": "rag",
|
| 122 |
+
},
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
# Main
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
|
| 130 |
+
def run_evaluation(test_cases: list = None):
|
| 131 |
+
cases = test_cases or TEST_CASES
|
| 132 |
+
print(f"Starting evaluation — {len(cases)} test case(s)\n")
|
| 133 |
+
|
| 134 |
+
api_key = os.getenv("GOOGLE_API_KEY")
|
| 135 |
+
if not api_key:
|
| 136 |
+
print("Set GOOGLE_API_KEY in your .env to run the offline evaluation.")
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
judge = ChatGoogleGenerativeAI(model="gemini-2.0-flash", temperature=0, google_api_key=api_key)
|
| 140 |
+
|
| 141 |
try:
|
| 142 |
+
agent = get_agent_executor(api_key)
|
| 143 |
except Exception as e:
|
| 144 |
+
print(f"Could not initialize agent: {e}")
|
| 145 |
return
|
| 146 |
|
| 147 |
+
results = []
|
| 148 |
+
|
| 149 |
+
for i, case in enumerate(cases, 1):
|
| 150 |
+
question = case["question"]
|
| 151 |
+
ground_truth = case.get("ground_truth")
|
| 152 |
+
source = case.get("source", "rag")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
+
print(f"[{i}/{len(cases)}] {question}")
|
| 155 |
+
|
| 156 |
+
# 1. Get agent answer
|
| 157 |
+
try:
|
| 158 |
+
result = agent.invoke({"messages": [("user", question)]})
|
| 159 |
+
answer = extract_content(result["messages"][-1])
|
| 160 |
except Exception as e:
|
| 161 |
+
print(f" Agent error: {e}\n")
|
| 162 |
+
results.append({"Question": question, "Answer": f"ERROR: {e}",
|
| 163 |
+
"Ground Truth": ground_truth,
|
| 164 |
+
"Faithfulness Score": "-", "Faithfulness Reason": str(e),
|
| 165 |
+
"Accuracy Score": "-", "Accuracy Reason": str(e)})
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
print(f" Answer: {answer[:120]}...")
|
| 169 |
+
|
| 170 |
+
# 2. Faithfulness check (hallucination detection) — RAG questions only
|
| 171 |
+
faithfulness_score, faithfulness_reason = "-", "N/A (web search question)"
|
| 172 |
+
if source == "rag":
|
| 173 |
+
try:
|
| 174 |
+
context = get_context(question)
|
| 175 |
+
response = judge.invoke(
|
| 176 |
+
FAITHFULNESS_PROMPT.format(question=question, context=context, answer=answer)
|
| 177 |
+
)
|
| 178 |
+
faith_text = extract_content(response)
|
| 179 |
+
faithfulness_score = parse_score(faith_text)
|
| 180 |
+
faithfulness_reason = faith_text.split("Reason:")[-1].strip()
|
| 181 |
+
print(f" Faithfulness: {faithfulness_score}/10")
|
| 182 |
+
except Exception as e:
|
| 183 |
+
faithfulness_reason = str(e)
|
| 184 |
+
print(f" Faithfulness check failed: {e}")
|
| 185 |
+
|
| 186 |
+
# 3. Accuracy check — only if ground truth is provided
|
| 187 |
+
accuracy_score, accuracy_reason = "-", "N/A (no ground truth)"
|
| 188 |
+
if ground_truth:
|
| 189 |
+
try:
|
| 190 |
+
response = judge.invoke(
|
| 191 |
+
ACCURACY_PROMPT.format(question=question, ground_truth=ground_truth, answer=answer)
|
| 192 |
+
)
|
| 193 |
+
acc_text = extract_content(response)
|
| 194 |
+
accuracy_score = parse_score(acc_text)
|
| 195 |
+
accuracy_reason = acc_text.split("Reason:")[-1].strip()
|
| 196 |
+
print(f" Accuracy: {accuracy_score}/10")
|
| 197 |
+
except Exception as e:
|
| 198 |
+
accuracy_reason = str(e)
|
| 199 |
+
print(f" Accuracy check failed: {e}")
|
| 200 |
+
|
| 201 |
+
results.append({
|
| 202 |
+
"Question": question,
|
| 203 |
+
"Answer": answer,
|
| 204 |
+
"Ground Truth": ground_truth or "",
|
| 205 |
+
"Faithfulness Score": faithfulness_score,
|
| 206 |
+
"Faithfulness Reason": faithfulness_reason,
|
| 207 |
+
"Accuracy Score": accuracy_score,
|
| 208 |
+
"Accuracy Reason": accuracy_reason,
|
| 209 |
+
})
|
| 210 |
+
print()
|
| 211 |
+
|
| 212 |
+
# Save report
|
| 213 |
+
if not results:
|
| 214 |
+
print("No results to save.")
|
| 215 |
+
return
|
| 216 |
+
|
| 217 |
+
df = pd.DataFrame(results)
|
| 218 |
+
df.to_csv("evaluation_report.csv", index=False)
|
| 219 |
+
|
| 220 |
+
# Print summary
|
| 221 |
+
print("=" * 50)
|
| 222 |
+
numeric_faith = [r["Faithfulness Score"] for r in results if isinstance(r["Faithfulness Score"], int)]
|
| 223 |
+
numeric_acc = [r["Accuracy Score"] for r in results if isinstance(r["Accuracy Score"], int)]
|
| 224 |
+
if numeric_faith:
|
| 225 |
+
print(f"Avg Faithfulness (hallucination): {sum(numeric_faith)/len(numeric_faith):.1f}/10")
|
| 226 |
+
if numeric_acc:
|
| 227 |
+
print(f"Avg Accuracy: {sum(numeric_acc)/len(numeric_acc):.1f}/10")
|
| 228 |
+
print(f"\nReport saved to evaluation_report.csv")
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
if __name__ == "__main__":
|
| 232 |
+
run_evaluation()
|