Commit Β·
2b63102
1
Parent(s): 834a723
fix CI
Browse files- app.py +14 -17
- src/agent.py +5 -9
- src/embeddings.py +3 -15
- src/evaluator.py +31 -67
- src/main.py +2 -4
- src/memory.py +2 -16
- src/prefetch_models.py +3 -7
- src/rag_engine.py +0 -3
- src/utils.py +1 -1
- tests/evaluate.py +8 -29
- tests/test_evaluator.py +2 -5
app.py
CHANGED
|
@@ -35,14 +35,13 @@ def upload_files(files) -> str:
|
|
| 35 |
path = f if isinstance(f, str) else f.name
|
| 36 |
with open(path, "rb") as fp:
|
| 37 |
multipart.append(("files", (os.path.basename(path), fp.read(), "application/octet-stream")))
|
| 38 |
-
# Large PDFs
|
| 39 |
-
# a few minutes, so allow a generous timeout.
|
| 40 |
resp = requests.post(UPLOAD_ENDPOINT, files=multipart, timeout=900)
|
| 41 |
resp.raise_for_status()
|
| 42 |
return resp.json().get("status", "Files processed.")
|
| 43 |
except requests.exceptions.ReadTimeout:
|
| 44 |
-
return ("Still indexing
|
| 45 |
-
"
|
| 46 |
except requests.exceptions.RequestException as e:
|
| 47 |
return f"Upload failed: {e}"
|
| 48 |
|
|
@@ -71,7 +70,7 @@ def _faith_hint(score: float, source: str) -> str:
|
|
| 71 |
return f"Answer is well-grounded in the {what}"
|
| 72 |
if score >= 0.50:
|
| 73 |
return f"Answer is mostly grounded in the {what}, minor unsupported details possible"
|
| 74 |
-
return f"Low grounding
|
| 75 |
|
| 76 |
|
| 77 |
def _relevance_hint(score: float) -> str:
|
|
@@ -86,7 +85,7 @@ def _accuracy_hint(score: float) -> str:
|
|
| 86 |
if score >= 0.75:
|
| 87 |
return "Strong match with your reference answer"
|
| 88 |
if score >= 0.40:
|
| 89 |
-
return "Partial match
|
| 90 |
return "Low overlap with your reference answer"
|
| 91 |
|
| 92 |
|
|
@@ -98,14 +97,14 @@ def _format_metrics(source: str, faithfulness, answer_relevance, accuracy) -> st
|
|
| 98 |
lines = [f"**Answer source: {src_label}**", "---"]
|
| 99 |
|
| 100 |
if faithfulness is not None:
|
| 101 |
-
faith_label = "Faithfulness
|
| 102 |
lines.append(_fmt(faith_label, faithfulness, _faith_hint(faithfulness, source)))
|
| 103 |
|
| 104 |
if answer_relevance is not None:
|
| 105 |
-
lines.append(_fmt("Answer Relevance
|
| 106 |
|
| 107 |
if accuracy is not None:
|
| 108 |
-
lines.append(_fmt("Accuracy
|
| 109 |
else:
|
| 110 |
lines.append("_Accuracy: provide a reference answer above to see this score._")
|
| 111 |
|
|
@@ -150,7 +149,7 @@ def process_query(message: str, api_key: str, reference: str, session_id: str, c
|
|
| 150 |
data.get("answer_relevance"),
|
| 151 |
data.get("accuracy"),
|
| 152 |
)
|
| 153 |
-
return chat_history, f"Done
|
| 154 |
|
| 155 |
except requests.exceptions.RequestException as e:
|
| 156 |
error = f"Request failed: {e}"
|
|
@@ -169,8 +168,7 @@ def clear_chat(session_id: str) -> tuple[list, str, str]:
|
|
| 169 |
|
| 170 |
|
| 171 |
with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
| 172 |
-
#
|
| 173 |
-
session_id = gr.State()
|
| 174 |
|
| 175 |
gr.Markdown("# Agentic RAG Knowledge Search")
|
| 176 |
gr.Markdown(
|
|
@@ -183,7 +181,7 @@ with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
|
| 183 |
with gr.Group():
|
| 184 |
gr.Markdown(
|
| 185 |
"### Your Gemini API Key (required)\n"
|
| 186 |
-
"This app uses **your own** Google Gemini key
|
| 187 |
"Get a free key at [Google AI Studio](https://aistudio.google.com/apikey)."
|
| 188 |
)
|
| 189 |
api_key_input = gr.Textbox(
|
|
@@ -196,8 +194,8 @@ with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
|
| 196 |
with gr.Group():
|
| 197 |
gr.Markdown(
|
| 198 |
f"### Upload Documents\n"
|
| 199 |
-
f"Supported: **{', '.join(SUPPORTED_TYPES)}**
|
| 200 |
-
"_Large PDFs (100s of pages) can take a few minutes to index on the free CPU
|
| 201 |
)
|
| 202 |
file_input = gr.File(label="Select Files", file_count="multiple", file_types=SUPPORTED_TYPES)
|
| 203 |
with gr.Row():
|
|
@@ -239,7 +237,7 @@ with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
|
| 239 |
with gr.Group():
|
| 240 |
gr.Markdown(
|
| 241 |
"### Evaluation Metrics\n"
|
| 242 |
-
"Computed automatically after every response
|
| 243 |
"| Metric | Always shown? | What it measures |\n"
|
| 244 |
"|---|---|---|\n"
|
| 245 |
"| **Faithfulness** | Yes | Is the answer grounded in the source it used? (docs or web results) |\n"
|
|
@@ -248,7 +246,6 @@ with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
|
| 248 |
)
|
| 249 |
metrics_output = gr.Markdown()
|
| 250 |
|
| 251 |
-
# Assign a fresh session id when the page loads
|
| 252 |
demo.load(fn=lambda: str(uuid.uuid4()), inputs=[], outputs=[session_id])
|
| 253 |
|
| 254 |
upload_btn.click(fn=upload_files, inputs=[file_input], outputs=[upload_status])
|
|
|
|
| 35 |
path = f if isinstance(f, str) else f.name
|
| 36 |
with open(path, "rb") as fp:
|
| 37 |
multipart.append(("files", (os.path.basename(path), fp.read(), "application/octet-stream")))
|
| 38 |
+
# Large PDFs can take minutes to embed on CPU, so allow a generous timeout.
|
|
|
|
| 39 |
resp = requests.post(UPLOAD_ENDPOINT, files=multipart, timeout=900)
|
| 40 |
resp.raise_for_status()
|
| 41 |
return resp.json().get("status", "Files processed.")
|
| 42 |
except requests.exceptions.ReadTimeout:
|
| 43 |
+
return ("Still indexing. This file is large and CPU embedding is slow; "
|
| 44 |
+
"wait a moment and try your question, it may already be indexed.")
|
| 45 |
except requests.exceptions.RequestException as e:
|
| 46 |
return f"Upload failed: {e}"
|
| 47 |
|
|
|
|
| 70 |
return f"Answer is well-grounded in the {what}"
|
| 71 |
if score >= 0.50:
|
| 72 |
return f"Answer is mostly grounded in the {what}, minor unsupported details possible"
|
| 73 |
+
return f"Low grounding: answer may contain content not present in the {what}"
|
| 74 |
|
| 75 |
|
| 76 |
def _relevance_hint(score: float) -> str:
|
|
|
|
| 85 |
if score >= 0.75:
|
| 86 |
return "Strong match with your reference answer"
|
| 87 |
if score >= 0.40:
|
| 88 |
+
return "Partial match: some key points differ from your reference"
|
| 89 |
return "Low overlap with your reference answer"
|
| 90 |
|
| 91 |
|
|
|
|
| 97 |
lines = [f"**Answer source: {src_label}**", "---"]
|
| 98 |
|
| 99 |
if faithfulness is not None:
|
| 100 |
+
faith_label = "Faithfulness (grounded in documents)" if source != "web" else "Faithfulness (grounded in web results)"
|
| 101 |
lines.append(_fmt(faith_label, faithfulness, _faith_hint(faithfulness, source)))
|
| 102 |
|
| 103 |
if answer_relevance is not None:
|
| 104 |
+
lines.append(_fmt("Answer Relevance (addresses the question)", answer_relevance, _relevance_hint(answer_relevance)))
|
| 105 |
|
| 106 |
if accuracy is not None:
|
| 107 |
+
lines.append(_fmt("Accuracy (matches your reference)", accuracy, _accuracy_hint(accuracy)))
|
| 108 |
else:
|
| 109 |
lines.append("_Accuracy: provide a reference answer above to see this score._")
|
| 110 |
|
|
|
|
| 149 |
data.get("answer_relevance"),
|
| 150 |
data.get("accuracy"),
|
| 151 |
)
|
| 152 |
+
return chat_history, f"Done. Answered via {src_label}", metrics_md
|
| 153 |
|
| 154 |
except requests.exceptions.RequestException as e:
|
| 155 |
error = f"Request failed: {e}"
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
with gr.Blocks(title="Agentic RAG Knowledge Search") as demo:
|
| 171 |
+
session_id = gr.State() # per-browser id for server-side memory
|
|
|
|
| 172 |
|
| 173 |
gr.Markdown("# Agentic RAG Knowledge Search")
|
| 174 |
gr.Markdown(
|
|
|
|
| 181 |
with gr.Group():
|
| 182 |
gr.Markdown(
|
| 183 |
"### Your Gemini API Key (required)\n"
|
| 184 |
+
"This app uses **your own** Google Gemini key; it is sent only with your requests and never stored. "
|
| 185 |
"Get a free key at [Google AI Studio](https://aistudio.google.com/apikey)."
|
| 186 |
)
|
| 187 |
api_key_input = gr.Textbox(
|
|
|
|
| 194 |
with gr.Group():
|
| 195 |
gr.Markdown(
|
| 196 |
f"### Upload Documents\n"
|
| 197 |
+
f"Supported: **{', '.join(SUPPORTED_TYPES)}**. Multiple files allowed; new uploads add to the index. \n"
|
| 198 |
+
"_Large PDFs (100s of pages) can take a few minutes to index on the free CPU, so please be patient._"
|
| 199 |
)
|
| 200 |
file_input = gr.File(label="Select Files", file_count="multiple", file_types=SUPPORTED_TYPES)
|
| 201 |
with gr.Row():
|
|
|
|
| 237 |
with gr.Group():
|
| 238 |
gr.Markdown(
|
| 239 |
"### Evaluation Metrics\n"
|
| 240 |
+
"Computed automatically after every response, with **no extra API calls and no reference needed** for the first two.\n\n"
|
| 241 |
"| Metric | Always shown? | What it measures |\n"
|
| 242 |
"|---|---|---|\n"
|
| 243 |
"| **Faithfulness** | Yes | Is the answer grounded in the source it used? (docs or web results) |\n"
|
|
|
|
| 246 |
)
|
| 247 |
metrics_output = gr.Markdown()
|
| 248 |
|
|
|
|
| 249 |
demo.load(fn=lambda: str(uuid.uuid4()), inputs=[], outputs=[session_id])
|
| 250 |
|
| 251 |
upload_btn.click(fn=upload_files, inputs=[file_input], outputs=[upload_status])
|
src/agent.py
CHANGED
|
@@ -20,8 +20,7 @@ except Exception as e:
|
|
| 20 |
|
| 21 |
_search_tool = DuckDuckGoSearchRun()
|
| 22 |
|
| 23 |
-
#
|
| 24 |
-
# calls. Override with the GEMINI_MODEL env var if your key supports another model.
|
| 25 |
MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
| 26 |
|
| 27 |
SYSTEM_PROMPT = (
|
|
@@ -32,7 +31,7 @@ SYSTEM_PROMPT = (
|
|
| 32 |
"knowledge that is unlikely to be in the documents.\n\n"
|
| 33 |
"Guidelines:\n"
|
| 34 |
"1. Choose the tool that best fits the question; use both if needed.\n"
|
| 35 |
-
"2. Ground your answer in the retrieved content
|
| 36 |
"do not contain the answer, say so and try the web.\n"
|
| 37 |
"3. Use the conversation summary and recent turns to resolve follow-up questions "
|
| 38 |
"(e.g. pronouns like 'it' or 'that').\n"
|
|
@@ -62,7 +61,7 @@ def search_web(query: str) -> str:
|
|
| 62 |
|
| 63 |
@lru_cache(maxsize=32)
|
| 64 |
def get_llm(api_key: str) -> ChatGoogleGenerativeAI:
|
| 65 |
-
"""Cached Gemini client
|
| 66 |
if not api_key or not api_key.strip():
|
| 67 |
raise ValueError("A Google Gemini API key is required.")
|
| 68 |
return ChatGoogleGenerativeAI(
|
|
@@ -74,11 +73,8 @@ def get_llm(api_key: str) -> ChatGoogleGenerativeAI:
|
|
| 74 |
|
| 75 |
@lru_cache(maxsize=32)
|
| 76 |
def get_agent_executor(api_key: str):
|
| 77 |
-
"""
|
| 78 |
-
|
| 79 |
-
Each visitor supplies their own key (BYOK). The shared tools, embeddings, and
|
| 80 |
-
RAG index are module-level and reused; only the LLM is per-key. Cached by key
|
| 81 |
-
so repeat requests from the same user don't rebuild the graph."""
|
| 82 |
return create_react_agent(
|
| 83 |
get_llm(api_key),
|
| 84 |
[lookup_documents, search_web],
|
|
|
|
| 20 |
|
| 21 |
_search_tool = DuckDuckGoSearchRun()
|
| 22 |
|
| 23 |
+
# gemini-2.5-flash reliably answers after tool calls; override per key via env.
|
|
|
|
| 24 |
MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
| 25 |
|
| 26 |
SYSTEM_PROMPT = (
|
|
|
|
| 31 |
"knowledge that is unlikely to be in the documents.\n\n"
|
| 32 |
"Guidelines:\n"
|
| 33 |
"1. Choose the tool that best fits the question; use both if needed.\n"
|
| 34 |
+
"2. Ground your answer in the retrieved content and do not invent facts. If the documents "
|
| 35 |
"do not contain the answer, say so and try the web.\n"
|
| 36 |
"3. Use the conversation summary and recent turns to resolve follow-up questions "
|
| 37 |
"(e.g. pronouns like 'it' or 'that').\n"
|
|
|
|
| 61 |
|
| 62 |
@lru_cache(maxsize=32)
|
| 63 |
def get_llm(api_key: str) -> ChatGoogleGenerativeAI:
|
| 64 |
+
"""Cached Gemini client per key, reused by the agent and the summarizer."""
|
| 65 |
if not api_key or not api_key.strip():
|
| 66 |
raise ValueError("A Google Gemini API key is required.")
|
| 67 |
return ChatGoogleGenerativeAI(
|
|
|
|
| 73 |
|
| 74 |
@lru_cache(maxsize=32)
|
| 75 |
def get_agent_executor(api_key: str):
|
| 76 |
+
"""ReAct agent for a given key (BYOK). Tools and the index are shared; only the
|
| 77 |
+
LLM is per-key, and the graph is cached so repeat calls don't rebuild it."""
|
|
|
|
|
|
|
|
|
|
| 78 |
return create_react_agent(
|
| 79 |
get_llm(api_key),
|
| 80 |
[lookup_documents, search_web],
|
src/embeddings.py
CHANGED
|
@@ -1,13 +1,3 @@
|
|
| 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"
|
|
@@ -15,7 +5,7 @@ _embeddings = None
|
|
| 15 |
|
| 16 |
|
| 17 |
def get_embeddings() -> HuggingFaceEmbeddings:
|
| 18 |
-
"""Shared
|
| 19 |
global _embeddings
|
| 20 |
if _embeddings is None:
|
| 21 |
_embeddings = HuggingFaceEmbeddings(model_name=_MODEL_NAME)
|
|
@@ -23,10 +13,8 @@ def get_embeddings() -> HuggingFaceEmbeddings:
|
|
| 23 |
|
| 24 |
|
| 25 |
def get_sentence_transformer():
|
| 26 |
-
"""The
|
| 27 |
-
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 2 |
|
| 3 |
_MODEL_NAME = "all-MiniLM-L6-v2"
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
def get_embeddings() -> HuggingFaceEmbeddings:
|
| 8 |
+
"""Shared embeddings instance, loaded once and reused by RAG and the evaluator."""
|
| 9 |
global _embeddings
|
| 10 |
if _embeddings is None:
|
| 11 |
_embeddings = HuggingFaceEmbeddings(model_name=_MODEL_NAME)
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def get_sentence_transformer():
|
| 16 |
+
"""The raw SentenceTransformer behind the shared embeddings (used by the evaluator)."""
|
| 17 |
+
model = getattr(get_embeddings(), "client", None)
|
|
|
|
|
|
|
| 18 |
if model is None:
|
| 19 |
from sentence_transformers import SentenceTransformer
|
| 20 |
model = SentenceTransformer(_MODEL_NAME)
|
src/evaluator.py
CHANGED
|
@@ -1,40 +1,29 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 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 |
|
| 18 |
-
# Heavy model libraries (sentence_transformers, torch) are imported lazily inside
|
| 19 |
-
# the functions that need them, so importing this module β and running the
|
| 20 |
-
# ROUGE/text-helper unit tests β stays fast and dependency-light in CI.
|
| 21 |
-
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
|
| 24 |
-
# FEVER/ANLI-trained NLI model
|
| 25 |
-
# subset/superset and compound claims
|
| 26 |
_NLI_MODEL_NAME = "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli"
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
_nli_model = None
|
| 29 |
_entail_idx = None
|
| 30 |
_rouge = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True)
|
| 31 |
|
| 32 |
|
| 33 |
-
# --- helpers ---------------------------------------------------------------
|
| 34 |
-
|
| 35 |
def _nli():
|
| 36 |
-
"""
|
| 37 |
-
(label order
|
| 38 |
global _nli_model, _entail_idx
|
| 39 |
if _nli_model is None:
|
| 40 |
from sentence_transformers import CrossEncoder
|
|
@@ -63,64 +52,45 @@ def _cosine(text_a: str, text_b: str) -> float:
|
|
| 63 |
|
| 64 |
|
| 65 |
def _is_claim(text: str) -> bool:
|
| 66 |
-
"""Keep verifiable
|
| 67 |
-
if len(text) <= 15:
|
| 68 |
return False
|
| 69 |
-
|
| 70 |
-
return False
|
| 71 |
-
if re.match(r"^\(?\s*sources?\s*:", text, re.IGNORECASE): # "(Source: file.pdf)"
|
| 72 |
-
return False
|
| 73 |
-
return True
|
| 74 |
|
| 75 |
|
| 76 |
def _split_sentences(text: str) -> list[str]:
|
| 77 |
-
|
| 78 |
-
# become individual claims; strip leading bullet markers, then drop
|
| 79 |
-
# framing/meta lines that aren't verifiable factual statements.
|
| 80 |
parts = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
|
| 81 |
cleaned = []
|
| 82 |
for p in parts:
|
| 83 |
p = p.lstrip("*-β’Β· \t")
|
| 84 |
-
p = re.sub(r"[*_`]+", "", p).strip()
|
| 85 |
cleaned.append(p)
|
| 86 |
return [p for p in cleaned if _is_claim(p)]
|
| 87 |
|
| 88 |
|
| 89 |
def _split_evidence(context: str) -> list[str]:
|
| 90 |
-
"""
|
| 91 |
-
|
| 92 |
-
Source markers like "[File: x]" / "[Source: Page 3]" are stripped, then the
|
| 93 |
-
text is split on sentence boundaries and newlines. NLI is far more reliable
|
| 94 |
-
with single-sentence premises than with whole multi-sentence chunks."""
|
| 95 |
cleaned = re.sub(r"\[(?:File|Source)[^\]]*\]", " ", context)
|
| 96 |
parts = re.split(r"(?<=[.!?])\s+|\n+", cleaned.strip())
|
| 97 |
return [p.strip() for p in parts if len(p.strip()) > 15]
|
| 98 |
|
| 99 |
|
| 100 |
-
# --- metrics ---------------------------------------------------------------
|
| 101 |
-
|
| 102 |
-
_TOP_EVIDENCE = 4 # source sentences considered per claim
|
| 103 |
-
|
| 104 |
-
# Leading proper-noun subject of 2-4 capitalized words, e.g. "Devi Sri Bandaru ".
|
| 105 |
-
_SUBJECT_RE = re.compile(r"^[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\s+")
|
| 106 |
-
|
| 107 |
-
|
| 108 |
def _strip_subject(claim: str) -> str:
|
| 109 |
-
"""Drop a leading proper-noun subject so a subjectless source sentence can
|
| 110 |
-
|
| 111 |
-
|
| 112 |
return _SUBJECT_RE.sub("", claim)
|
| 113 |
|
| 114 |
|
| 115 |
def faithfulness_score(answer: str, source_context: str) -> float:
|
| 116 |
-
"""
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
score is the mean over claims (0-1) β robust to irrelevant passages (e.g. extra
|
| 123 |
-
web results), while contradicted or unsupported claims correctly fall toward 0."""
|
| 124 |
if not answer.strip() or not source_context.strip():
|
| 125 |
return 0.0
|
| 126 |
|
|
@@ -141,13 +111,9 @@ def faithfulness_score(answer: str, source_context: str) -> float:
|
|
| 141 |
pairs, owners = [], []
|
| 142 |
for i in range(len(claims)):
|
| 143 |
idxs = sims[i].topk(topk).indices.tolist()
|
| 144 |
-
best = evidence[idxs[0]]
|
| 145 |
-
concat = " ".join(evidence[j] for j in idxs)
|
| 146 |
premises = [best] if best == concat else [best, concat]
|
| 147 |
-
# Document bullets are often subjectless ("Architected a RAG pipeline"),
|
| 148 |
-
# while answers prepend the person's name ("Devi Sri Bandaru architected
|
| 149 |
-
# ..."), which NLI reads as unsupported. Also test a subject-stripped
|
| 150 |
-
# variant and keep the best.
|
| 151 |
variants = [claims[i]]
|
| 152 |
stripped = _strip_subject(claims[i])
|
| 153 |
if stripped != claims[i] and len(stripped) > 10:
|
|
@@ -157,9 +123,7 @@ def faithfulness_score(answer: str, source_context: str) -> float:
|
|
| 157 |
pairs.append((premise, variant))
|
| 158 |
owners.append(i)
|
| 159 |
|
| 160 |
-
|
| 161 |
-
entail = _softmax(np.asarray(model_nli.predict(pairs)))[:, _entail_idx]
|
| 162 |
-
|
| 163 |
per_claim = [0.0] * len(claims)
|
| 164 |
for owner, e in zip(owners, entail):
|
| 165 |
per_claim[owner] = max(per_claim[owner], float(e))
|
|
@@ -170,12 +134,12 @@ def faithfulness_score(answer: str, source_context: str) -> float:
|
|
| 170 |
|
| 171 |
|
| 172 |
def answer_relevance_score(question: str, answer: str) -> float:
|
| 173 |
-
"""
|
| 174 |
return _cosine(question, answer)
|
| 175 |
|
| 176 |
|
| 177 |
def accuracy_score(answer: str, reference: str) -> float:
|
| 178 |
-
"""ROUGE-L F1
|
| 179 |
if not answer.strip() or not reference.strip():
|
| 180 |
return 0.0
|
| 181 |
return round(_rouge.score(reference, answer)["rougeL"].fmeasure, 3)
|
|
|
|
| 1 |
+
"""Local answer-quality metrics: faithfulness (NLI), relevance (cosine), accuracy (ROUGE).
|
| 2 |
+
No LLM/API calls, so it stays cheap and CPU-friendly. Model libraries are imported lazily
|
| 3 |
+
inside the functions that need them, keeping module import (and CI) fast."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import re
|
| 6 |
import logging
|
| 7 |
import numpy as np
|
| 8 |
from rouge_score import rouge_scorer
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
+
# FEVER/ANLI-trained NLI model: reliable for fact verification, unlike smaller NLI models
|
| 13 |
+
# that mislabel subset/superset and compound claims.
|
| 14 |
_NLI_MODEL_NAME = "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli"
|
| 15 |
+
_TOP_EVIDENCE = 4
|
| 16 |
+
# Leading proper-noun subject of 2-4 capitalized words, e.g. "Devi Sri Bandaru ".
|
| 17 |
+
_SUBJECT_RE = re.compile(r"^[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\s+")
|
| 18 |
|
| 19 |
_nli_model = None
|
| 20 |
_entail_idx = None
|
| 21 |
_rouge = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
| 24 |
def _nli():
|
| 25 |
+
"""Load the NLI cross-encoder once and resolve its entailment label index
|
| 26 |
+
(label order differs between models, so read it from the config)."""
|
| 27 |
global _nli_model, _entail_idx
|
| 28 |
if _nli_model is None:
|
| 29 |
from sentence_transformers import CrossEncoder
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def _is_claim(text: str) -> bool:
|
| 55 |
+
"""Keep verifiable statements; drop list lead-ins and "(Source: file.pdf)" lines."""
|
| 56 |
+
if len(text) <= 15 or text.endswith(":"):
|
| 57 |
return False
|
| 58 |
+
return not re.match(r"^\(?\s*sources?\s*:", text, re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
def _split_sentences(text: str) -> list[str]:
|
| 62 |
+
"""Break an answer into individual claims, handling bullet lists and markdown."""
|
|
|
|
|
|
|
| 63 |
parts = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
|
| 64 |
cleaned = []
|
| 65 |
for p in parts:
|
| 66 |
p = p.lstrip("*-β’Β· \t")
|
| 67 |
+
p = re.sub(r"[*_`]+", "", p).strip()
|
| 68 |
cleaned.append(p)
|
| 69 |
return [p for p in cleaned if _is_claim(p)]
|
| 70 |
|
| 71 |
|
| 72 |
def _split_evidence(context: str) -> list[str]:
|
| 73 |
+
"""Source text split into single sentences (NLI is far more reliable per-sentence
|
| 74 |
+
than against a whole multi-sentence chunk). Source markers are stripped first."""
|
|
|
|
|
|
|
|
|
|
| 75 |
cleaned = re.sub(r"\[(?:File|Source)[^\]]*\]", " ", context)
|
| 76 |
parts = re.split(r"(?<=[.!?])\s+|\n+", cleaned.strip())
|
| 77 |
return [p.strip() for p in parts if len(p.strip()) > 15]
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def _strip_subject(claim: str) -> str:
|
| 81 |
+
"""Drop a leading proper-noun subject so a subjectless source sentence can still
|
| 82 |
+
entail the fact. Only ever used as an extra variant (we keep the max), so a wrong
|
| 83 |
+
strip never lowers the score."""
|
| 84 |
return _SUBJECT_RE.sub("", claim)
|
| 85 |
|
| 86 |
|
| 87 |
def faithfulness_score(answer: str, source_context: str) -> float:
|
| 88 |
+
"""Mean entailment of each answer claim against the source it was drawn from (0-1).
|
| 89 |
+
|
| 90 |
+
Each claim is tested against its most similar source sentences and their
|
| 91 |
+
concatenation, keeping the best match. A claim is faithful if some evidence entails
|
| 92 |
+
it, so the score is robust to irrelevant passages while contradicted or unsupported
|
| 93 |
+
claims fall toward 0."""
|
|
|
|
|
|
|
| 94 |
if not answer.strip() or not source_context.strip():
|
| 95 |
return 0.0
|
| 96 |
|
|
|
|
| 111 |
pairs, owners = [], []
|
| 112 |
for i in range(len(claims)):
|
| 113 |
idxs = sims[i].topk(topk).indices.tolist()
|
| 114 |
+
best = evidence[idxs[0]]
|
| 115 |
+
concat = " ".join(evidence[j] for j in idxs)
|
| 116 |
premises = [best] if best == concat else [best, concat]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
variants = [claims[i]]
|
| 118 |
stripped = _strip_subject(claims[i])
|
| 119 |
if stripped != claims[i] and len(stripped) > 10:
|
|
|
|
| 123 |
pairs.append((premise, variant))
|
| 124 |
owners.append(i)
|
| 125 |
|
| 126 |
+
entail = _softmax(np.asarray(_nli().predict(pairs)))[:, _entail_idx]
|
|
|
|
|
|
|
| 127 |
per_claim = [0.0] * len(claims)
|
| 128 |
for owner, e in zip(owners, entail):
|
| 129 |
per_claim[owner] = max(per_claim[owner], float(e))
|
|
|
|
| 134 |
|
| 135 |
|
| 136 |
def answer_relevance_score(question: str, answer: str) -> float:
|
| 137 |
+
"""Cosine similarity between the question and the answer (0-1)."""
|
| 138 |
return _cosine(question, answer)
|
| 139 |
|
| 140 |
|
| 141 |
def accuracy_score(answer: str, reference: str) -> float:
|
| 142 |
+
"""ROUGE-L F1 between the answer and a user-supplied reference (0-1)."""
|
| 143 |
if not answer.strip() or not reference.strip():
|
| 144 |
return 0.0
|
| 145 |
return round(_rouge.score(reference, answer)["rougeL"].fmeasure, 3)
|
src/main.py
CHANGED
|
@@ -117,8 +117,7 @@ async def chat(request: QueryRequest):
|
|
| 117 |
result = agent.invoke({"messages": messages})
|
| 118 |
answer = extract_content(result["messages"][-1])
|
| 119 |
|
| 120 |
-
# Some models occasionally
|
| 121 |
-
# retry before giving up so the user never sees a blank reply.
|
| 122 |
if not answer.strip():
|
| 123 |
logger.warning("Empty answer from model; retrying.")
|
| 124 |
if attempt < 2:
|
|
@@ -135,7 +134,6 @@ async def chat(request: QueryRequest):
|
|
| 135 |
relevance = answer_relevance_score(request.query, answer)
|
| 136 |
acc = accuracy_score(answer, request.reference) if request.reference else None
|
| 137 |
|
| 138 |
-
# Update conversation memory, summarizing older turns when it grows
|
| 139 |
memory.add_turn(request.query, answer)
|
| 140 |
try:
|
| 141 |
memory.summarize_if_needed(_summarizer(api_key))
|
|
@@ -163,7 +161,7 @@ async def chat(request: QueryRequest):
|
|
| 163 |
raise HTTPException(status_code=500, detail=error_str)
|
| 164 |
delay = retry_delay(error_str)
|
| 165 |
if delay and delay <= 120 and attempt < 2:
|
| 166 |
-
logger.warning(f"Rate limited
|
| 167 |
time.sleep(delay + 1)
|
| 168 |
continue
|
| 169 |
raise HTTPException(
|
|
|
|
| 117 |
result = agent.invoke({"messages": messages})
|
| 118 |
answer = extract_content(result["messages"][-1])
|
| 119 |
|
| 120 |
+
# Some models occasionally return an empty turn after a tool call; retry.
|
|
|
|
| 121 |
if not answer.strip():
|
| 122 |
logger.warning("Empty answer from model; retrying.")
|
| 123 |
if attempt < 2:
|
|
|
|
| 134 |
relevance = answer_relevance_score(request.query, answer)
|
| 135 |
acc = accuracy_score(answer, request.reference) if request.reference else None
|
| 136 |
|
|
|
|
| 137 |
memory.add_turn(request.query, answer)
|
| 138 |
try:
|
| 139 |
memory.summarize_if_needed(_summarizer(api_key))
|
|
|
|
| 161 |
raise HTTPException(status_code=500, detail=error_str)
|
| 162 |
delay = retry_delay(error_str)
|
| 163 |
if delay and delay <= 120 and attempt < 2:
|
| 164 |
+
logger.warning(f"Rate limited, retrying in {delay:.0f}s...")
|
| 165 |
time.sleep(delay + 1)
|
| 166 |
continue
|
| 167 |
raise HTTPException(
|
src/memory.py
CHANGED
|
@@ -1,19 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
Long chats are expensive: sending the whole history to the LLM on every turn
|
| 5 |
-
grows token cost without bound and eventually overflows the context window.
|
| 6 |
-
|
| 7 |
-
Strategy (sliding window + summary):
|
| 8 |
-
- Keep the most recent `keep_recent` turns verbatim.
|
| 9 |
-
- When the history grows past `max_turns`, fold the *older* turns into a running
|
| 10 |
-
natural-language summary (one cheap LLM call) and drop them from the window.
|
| 11 |
-
- Each request then sends: [summary] + [recent turns] + [new question] β bounded
|
| 12 |
-
in size no matter how long the conversation runs.
|
| 13 |
-
|
| 14 |
-
The summarizer is injected (a callable str -> str), so this class is pure and
|
| 15 |
-
unit-testable without any API calls.
|
| 16 |
-
"""
|
| 17 |
|
| 18 |
from dataclasses import dataclass, field
|
| 19 |
from typing import Callable
|
|
|
|
| 1 |
+
"""Per-session conversation memory. Recent turns are kept verbatim; older turns are
|
| 2 |
+
folded into a running summary so long chats stay within the model's context window."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
from typing import Callable
|
src/prefetch_models.py
CHANGED
|
@@ -1,14 +1,10 @@
|
|
| 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
|
| 11 |
get_sentence_transformer()
|
| 12 |
-
print("Prefetching NLI model (cross-encoder/nli-deberta-v3-small)...")
|
| 13 |
_nli()
|
| 14 |
-
print("
|
|
|
|
| 1 |
+
"""Download the embedding and NLI models at build time so the container runs offline."""
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from src.embeddings import get_sentence_transformer
|
| 4 |
from src.evaluator import _nli
|
| 5 |
|
| 6 |
if __name__ == "__main__":
|
| 7 |
+
print("Prefetching embedding and NLI models...")
|
| 8 |
get_sentence_transformer()
|
|
|
|
| 9 |
_nli()
|
| 10 |
+
print("Done.")
|
src/rag_engine.py
CHANGED
|
@@ -4,9 +4,6 @@ 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 |
|
|
|
|
| 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 |
|
src/utils.py
CHANGED
|
@@ -33,7 +33,7 @@ def parse_tool_results(messages: list) -> tuple[str, str]:
|
|
| 33 |
"""Return (source_type, combined_tool_output) from the agent message chain.
|
| 34 |
|
| 35 |
source_type is 'rag', 'web', 'rag+web', or 'unknown'. The combined output is
|
| 36 |
-
the actual text the tools returned
|
| 37 |
"""
|
| 38 |
rag_parts, web_parts = [], []
|
| 39 |
for msg in messages:
|
|
|
|
| 33 |
"""Return (source_type, combined_tool_output) from the agent message chain.
|
| 34 |
|
| 35 |
source_type is 'rag', 'web', 'rag+web', or 'unknown'. The combined output is
|
| 36 |
+
the actual text the tools returned, which faithfulness is measured against.
|
| 37 |
"""
|
| 38 |
rag_parts, web_parts = [], []
|
| 39 |
for msg in messages:
|
tests/evaluate.py
CHANGED
|
@@ -1,9 +1,6 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 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
|
|
@@ -22,10 +19,6 @@ except ImportError:
|
|
| 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 |
|
|
@@ -65,10 +58,6 @@ 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):
|
|
@@ -94,13 +83,8 @@ def get_context(question: str) -> str:
|
|
| 94 |
return _fallback_kb.retrieve(question)
|
| 95 |
|
| 96 |
|
| 97 |
-
#
|
| 98 |
-
#
|
| 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?",
|
|
@@ -123,13 +107,9 @@ TEST_CASES = [
|
|
| 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
|
| 133 |
|
| 134 |
api_key = os.getenv("GOOGLE_API_KEY")
|
| 135 |
if not api_key:
|
|
@@ -153,7 +133,6 @@ def run_evaluation(test_cases: list = None):
|
|
| 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])
|
|
@@ -167,7 +146,7 @@ def run_evaluation(test_cases: list = None):
|
|
| 167 |
|
| 168 |
print(f" Answer: {answer[:120]}...")
|
| 169 |
|
| 170 |
-
#
|
| 171 |
faithfulness_score, faithfulness_reason = "-", "N/A (web search question)"
|
| 172 |
if source == "rag":
|
| 173 |
try:
|
|
@@ -183,7 +162,7 @@ def run_evaluation(test_cases: list = None):
|
|
| 183 |
faithfulness_reason = str(e)
|
| 184 |
print(f" Faithfulness check failed: {e}")
|
| 185 |
|
| 186 |
-
#
|
| 187 |
accuracy_score, accuracy_reason = "-", "N/A (no ground truth)"
|
| 188 |
if ground_truth:
|
| 189 |
try:
|
|
|
|
| 1 |
+
"""Offline LLM-as-a-judge evaluation. Grades the agent on two metrics per question:
|
| 2 |
+
faithfulness (grounded in the retrieved context?) and accuracy (matches ground truth?).
|
| 3 |
+
Run from the project root: python -m tests.evaluate"""
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import sys
|
| 6 |
import os
|
|
|
|
| 19 |
sys.exit(1)
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
FAITHFULNESS_PROMPT = """\
|
| 23 |
You are evaluating whether an AI answer is grounded in the provided source context.
|
| 24 |
|
|
|
|
| 58 |
Reason: [one sentence]"""
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def extract_content(message) -> str:
|
| 62 |
content = message.content
|
| 63 |
if isinstance(content, list):
|
|
|
|
| 83 |
return _fallback_kb.retrieve(question)
|
| 84 |
|
| 85 |
|
| 86 |
+
# Add your own Q&A pairs here. Leave ground_truth as None for web questions
|
| 87 |
+
# (accuracy is then skipped, since there is nothing to compare against).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
TEST_CASES = [
|
| 89 |
{
|
| 90 |
"question": "What are the reporting requirements for State Parties?",
|
|
|
|
| 107 |
]
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def run_evaluation(test_cases: list = None):
|
| 111 |
cases = test_cases or TEST_CASES
|
| 112 |
+
print(f"Starting evaluation ({len(cases)} test case(s))\n")
|
| 113 |
|
| 114 |
api_key = os.getenv("GOOGLE_API_KEY")
|
| 115 |
if not api_key:
|
|
|
|
| 133 |
|
| 134 |
print(f"[{i}/{len(cases)}] {question}")
|
| 135 |
|
|
|
|
| 136 |
try:
|
| 137 |
result = agent.invoke({"messages": [("user", question)]})
|
| 138 |
answer = extract_content(result["messages"][-1])
|
|
|
|
| 146 |
|
| 147 |
print(f" Answer: {answer[:120]}...")
|
| 148 |
|
| 149 |
+
# Faithfulness only applies to document-grounded answers
|
| 150 |
faithfulness_score, faithfulness_reason = "-", "N/A (web search question)"
|
| 151 |
if source == "rag":
|
| 152 |
try:
|
|
|
|
| 162 |
faithfulness_reason = str(e)
|
| 163 |
print(f" Faithfulness check failed: {e}")
|
| 164 |
|
| 165 |
+
# Accuracy only when a ground truth is provided
|
| 166 |
accuracy_score, accuracy_reason = "-", "N/A (no ground truth)"
|
| 167 |
if ground_truth:
|
| 168 |
try:
|
tests/test_evaluator.py
CHANGED
|
@@ -1,8 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
The embedding/NLI metrics are intentionally not exercised here so CI stays fast
|
| 4 |
-
and offline β they require model downloads and are validated manually.
|
| 5 |
-
"""
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
from src.evaluator import accuracy_score, _split_sentences, _softmax
|
|
|
|
| 1 |
+
"""Unit tests for the lightweight evaluator pieces (ROUGE accuracy and text helpers).
|
| 2 |
+
The embedding/NLI metrics need model downloads, so they are validated manually, not in CI."""
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
import numpy as np
|
| 5 |
from src.evaluator import accuracy_score, _split_sentences, _softmax
|