Devisri515 commited on
Commit
2b63102
Β·
1 Parent(s): 834a723
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 produce thousands of chunks; embedding them on CPU can take
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 β€” this file is large and CPU embedding is slow. "
45
- "Wait a moment and try your question; it may already be indexed.")
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 β€” answer may contain content not present in the {what}"
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 β€” some key points differ from your reference"
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 β€” grounded in documents" if source != "web" else "Faithfulness β€” grounded in web results"
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 β€” addresses the question", answer_relevance, _relevance_hint(answer_relevance)))
106
 
107
  if accuracy is not None:
108
- lines.append(_fmt("Accuracy β€” matches your reference", accuracy, _accuracy_hint(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 β€” answered via {src_label}", metrics_md
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
- # Per-browser-session id for server-side conversation memory
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 β€” it is sent only with your requests and never stored. "
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)}** β€” multiple files allowed, new uploads add to the index. \n"
200
- "_Large PDFs (100s of pages) can take a few minutes to index on the free CPU β€” please be patient._"
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 β€” **no extra API calls, no reference needed** for the first two.\n\n"
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
- # Default to a free-tier model that reliably produces a final answer after tool
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 β€” do not invent facts. If the documents "
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 for a given key β€” reused by the agent and the summarizer."""
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
- """Build (and cache) a LangGraph ReAct agent for the given Gemini API key.
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 langchain embeddings object for FAISS vector stores."""
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 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)
 
 
 
 
 
 
 
 
 
 
 
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
- 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
 
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 β€” reliable for fact verification (handles
25
- # subset/superset and compound claims, which the smaller NLI models miss).
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
- """Lazily load the NLI cross-encoder and resolve its 'entailment' label index
37
- (label order varies between models, so we read it from the config)."""
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 factual statements; drop framing and meta lines."""
67
- if len(text) <= 15:
68
  return False
69
- if text.endswith(":"): # lead-in to a list
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
- # Split on sentence punctuation and newlines so bulleted/list answers
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() # strip markdown emphasis (**bold**, *italic*, `code`)
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
- """Break the retrieved context into individual evidence sentences.
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
- still entail the fact. Used only as an extra variant (we keep the max), so a
111
- wrong strip never lowers the score."""
112
  return _SUBJECT_RE.sub("", claim)
113
 
114
 
115
  def faithfulness_score(answer: str, source_context: str) -> float:
116
- """RAGAS-style faithfulness: is each claim in the answer supported by the source?
117
-
118
- For every answer sentence (claim) we take its most similar source sentences and
119
- test entailment against each one *and* their concatenation, keeping the best
120
- (max). Single sentences support simple/verbatim claims; the concatenation
121
- supports compound claims that combine facts from several source sentences. The
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]] # supports simple claims
145
- concat = " ".join(evidence[j] for j in idxs) # supports compound claims
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
- model_nli = _nli()
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
- """Does the answer address the question? Cosine similarity (0–1)."""
174
  return _cosine(question, answer)
175
 
176
 
177
  def accuracy_score(answer: str, reference: str) -> float:
178
- """ROUGE-L F1 vs a user-supplied reference answer (0–1)."""
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 emit an empty final turn after a tool call;
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 β€” retrying in {delay:.0f}s...")
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
- Conversation memory with rolling summarization.
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 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.")
 
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 β€” what faithfulness is measured against.
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
- 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
@@ -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
- # 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?",
@@ -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 β€” {len(cases)} test case(s)\n")
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
- # 2. Faithfulness check (hallucination detection) β€” RAG questions only
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
- # 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:
 
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
- Unit tests for the lightweight evaluator pieces (ROUGE accuracy + text helpers).
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