Devisri515 commited on
Commit
8f6bb93
·
1 Parent(s): 41e79c8

live evaluations

Browse files
Files changed (14) hide show
  1. .dockerignore +12 -0
  2. Dockerfile +21 -15
  3. README.md +81 -3
  4. app.py +164 -59
  5. requirements.txt +3 -7
  6. src/agent.py +19 -16
  7. src/embeddings.py +33 -0
  8. src/evaluator.py +105 -0
  9. src/file_processor.py +17 -42
  10. src/main.py +112 -40
  11. src/prefetch_models.py +14 -0
  12. src/rag_engine.py +15 -33
  13. start.sh +24 -0
  14. 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
- # Use an official Python runtime as a parent image
2
  FROM python:3.10-slim
3
 
4
- # Set the working directory in the container
5
- WORKDIR /app
 
 
 
 
 
 
 
6
 
7
- # Copy the requirements file into the container
8
- COPY requirements.txt .
9
 
10
- # Install any needed packages specified in requirements.txt
11
- RUN pip install --no-cache-dir -r requirements.txt
 
12
 
13
- # Copy the current directory contents into the container at /app
14
- COPY . .
15
 
16
- # Expose the port FastAPI runs on
17
- EXPOSE 8000
18
 
19
- # Define environment variable for unbuffered logs
20
- ENV PYTHONUNBUFFERED=1
21
 
22
- # Run the application
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
- - data/: Place your PDF documents here.
 
 
 
 
 
 
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
- Create a .env file in the root directory:
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", (filename, fp.read(), "application/octet-stream")))
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 determine_source(text: str) -> str:
47
- lower = text.lower()
48
- if any(k in lower for k in ["search", "web", "duckduckgo", "internet", "online"]):
49
- return "Web Search"
50
- if any(k in lower for k in ["uploaded", "file:", "page", "document", "policy", "pdf"]):
51
- return "Uploaded Documents (RAG)"
52
- return "RAG + Web Search"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
 
 
 
 
 
 
54
 
55
- def process_query(message: str, chat_history: list) -> tuple[list, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  if not message.strip():
57
- return chat_history, "Please enter a question."
58
  try:
59
- resp = requests.post(CHAT_ENDPOINT, json={"query": message}, timeout=120)
60
- resp.raise_for_status()
61
- response_text = resp.json().get("response", "")
 
 
 
 
 
 
 
 
62
 
63
- source = determine_source(response_text)
64
- full_response = f"{response_text}\n\n--- Source: {source} ---"
 
 
65
 
 
66
  chat_history.append({"role": "user", "content": message})
67
- chat_history.append({"role": "assistant", "content": full_response})
68
- return chat_history, f"Done — answered via {source}"
 
 
 
 
 
 
 
 
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, then ask questions. "
85
- "The agent searches your files first, then the web if needed."
 
86
  )
87
 
88
- # File upload panel
89
  with gr.Group():
90
- gr.Markdown("### Upload Documents")
91
- gr.Markdown(f"Supported: {', '.join(SUPPORTED_TYPES)}")
92
- with gr.Row():
93
- file_input = gr.File(
94
- label="Select Files",
95
- file_count="multiple",
96
- file_types=SUPPORTED_TYPES,
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("### Ask a Question")
108
- chatbot = gr.Chatbot(
109
- label="Conversation",
110
- height=420,
111
  )
112
- with gr.Row():
 
 
113
  user_input = gr.Textbox(
114
  placeholder="Ask anything about your documents or the web...",
115
- label="Your Question",
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
- status_output = gr.Textbox(label="Status", interactive=False, lines=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- duckduckgo-search==5.3.1
 
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
- # Optional fallback KB (original policy.pdf); silently skipped if missing
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
- # --- Tools ---
 
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 search_tool.run(query)
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
- print("Initializing Gemini Agent (Model: gemini-2.5-flash-lite)...")
54
- llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash-lite", temperature=0)
 
 
 
 
 
 
 
 
 
 
 
 
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 langchain_huggingface import HuggingFaceEmbeddings
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
  SUPPORTED_EXTENSIONS = {".pdf", ".txt", ".md", ".csv", ".docx"}
16
 
17
 
18
- def _loader_for(file_path: str):
19
- ext = os.path.splitext(file_path)[1].lower()
20
  if ext == ".pdf":
21
- return PyPDFLoader(file_path)
22
  if ext in (".txt", ".md"):
23
- return TextLoader(file_path, encoding="utf-8")
24
  if ext == ".csv":
25
- return CSVLoader(file_path)
26
  if ext == ".docx":
27
- return Docx2txtLoader(file_path)
28
  return None
29
 
30
 
31
  class FileProcessor:
32
- def __init__(self, embedding_model: str = "all-MiniLM-L6-v2"):
33
- self.embeddings = HuggingFaceEmbeddings(model_name=embedding_model)
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
- ext = os.path.splitext(path)[1].lower()
50
- if ext not in SUPPORTED_EXTENSIONS:
51
  skipped.append(os.path.basename(path))
52
  continue
53
  try:
54
- loader = _loader_for(path)
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
- # Merge into existing index so previous uploads are retained
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
- "status": "active",
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
- if not agent_executor:
78
- raise HTTPException(status_code=500, detail="Agent not initialized (check API key)")
 
 
 
 
79
 
80
  try:
81
- logger.info(f"Query: {request.query}")
82
- result = agent_executor.invoke({"messages": [("user", request.query)]})
83
- last_message = result["messages"][-1]
84
-
85
- content = last_message.content
86
- if isinstance(content, list):
87
- content = " ".join(
88
- block["text"] if isinstance(block, dict) else str(block)
89
- for block in content
90
- if not isinstance(block, dict) or block.get("type") == "text"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  )
92
 
93
- return QueryResponse(response=str(content))
94
- except Exception as e:
95
- logger.error(f"Chat error: {e}")
96
- raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 langchain_huggingface import HuggingFaceEmbeddings
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
- print("Loaded index from disk.")
30
  return
31
  except Exception as e:
32
- print(f"Could not load cache: {e}. Re-indexing...")
33
 
34
- # 2. Check PDF
35
  if not os.path.exists(self.pdf_path):
36
- print(f"Warning: File not found at {self.pdf_path}")
37
  return
38
 
39
- # 3. Load and Split
40
- print(f"Loading PDF from: {self.pdf_path}...")
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
- print("Indexing Complete and Saved.")
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([f"[Source: Page {d.metadata.get('page', '?')}] {d.page_content}" for d in docs])
 
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 langchain_core.prompts import PromptTemplate
 
 
 
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("Could not import agent. Make sure you are running this from the root folder!")
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
- # 4. Run the Agent & Grade it
60
- print("Generating and Grading answers...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  try:
62
- agent = get_agent_executor()
63
  except Exception as e:
64
- print(f"Error initializing agent: {e}")
65
  return
66
 
67
- for i, q in enumerate(questions):
68
- print(f"\nTest Case {i+1}: {q}")
69
- try:
70
- # A. Get Agent Answer
71
- inputs = {"messages": [("user", q)]}
72
- result = agent.invoke(inputs)
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"Failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()