devrajsinh2012 commited on
Commit
632bdc4
·
verified ·
1 Parent(s): 161b8cd

feat: deploy Phase 1 architecture fixes and Phase 2 dataset collection & evaluation infrastructure

Browse files
MEXAR_Phase1_Architecture_Fixes.md ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MEXAR Phase 1 — Architecture Fixes to Match the Paper
2
+
3
+ **Purpose of this document:** hand this to an AI coding agent (Antigravity or similar) working directly in the `Mexar` repository (`github.com/devrajsinh2012/Mexar`). This is Phase 1 only: it closes the gap between what the paper *"Hybrid Retrieval and Claim Verification: An Empirical Study of Constrained RAG for High-Stakes Professional Domains"* claims about the system's architecture, and what the code currently does. No dataset work in this document, that's Phase 2, handled separately.
4
+
5
+ **Presentation date:** July 30-31, 2026. Target for this phase: 3 days.
6
+
7
+ **Ground rule for the agent:** every change below must keep the app deployable on the current stack (FastAPI backend on Hugging Face Spaces, Supabase Postgres + pgvector, React frontend on Vercel). Don't introduce services that need infra Dev doesn't already have.
8
+
9
+ ---
10
+
11
+ ## 1.0 Preconditions
12
+
13
+ - Add to `backend/requirements.txt`:
14
+ ```
15
+ scikit-learn>=1.4.0
16
+ spacy>=3.7.0
17
+ ```
18
+ - After install, download the small English model: `python -m spacy download en_core_web_sm`. Add this as a step in `Dockerfile` so it survives Hugging Face Spaces rebuilds.
19
+ - Verify the pgvector extension version on the Supabase project supports HNSW (needs pgvector >= 0.5.0). Run `SELECT extversion FROM pg_extension WHERE extname = 'vector';`. If below 0.5.0, run `ALTER EXTENSION vector UPDATE;` before touching the index migration in 1.2.
20
+
21
+ ---
22
+
23
+ ## 1.1 Domain Guardrail: replace the heuristic with real TF-IDF + NER + Jaccard
24
+
25
+ **Why:** Paper Section III-A defines the guardrail as TF-IDF term weighting (Eq. 1), NER-extracted domain entities, and Jaccard similarity between query and signature (Eq. 2). The current `_check_guardrail()` in `backend/modules/reasoning_engine.py` is a hand-tuned fuzzy-string-match heuristic with arbitrary bonus weights and no TF-IDF or NER anywhere. This needs to become the real thing.
26
+
27
+ **New file: `backend/utils/domain_signature.py`**
28
+
29
+ ```python
30
+ """
31
+ MEXAR - Domain Signature Construction
32
+ Implements Section III-A: TF-IDF lexical signature + NER entity signature.
33
+ """
34
+ from typing import List, Dict, Set, Tuple
35
+ from sklearn.feature_extraction.text import TfidfVectorizer
36
+ import spacy
37
+ import logging
38
+
39
+ logger = logging.getLogger(__name__)
40
+ _nlp = None
41
+
42
+ def get_nlp():
43
+ global _nlp
44
+ if _nlp is None:
45
+ _nlp = spacy.load("en_core_web_sm")
46
+ return _nlp
47
+
48
+ # Entity labels relevant to professional domains (medical/legal/financial)
49
+ RELEVANT_ENTITY_LABELS = {"ORG", "LAW", "PRODUCT", "GPE", "PERSON", "NORP", "EVENT", "FAC"}
50
+
51
+ def build_tfidf_signature(documents: List[str], top_n: int = 100, tau_tf: float = 0.0) -> List[Tuple[str, float]]:
52
+ """
53
+ Eq. 1: w(t, D) = tf(t, D) * log(|D| / |{d in D : t in d}|)
54
+ Returns top_n (term, weight) pairs above tau_tf, sorted descending by weight.
55
+ """
56
+ if not documents or len(documents) < 2:
57
+ # TF-IDF needs a corpus of multiple "documents" to compute IDF meaningfully.
58
+ # If compiling from a single uploaded file, split it into paragraph-level
59
+ # pseudo-documents so IDF is not degenerate.
60
+ documents = _split_into_pseudo_documents(documents[0]) if documents else []
61
+
62
+ vectorizer = TfidfVectorizer(
63
+ max_features=2000,
64
+ stop_words="english",
65
+ ngram_range=(1, 2),
66
+ min_df=1,
67
+ )
68
+ matrix = vectorizer.fit_transform(documents)
69
+ scores = matrix.sum(axis=0).A1
70
+ terms = vectorizer.get_feature_names_out()
71
+ pairs = [(t, float(s)) for t, s in zip(terms, scores) if s > tau_tf]
72
+ pairs.sort(key=lambda x: x[1], reverse=True)
73
+ return pairs[:top_n]
74
+
75
+ def _split_into_pseudo_documents(text: str, chunk_size: int = 500) -> List[str]:
76
+ words = text.split()
77
+ return [" ".join(words[i:i+chunk_size]) for i in range(0, len(words), chunk_size)] or [text]
78
+
79
+ def extract_ner_entities(documents: List[str], tau_ent: int = 2, max_docs_to_scan: int = 200) -> List[str]:
80
+ """
81
+ Extracts named entities appearing in at least tau_ent documents.
82
+ Capped at max_docs_to_scan for latency (spaCy is not free at compile time).
83
+ """
84
+ nlp = get_nlp()
85
+ doc_freq: Dict[str, int] = {}
86
+ for doc_text in documents[:max_docs_to_scan]:
87
+ seen_in_this_doc: Set[str] = set()
88
+ for ent in nlp(doc_text[:5000]).ents: # cap per-doc length for speed
89
+ if ent.label_ in RELEVANT_ENTITY_LABELS:
90
+ key = ent.text.lower().strip()
91
+ if key and key not in seen_in_this_doc:
92
+ seen_in_this_doc.add(key)
93
+ for key in seen_in_this_doc:
94
+ doc_freq[key] = doc_freq.get(key, 0) + 1
95
+ return [k for k, v in doc_freq.items() if v >= tau_ent]
96
+
97
+ def build_domain_signature(
98
+ documents: List[str],
99
+ tau_tf: float = 0.0,
100
+ tau_ent: int = 2,
101
+ top_n_lexical: int = 100,
102
+ ) -> Dict[str, List]:
103
+ """Returns Sigma = Sigma_lex ∪ Sigma_ent as described in Section III-A, kept separate for inspection."""
104
+ lexical_pairs = build_tfidf_signature(documents, top_n=top_n_lexical, tau_tf=tau_tf)
105
+ lexical_terms = [t for t, _ in lexical_pairs]
106
+ entities = extract_ner_entities(documents, tau_ent=tau_ent)
107
+ return {
108
+ "lexical": lexical_terms,
109
+ "lexical_weights": {t: w for t, w in lexical_pairs},
110
+ "entities": entities,
111
+ "combined": sorted(set(lexical_terms) | set(e for e in entities)),
112
+ }
113
+ ```
114
+
115
+ **Modify `backend/modules/knowledge_compiler.py`:**
116
+ Replace `_extract_domain_signature()` so it calls `build_domain_signature()` from the new module, passing in the raw per-source-document text list (not the flattened `text_context` string, we need per-document granularity for TF-IDF's document-frequency term to mean anything). Store the result's `combined` list in the existing `domain_signature` JSONB column, and add the `lexical_weights` dict to a **new** JSONB column (see migration below) so the guardrail can compute a weighted Jaccard rather than a flat one if useful later.
117
+
118
+ **New migration** (Alembic or raw SQL depending on how `backend/migrations` is structured, check `backend/migrations/README.md` for the convention already in use):
119
+ ```sql
120
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS domain_signature_weights JSONB;
121
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS domain_entities JSONB;
122
+ ```
123
+
124
+ **Rewrite `_check_guardrail()` in `backend/modules/reasoning_engine.py`:**
125
+ Delete the fuzzy-match/bonus-score implementation entirely. Replace with real Eq. 2:
126
+
127
+ ```python
128
+ def _check_guardrail(self, query: str, domain_signature: List[str], prompt_analysis: Dict[str, Any]) -> Tuple[bool, float]:
129
+ """Eq. 2: sim(q, Sigma) = |Phi(q) ∩ Sigma| / |Phi(q)|"""
130
+ from utils.domain_signature import get_nlp
131
+ nlp = get_nlp()
132
+ query_doc = nlp(query.lower())
133
+ # Phi(q): lemmatized content tokens, excluding stopwords/punctuation
134
+ phi_q = {
135
+ tok.lemma_ for tok in query_doc
136
+ if not tok.is_stop and not tok.is_punct and len(tok.text) > 2
137
+ }
138
+ sigma = set(s.lower() for s in (domain_signature or []))
139
+
140
+ if not phi_q:
141
+ return False, 0.0
142
+
143
+ intersection = phi_q & sigma
144
+ score = len(intersection) / len(phi_q)
145
+
146
+ is_in_domain = score >= self.DOMAIN_SIMILARITY_THRESHOLD
147
+ logger.info(f"Guardrail: sim={score:.3f}, |Phi(q)|={len(phi_q)}, |intersection|={len(intersection)}, in_domain={is_in_domain}")
148
+ return is_in_domain, score
149
+ ```
150
+
151
+ **Tune `DOMAIN_SIMILARITY_THRESHOLD`:** the current 0.05 was calibrated against the *old* heuristic scorer, it will not transfer to real Jaccard similarity, which tends to produce lower absolute values on short queries. Don't guess this: after wiring the new guardrail, run `backend/evaluation/guardrail_analysis.py` against a small hand-labeled set of ~20 in-domain and ~20 out-of-domain queries per existing test_data domain, sweep the threshold from 0.05 to 0.4 in steps of 0.05, and pick the value that maximizes F1. Record that sweep, it becomes supporting evidence for Table IV in the paper instead of a guessed constant.
152
+
153
+ **Acceptance test:** for the medical test agent, a query like "what is the recommended treatment for stage 2 hypertension" should be accepted; a query like "how do I file a small claims lawsuit" should be rejected. Log both the old and new guardrail score during a transition period to sanity-check before removing the old code path.
154
+
155
+ ---
156
+
157
+ ## 1.2 Vector index: IVFFlat → HNSW
158
+
159
+ **Why:** Paper Section III-B explicitly claims HNSW indexing. The current migration (`backend/migrations/hybrid_search_function.sql`, lines ~98-101) creates an IVFFlat index instead.
160
+
161
+ **New migration file** `backend/migrations/switch_to_hnsw_index.sql`:
162
+ ```sql
163
+ -- Drop the old IVFFlat index
164
+ DROP INDEX IF EXISTS idx_document_chunks_embedding;
165
+
166
+ -- Create HNSW index to match paper Section III-B
167
+ CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding_hnsw
168
+ ON document_chunks USING hnsw (embedding vector_cosine_ops)
169
+ WITH (m = 16, ef_construction = 64);
170
+
171
+ COMMENT ON INDEX idx_document_chunks_embedding_hnsw IS
172
+ 'HNSW index for cosine similarity search, m=16, ef_construction=64 (Section III-B)';
173
+ ```
174
+ Apply via `Supabase:apply_migration` (or however existing migrations are run, check for a runner script referenced in `backend/migrations/README.md`) rather than editing the old file, so history is preserved.
175
+
176
+ **Note for the agent:** HNSW build time scales worse than IVFFlat with corpus size, but at the 500-600 document / few-thousand-chunk scale planned for the presentation, this is a non-issue. No further tuning needed.
177
+
178
+ **Acceptance test:** `\d document_chunks` in psql (or `SELECT indexdef FROM pg_indexes WHERE tablename = 'document_chunks';`) should show the HNSW index, not IVFFlat. Re-run a handful of existing queries and confirm retrieval still returns coherent top-5 chunks (HNSW is approximate, results may reorder slightly versus IVFFlat, that's expected and fine).
179
+
180
+ ---
181
+
182
+ ## 1.3 RRF weighting: reconcile code and paper
183
+
184
+ **Why:** Paper Eq. 5 is unweighted RRF. `backend/migrations/hybrid_search_function.sql` applies a 0.6 semantic / 0.4 keyword weighting inside the SQL function.
185
+
186
+ **Decision for this project (recommended, don't re-litigate):** keep the 0.6/0.4 weighting in code, it's a reasonable and defensible design choice, and update the paper instead. This is less risky than ripping out a tuned weighting under time pressure. The agent does not need to touch `hybrid_search_function.sql` for this item, just flag it in a "paper edits needed" note for later.
187
+
188
+ If Dev instead decides to match the paper exactly, the one-line change is setting `semantic_weight := 0.5; keyword_weight := 0.5;` in that SQL function, but this is **not** the default action, don't do it unless explicitly told.
189
+
190
+ ---
191
+
192
+ ## 1.4 Faithfulness verification: make DeBERTa-v3 NLI the real, primary signal
193
+
194
+ **Why:** this is the most important fix. Paper Section III-C and the entire Related Work argument against LLM-judging-LLM circularity claim the production system verifies claims with "an NLI model (DeBERTa-v3-large fine-tuned on MNLI)" using per-document max entailment (Eq. 6). In the actual code:
195
+ - The primary confidence-driving call in `reasoning_engine.reason()` (Step 6) is `self.faithfulness_scorer.score(answer, context)`, which is `FaithfulnessScorer` in `backend/utils/faithfulness.py`, an LLM (Groq) extracting claims and then Groq again answering YES/NO. That's exactly the circularity the paper argues against.
196
+ - The actual NLI model in the repo, `BartNLIScorer`, uses `facebook/bart-large-mnli`, not DeBERTa-v3-large, and it's computed only as a side "reviewer feedback" score (`bart_nli_result` in `reason()`), it never feeds the confidence calculation.
197
+ - The NLI scorer concatenates all retrieved context into a single premise string, it does not implement the per-document max in Eq. 6.
198
+
199
+ **Model choice:** use `cross-encoder/nli-deberta-v3-large` from Hugging Face. This is a DeBERTa-v3-large architecture fine-tuned specifically for NLI (SNLI + MultiNLI), it is the standard, citable checkpoint people mean when they say "DeBERTa-v3-large fine-tuned on MNLI" in RAG faithfulness papers, and it loads cleanly through `sentence-transformers`'s `CrossEncoder` class, which is already a dependency.
200
+
201
+ **Modify `backend/utils/faithfulness.py`:** replace the `BartNLIScorer` class with:
202
+
203
+ ```python
204
+ class DebertaNLIScorer:
205
+ """
206
+ Primary faithfulness verifier per Section III-C.
207
+ Per-claim, per-document max entailment probability (Eq. 6),
208
+ aggregated as the fraction of claims exceeding tau_ent (Eq. 7).
209
+ """
210
+ LABEL_MAP = {0: "contradiction", 1: "entailment", 2: "neutral"} # verify against model card at load time
211
+ TAU_ENT = 0.7
212
+
213
+ def __init__(self):
214
+ self._model = None
215
+
216
+ @property
217
+ def model(self):
218
+ if self._model is None:
219
+ from sentence_transformers import CrossEncoder
220
+ logger.info("Loading cross-encoder/nli-deberta-v3-large...")
221
+ self._model = CrossEncoder("cross-encoder/nli-deberta-v3-large")
222
+ logger.info("DeBERTa-v3 NLI model loaded.")
223
+ return self._model
224
+
225
+ def score(self, answer: str, context_documents: List[str]) -> FaithfulnessResult:
226
+ """
227
+ context_documents: list of individual retrieved chunk texts (NOT concatenated),
228
+ so per-document max in Eq. 6 is meaningful.
229
+ """
230
+ import re
231
+ import numpy as np
232
+
233
+ if not answer or not context_documents:
234
+ return FaithfulnessResult(score=1.0, total_claims=0, supported_claims=0, unsupported_claims=[])
235
+
236
+ sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', answer) if len(s.strip()) > 15][:10]
237
+ if not sentences:
238
+ return FaithfulnessResult(score=1.0, total_claims=0, supported_claims=0, unsupported_claims=[])
239
+
240
+ supported = 0
241
+ unsupported = []
242
+
243
+ for sentence in sentences:
244
+ pairs = [(doc[:2000], sentence) for doc in context_documents]
245
+ scores = self.model.predict(pairs) # shape: (n_docs, 3) logits/probs per label
246
+ probs = self._softmax(scores)
247
+ entailment_idx = 1 # confirm this matches the model's actual label order at integration time
248
+ max_entailment = float(np.max(probs[:, entailment_idx]))
249
+ if max_entailment > self.TAU_ENT:
250
+ supported += 1
251
+ else:
252
+ unsupported.append(sentence)
253
+
254
+ score = supported / len(sentences)
255
+ return FaithfulnessResult(
256
+ score=round(score, 3),
257
+ total_claims=len(sentences),
258
+ supported_claims=supported,
259
+ unsupported_claims=unsupported[:5],
260
+ )
261
+
262
+ @staticmethod
263
+ def _softmax(x):
264
+ import numpy as np
265
+ e_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
266
+ return e_x / e_x.sum(axis=-1, keepdims=True)
267
+ ```
268
+
269
+ **Important integration detail for the agent:** verify `cross-encoder/nli-deberta-v3-large`'s actual `id2label` mapping from its `config.json` on first load and print it to logs, cross-encoder NLI checkpoints don't always use the same label order. Don't hardcode `entailment_idx = 1` blindly, confirm it, this is exactly the kind of silent bug that would quietly invalidate the reported faithfulness numbers.
270
+
271
+ **Modify `backend/modules/reasoning_engine.py`, `reason()` method:**
272
+ - Rename the old `FaithfulnessScorer` usage. Keep the class in the codebase (don't delete it), but relabel its role: it becomes the **"LLM-as-judge baseline"** used for baseline comparison tables (this is genuinely useful, RAGAS-style LLM self-eval is a legitimate baseline to compare against in Table I/III, just not the production signal).
273
+ - Swap Step 6 to call `self.deberta_nli_scorer.score(answer, [c.content for c in top_chunks])` (passing the list of individual chunks, not the concatenated `context` string) as the **primary** `faithfulness_result` used in `_calculate_confidence()` and in the returned `explainability` payload.
274
+ - Keep computing the old LLM-based score too, but store it under a clearly-labeled `"llm_judge_baseline_score"` key in `explainability.confidence_breakdown`, not as the headline faithfulness number.
275
+
276
+ **Acceptance test:** feed a known-hallucinated answer (write one manually, e.g. an answer that states a fabricated statistic not present in any retrieved chunk) through the pipeline and confirm the DeBERTa scorer flags it as unsupported. Feed a fully-grounded answer and confirm it scores above `TAU_ENT`. Compare against the LLM-judge baseline score on the same pair, they should usually agree, but if they diverge a lot on the same input, that's worth investigating before trusting either for the paper's numbers.
277
+
278
+ ---
279
+
280
+ ## 1.5 Wiring check
281
+
282
+ After 1.1-1.4 are done, re-run the existing `backend/evaluation/baseline_runner.py` and `backend/evaluation/benchmark_runner.py` against the current small `test_data/` samples (not for final numbers, just as a smoke test) and confirm nothing throws. Fix any interface mismatches from the `context: str` → `context_documents: List[str]` signature change in the faithfulness scorer, other call sites (`evaluation/metrics.py`, `_run_baseline()` in `reasoning_engine.py`) pass a concatenated string today and will need the same list-of-chunks treatment.
283
+
284
+ ---
285
+
286
+ ## Summary of files touched in Phase 1
287
+
288
+ | File | Change |
289
+ |---|---|
290
+ | `backend/requirements.txt` | add scikit-learn, spacy |
291
+ | `backend/Dockerfile` | add spacy model download step |
292
+ | `backend/utils/domain_signature.py` | **new** — TF-IDF + NER signature builder |
293
+ | `backend/modules/knowledge_compiler.py` | `_extract_domain_signature()` rewritten to use new module |
294
+ | `backend/modules/reasoning_engine.py` | `_check_guardrail()` rewritten to real Jaccard; Step 6 rewired to DeBERTa scorer |
295
+ | `backend/utils/faithfulness.py` | `BartNLIScorer` replaced with `DebertaNLIScorer` (per-doc max, Eq. 6/7) |
296
+ | `backend/migrations/switch_to_hnsw_index.sql` | **new** — HNSW index migration |
297
+ | `backend/migrations/*` (new file) | add `domain_signature_weights`, `domain_entities` columns |
298
+ | `backend/evaluation/*` | update call sites for the `context: str` → `context_documents: List[str]` signature change |
299
+
300
+ ---
301
+
302
+ ## What Phase 1 does NOT cover (handled in the separate Phase 2 document)
303
+
304
+ - Fetching real medical/legal/financial documents
305
+ - Building the query sets
306
+ - Running the actual evaluation to generate paper numbers
307
+ - Rewriting the paper's dataset section
308
+
309
+ Phase 1 is architecture only. Once this is done and verified against the small existing `test_data/` samples, move to the Phase 2 document to bring in real data at the ~500-600 document scale.
MEXAR_Phase2_Dataset_Collection.md ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MEXAR Phase 2 — Real Datasets at Reduced, Disclosed Scale
2
+
3
+ **Purpose of this document:** hand this to an AI coding agent (Antigravity or similar) working directly in the `Mexar` repository (`github.com/devrajsinh2012/Mexar`). This is Phase 2 only: it brings in real documents and real queries so the numbers in the paper are reproducible. Assumes Phase 1 (architecture fixes: TF-IDF/NER guardrail, HNSW index, DeBERTa faithfulness scorer) is already done and verified.
4
+
5
+ **Presentation date:** July 30-31, 2026. Target for this phase: days 4-11 (about a week).
6
+
7
+ ---
8
+
9
+ ## 2.0 Scope decision (already agreed with Dev)
10
+
11
+ Target **500-600 total documents** across the three domains for the presentation, not the 5,226 stated in the current paper draft. This is a resource-constrained pilot, and the paper's dataset section (IV-A) gets rewritten to say so honestly. Query sets are also reduced and single-annotator (Dev/Om/Jay), not the 900+600 two-expert-annotated set currently described. This is a normal, defensible thing to disclose in a paper, and far safer than presenting numbers nobody can reproduce live.
12
+
13
+ **Target split:**
14
+
15
+ | Domain | Target docs | Source |
16
+ |---|---|---|
17
+ | Medical | ~220 | PubMed Central OA subset |
18
+ | Legal | ~150 | CourtListener (Free Law Project) |
19
+ | Financial | ~130 | SEC EDGAR full-text search |
20
+ | **Total** | **~500** | |
21
+
22
+ **Important for the agent:** these fetch scripts hit external APIs (PubMed E-utilities, CourtListener, SEC EDGAR) that are outside a typical restricted sandbox's allowed network domains. Run these scripts in Dev's own local dev environment or a CI runner with normal internet access, not inside a network-restricted container.
23
+
24
+ ---
25
+
26
+ ## 2.1 Medical corpus — PubMed Central OA
27
+
28
+ **New script:** `backend/scripts/fetch_pubmed.py`
29
+
30
+ - Use the NCBI E-utilities API (`esearch` + `efetch`), no key required for low-volume use, but request one from NCBI if fetching more than 3 requests/sec.
31
+ - Query terms per subdomain, run three separate searches and cap results:
32
+ - `"cardiology treatment guideline"[Title/Abstract] AND "open access"[filter]`
33
+ - `"oncology treatment protocol"[Title/Abstract] AND "open access"[filter]`
34
+ - `"internal medicine diagnostic criteria"[Title/Abstract] AND "open access"[filter]`
35
+ - Pull ~75 docs per subdomain (≈220 total).
36
+ - Save each as plain text (abstract + body if available via the OA subset bulk XML) into `test_data/medical_real/`, one file per document, named by PMCID, e.g. `PMC1234567.txt`.
37
+ - Respect NCBI's rate limits (3 req/sec without an API key, 10/sec with one) and identify yourself via the `email` parameter on every request, per NCBI's usage policy.
38
+ - Log a manifest file `test_data/medical_real/manifest.json` mapping PMCID → title → subdomain → source URL, this becomes supporting material for the paper's dataset section and makes the corpus auditable.
39
+
40
+ **Skeleton:**
41
+ ```python
42
+ """
43
+ MEXAR - PubMed Central OA fetcher for the medical corpus.
44
+ Run this locally (not in a network-restricted sandbox).
45
+ """
46
+ import requests
47
+ import time
48
+ import json
49
+ import os
50
+
51
+ NCBI_EMAIL = "your_email@example.com" # required by NCBI usage policy
52
+ OUTPUT_DIR = "test_data/medical_real"
53
+ SEARCH_TERMS = {
54
+ "cardiology": '"cardiology treatment guideline"[Title/Abstract] AND "open access"[filter]',
55
+ "oncology": '"oncology treatment protocol"[Title/Abstract] AND "open access"[filter]',
56
+ "internal_medicine": '"internal medicine diagnostic criteria"[Title/Abstract] AND "open access"[filter]',
57
+ }
58
+ DOCS_PER_SUBDOMAIN = 75
59
+
60
+ def esearch(term, retmax):
61
+ url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
62
+ params = {"db": "pmc", "term": term, "retmax": retmax, "retmode": "json", "email": NCBI_EMAIL}
63
+ r = requests.get(url, params=params)
64
+ r.raise_for_status()
65
+ return r.json()["esearchresult"]["idlist"]
66
+
67
+ def efetch_fulltext(pmc_id):
68
+ url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
69
+ params = {"db": "pmc", "id": pmc_id, "rettype": "full", "retmode": "xml", "email": NCBI_EMAIL}
70
+ r = requests.get(url, params=params)
71
+ r.raise_for_status()
72
+ return r.text # parse out body text with an XML parser before saving as plain text
73
+
74
+ def main():
75
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
76
+ manifest = []
77
+ for subdomain, term in SEARCH_TERMS.items():
78
+ ids = esearch(term, DOCS_PER_SUBDOMAIN)
79
+ for pmc_id in ids:
80
+ time.sleep(0.34) # stay under 3 req/sec
81
+ try:
82
+ xml_text = efetch_fulltext(pmc_id)
83
+ # TODO: parse XML body text out with e.g. lxml, strip tags, save plain text
84
+ plain_text = xml_text # placeholder, replace with real extraction
85
+ out_path = os.path.join(OUTPUT_DIR, f"PMC{pmc_id}.txt")
86
+ with open(out_path, "w", encoding="utf-8") as f:
87
+ f.write(plain_text)
88
+ manifest.append({"pmc_id": pmc_id, "subdomain": subdomain, "path": out_path})
89
+ except Exception as e:
90
+ print(f"Failed to fetch PMC{pmc_id}: {e}")
91
+ with open(os.path.join(OUTPUT_DIR, "manifest.json"), "w") as f:
92
+ json.dump(manifest, f, indent=2)
93
+ print(f"Fetched {len(manifest)} medical documents.")
94
+
95
+ if __name__ == "__main__":
96
+ main()
97
+ ```
98
+ The agent should replace the placeholder XML-to-text extraction with a real parser (e.g. `lxml` or `BeautifulSoup`) that pulls the `<body>` text out of the PMC XML format and strips markup, don't save raw XML as the "plain text" corpus.
99
+
100
+ ---
101
+
102
+ ## 2.2 Legal corpus — CourtListener
103
+
104
+ **New script:** `backend/scripts/fetch_courtlistener.py`
105
+
106
+ - Use the CourtListener REST API (`https://www.courtlistener.com/api/rest/v4/`), requires a free account and API token (set as `COURTLISTENER_TOKEN` env var).
107
+ - Pull opinions/case summaries across contract law, IP, and corporate governance search terms, plus a handful of statutory/regulatory text pages if accessible via their `opinions` and `search` endpoints.
108
+ - Target ~50 docs per subdomain (≈150 total).
109
+ - Save the opinion text (or summary if full text is very long, truncate to a reasonable chunking size, e.g. first 3000 words) into `test_data/legal_real/`, one file per case, named by CourtListener opinion ID.
110
+ - Same manifest pattern as 2.1: `test_data/legal_real/manifest.json` with case name, subdomain, court, date, source URL.
111
+
112
+ **Skeleton:**
113
+ ```python
114
+ """
115
+ MEXAR - CourtListener fetcher for the legal corpus.
116
+ Requires COURTLISTENER_TOKEN env var. Run locally.
117
+ """
118
+ import requests
119
+ import os
120
+ import json
121
+
122
+ TOKEN = os.environ["COURTLISTENER_TOKEN"]
123
+ HEADERS = {"Authorization": f"Token {TOKEN}"}
124
+ BASE_URL = "https://www.courtlistener.com/api/rest/v4"
125
+ OUTPUT_DIR = "test_data/legal_real"
126
+ SEARCH_TERMS = {
127
+ "contract_law": "breach of contract damages",
128
+ "intellectual_property": "patent infringement trademark",
129
+ "corporate_governance": "fiduciary duty shareholder derivative",
130
+ }
131
+ DOCS_PER_SUBDOMAIN = 50
132
+
133
+ def search_opinions(query, count):
134
+ url = f"{BASE_URL}/search/"
135
+ params = {"q": query, "type": "o", "order_by": "score desc"}
136
+ results = []
137
+ while len(results) < count:
138
+ r = requests.get(url, headers=HEADERS, params=params)
139
+ r.raise_for_status()
140
+ data = r.json()
141
+ results.extend(data["results"])
142
+ if not data.get("next"):
143
+ break
144
+ url = data["next"]
145
+ params = {}
146
+ return results[:count]
147
+
148
+ def main():
149
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
150
+ manifest = []
151
+ for subdomain, term in SEARCH_TERMS.items():
152
+ opinions = search_opinions(term, DOCS_PER_SUBDOMAIN)
153
+ for op in opinions:
154
+ text = op.get("plain_text") or op.get("snippet", "")
155
+ if not text:
156
+ continue
157
+ op_id = op.get("id") or op.get("cluster_id")
158
+ out_path = os.path.join(OUTPUT_DIR, f"case_{op_id}.txt")
159
+ with open(out_path, "w", encoding="utf-8") as f:
160
+ f.write(text[:15000]) # cap length
161
+ manifest.append({
162
+ "id": op_id, "subdomain": subdomain,
163
+ "case_name": op.get("caseName", ""), "path": out_path,
164
+ })
165
+ with open(os.path.join(OUTPUT_DIR, "manifest.json"), "w") as f:
166
+ json.dump(manifest, f, indent=2)
167
+ print(f"Fetched {len(manifest)} legal documents.")
168
+
169
+ if __name__ == "__main__":
170
+ main()
171
+ ```
172
+ The agent should verify the actual current CourtListener v4 search response schema (field names like `plain_text` vs `snippet` vs a separate opinion-text endpoint may have changed) before trusting this skeleton verbatim, API responses drift over time.
173
+
174
+ ---
175
+
176
+ ## 2.3 Financial corpus — SEC EDGAR
177
+
178
+ **New script:** `backend/scripts/fetch_secedgar.py`
179
+
180
+ - Use SEC EDGAR's full-text search API (`https://efts.sec.gov/LATEST/search-index`) and the regular filing index to pull excerpts from 10-K filings and select analyst/accounting-standard documents.
181
+ - **Mandatory:** SEC requires a descriptive `User-Agent` header identifying the requester (name + email) on every request, or you get blocked. Format: `"YourName YourEmail@example.com"`.
182
+ - Target ~130 docs, mixing filing sections (MD&A, risk factors) rather than entire 10-Ks, which are too long and would dominate the corpus disproportionately relative to medical/legal document sizes.
183
+ - Save into `test_data/financial_real/`, named by CIK + accession number.
184
+ - Same manifest pattern.
185
+
186
+ **Skeleton:**
187
+ ```python
188
+ """
189
+ MEXAR - SEC EDGAR fetcher for the financial corpus.
190
+ Run locally. Requires a descriptive User-Agent per SEC's fair access policy.
191
+ """
192
+ import requests
193
+ import os
194
+ import json
195
+ import time
196
+
197
+ USER_AGENT = "Devrajsinh Gohil djgohil2012@gmail.com" # SEC requires this format
198
+ HEADERS = {"User-Agent": USER_AGENT}
199
+ OUTPUT_DIR = "test_data/financial_real"
200
+ SEARCH_TERMS = ["risk factors", "management discussion analysis", "GAAP IFRS accounting standards"]
201
+ DOCS_PER_TERM = 45 # ~130 total across 3 terms
202
+
203
+ def full_text_search(query, count):
204
+ url = "https://efts.sec.gov/LATEST/search-index?q=%22{}%22&forms=10-K".format(query.replace(" ", "+"))
205
+ r = requests.get(url, headers=HEADERS)
206
+ r.raise_for_status()
207
+ hits = r.json().get("hits", {}).get("hits", [])
208
+ return hits[:count]
209
+
210
+ def main():
211
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
212
+ manifest = []
213
+ for term in SEARCH_TERMS:
214
+ hits = full_text_search(term, DOCS_PER_TERM)
215
+ for hit in hits:
216
+ time.sleep(0.2) # be polite to SEC's rate limits
217
+ source = hit.get("_source", {})
218
+ cik = source.get("ciks", ["unknown"])[0]
219
+ accession = source.get("adsh", "unknown")
220
+ # TODO: fetch actual filing document text via the filing index URL,
221
+ # this skeleton only captures search-hit metadata, not full text.
222
+ excerpt = source.get("display_names", [""])[0]
223
+ out_path = os.path.join(OUTPUT_DIR, f"{cik}_{accession}.txt")
224
+ with open(out_path, "w", encoding="utf-8") as f:
225
+ f.write(excerpt)
226
+ manifest.append({"cik": cik, "accession": accession, "term": term, "path": out_path})
227
+ with open(os.path.join(OUTPUT_DIR, "manifest.json"), "w") as f:
228
+ json.dump(manifest, f, indent=2)
229
+ print(f"Fetched {len(manifest)} financial documents.")
230
+
231
+ if __name__ == "__main__":
232
+ main()
233
+ ```
234
+ This one needs the most work from the agent: the full-text search endpoint returns hit metadata, not the actual filing body text. The agent needs to follow through to the actual filing document (via the CIK/accession-number-derived URL under `https://www.sec.gov/Archives/edgar/data/{cik}/{accession}/`) and extract the relevant section text (MD&A, risk factors) from the filing HTML, rather than saving just the search snippet.
235
+
236
+ ---
237
+
238
+ ## 2.4 Ingestion format
239
+
240
+ Each fetch script should output files in whatever format `backend/modules/data_validator.py`'s parsers already expect, check the `.txt`/`.json` handling there first, don't invent a new schema. Simplest path: one `.txt` file per document, plain text body, filename as a stable ID, so the existing agent-compilation flow (`KnowledgeCompiler.compile()`) can ingest them exactly like a user-uploaded file batch, no special-casing needed in the app itself.
241
+
242
+ ---
243
+
244
+ ## 2.5 Query set authoring
245
+
246
+ Create `test_data/query_sets/{medical,legal,financial}_queries.json` with this schema:
247
+ ```json
248
+ [
249
+ {
250
+ "query": "What is the first-line treatment for stage 2 hypertension?",
251
+ "domain": "medical",
252
+ "is_in_domain": true,
253
+ "expected_source_docs": ["PMC1234567"],
254
+ "annotator": "devrajsinh"
255
+ }
256
+ ]
257
+ ```
258
+ - ~40-50 in-domain queries per domain (authored by whoever on the team is most comfortable with that domain, split the load across Dev/Om/Jay).
259
+ - ~25-35 out-of-domain queries per domain-pair (medical→legal, legal→financial, etc.) for guardrail testing, reusing the structure already implied by Table IV in the paper, just at a smaller count.
260
+ - Single annotator per query is fine at this scale, note this explicitly as a limitation instead of claiming inter-annotator agreement you didn't actually measure.
261
+
262
+ ---
263
+
264
+ ## 2.6 Suggested paper text for Section IV-A
265
+
266
+ Drafted now so it's ready when the paper gets updated in Phase 4:
267
+
268
+ > *"Due to resource constraints, this study evaluates on a reduced corpus of approximately 500 documents (220 medical, 150 legal, 130 financial) sourced from PubMed Central Open Access, CourtListener, and SEC EDGAR respectively, with 120-150 in-domain queries and approximately 90 out-of-domain queries authored by the paper's co-authors. We report this as a pilot-scale evaluation and note that a larger, multi-annotator study is needed to confirm these findings generalize at production scale."*
269
+
270
+ Adjust the exact counts once the fetch scripts finish and real numbers are known.
271
+
272
+ ---
273
+
274
+ ## Summary of new files in Phase 2
275
+
276
+ | File | Purpose |
277
+ |---|---|
278
+ | `backend/scripts/fetch_pubmed.py` | pulls ~220 medical docs |
279
+ | `backend/scripts/fetch_courtlistener.py` | pulls ~150 legal docs |
280
+ | `backend/scripts/fetch_secedgar.py` | pulls ~130 financial docs |
281
+ | `test_data/medical_real/`, `test_data/legal_real/`, `test_data/financial_real/` | fetched corpora + manifests |
282
+ | `test_data/query_sets/*.json` | authored query sets with domain/expected-source labels |
283
+
284
+ ---
285
+
286
+ ## What Phase 2 does NOT cover (handled later)
287
+
288
+ - Running `baseline_runner.py`, `benchmark_runner.py`, `guardrail_analysis.py`, `statistical_tests.py` against this real data to produce actual paper numbers, that's Phase 3.
289
+ - Rewriting Tables I-V and Figures 2-4 in the paper, and rehearsing the live demo, that's Phase 4.
290
+
291
+ Once Phase 1 (architecture) and Phase 2 (data) are both done, the evaluation scripts already in `backend/evaluation/` should be able to run against real inputs with minimal further changes, since Phase 1 kept their function signatures stable.
README.md CHANGED
@@ -1 +1,418 @@
1
- # Mexar Space
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Mexar
3
+ emoji: 🧠
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
10
+ # MEXAR Ultimate 🧠
11
+
12
+ **Multimodal Explainable AI Reasoning Assistant**
13
+
14
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
15
+ [![React 18](https://img.shields.io/badge/react-18-61dafb.svg)](https://reactjs.org/)
16
+ [![FastAPI](https://img.shields.io/badge/fastapi-0.109-009688.svg)](https://fastapi.tiangolo.com/)
17
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
18
+ [![Deployed](https://img.shields.io/badge/status-live-brightgreen.svg)](https://mexar.vercel.app)
19
+
20
+ > Create domain-specific intelligent agents from your data with transparent, explainable AI responses using RAG (Retrieval-Augmented Generation) with source attribution and faithfulness scoring.
21
+
22
+ **🚀 Live Demo**: [https://mexar.vercel.app](https://mexar.vercel.app)
23
+ **📡 Backend API**: [https://devrajsinh2012-mexar.hf.space](https://devrajsinh2012-mexar.hf.space)
24
+
25
+ ---
26
+
27
+ ## ✨ Key Features
28
+
29
+ | Feature | Description |
30
+ |---------|-------------|
31
+ | 🔍 **Hybrid Search** | Combines semantic (vector) + keyword search with RRF fusion for optimal retrieval |
32
+ | 🎯 **Cross-Encoder Reranking** | Improves retrieval precision using sentence-transformers |
33
+ | 📊 **Source Attribution** | Inline citations `[1]`, `[2]` linking answers to source data |
34
+ | ✅ **Faithfulness Scoring** | Measures how well answers are grounded in retrieved context |
35
+ | 🗣️ **Multimodal Input** | Audio (Whisper), Images (Vision), Video support |
36
+ | 🔐 **Domain Guardrails** | Prevents hallucinations outside knowledge base |
37
+ | 🔊 **Text-to-Speech** | ElevenLabs + Web Speech API integration |
38
+ | 📁 **5 File Types** | CSV, PDF, DOCX, JSON, TXT |
39
+
40
+ ---
41
+
42
+ ## 🏗️ Architecture
43
+
44
+ ```
45
+ ┌────────────────────────────────────────────────────────────────────────────┐
46
+ │ MEXAR Ultimate Stack │
47
+ ├────────────────────────────────────────────────────────────────────────────┤
48
+ │ │
49
+ │ ┌────────────────────────────────────────────┐ │
50
+ │ │ React Frontend (Vercel) │ │
51
+ │ └────────────────────────────────────────────┘ │
52
+ │ │ │
53
+ │ ▼ │
54
+ │ ┌────────────────────────────────────────────┐ │
55
+ │ │ FastAPI Backend (Hugging Face Spaces) │ │
56
+ │ └────────────────────────────────────────────┘ │
57
+ │ │ │
58
+ │ ▼ │
59
+ │ ┌────────────────────────────────────────────────────────────────────┐ │
60
+ │ │ Core Intelligence Layer │ │
61
+ │ │ │ │
62
+ │ │ 🔄 Data Validator (PDF / DOCX / CSV / TXT / JSON) │ │
63
+ │ │ 🤖 Prompt Analyzer (LLM-based Intent Parsing) │ │
64
+ │ │ 📦 Knowledge Compiler (FastEmbed + Chunking) │ │
65
+ │ │ 🧠 Reasoning Engine │ │
66
+ │ │ ├─ Hybrid Search (Dense + Sparse) │ │
67
+ │ │ ├─ Cross-Encoder (Re-ranking) │ │
68
+ │ │ ├─ Source Attribution (Citation Tracking) │ │
69
+ │ │ └─ Faithfulness Scorer (Hallucination Control) │ │
70
+ │ │ │ │
71
+ │ └─────────────────────────────────────��──────────────────────────────┘ │
72
+ │ │ │
73
+ │ ▼ │
74
+ │ ┌────────────────────────────────────────────────────────────────────┐ │
75
+ │ │ External Services Layer │ │
76
+ │ │ │ │
77
+ │ │ 🗄 Supabase → PostgreSQL + pgvector + File Storage │ │
78
+ │ │ ⚡ Groq API → LLM Inference + Whisper + Vision │ │
79
+ │ │ 🔊 ElevenLabs → Text-to-Speech │ │
80
+ │ │ │ │
81
+ │ └────────────────────────────────────────────────────────────────────┘ │
82
+ │ │
83
+ └────────────────────────────────────────────────────────────────────────────┘
84
+
85
+ ```
86
+
87
+ ---
88
+
89
+ ## 🚀 Quick Start
90
+
91
+ ### Prerequisites
92
+
93
+ - **Python 3.9+** with pip
94
+ - **Node.js 18+** with npm
95
+ - **PostgreSQL** with `pgvector` extension (or use Supabase)
96
+ - **Groq API Key** - Get free at [console.groq.com](https://console.groq.com)
97
+
98
+ ### Local Development
99
+
100
+ #### 1. Backend Setup
101
+
102
+ ```bash
103
+ cd backend
104
+
105
+ # Create virtual environment
106
+ python -m venv venv
107
+
108
+ # Activate (Windows)
109
+ .\venv\Scripts\activate
110
+ # Activate (macOS/Linux)
111
+ source venv/bin/activate
112
+
113
+ # Install dependencies
114
+ pip install -r requirements.txt
115
+
116
+ # Configure environment
117
+ cp .env.example .env
118
+ # Edit .env and add your API keys
119
+
120
+ # Run server
121
+ python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
122
+ ```
123
+
124
+ **Backend will run at**: [http://localhost:8000](http://localhost:8000)
125
+
126
+ #### 2. Frontend Setup
127
+
128
+ ```bash
129
+ cd frontend
130
+
131
+ # Install dependencies
132
+ npm install
133
+
134
+ # Start development server
135
+ npm start
136
+ ```
137
+
138
+ **Frontend will run at**: [http://localhost:3000](http://localhost:3000)
139
+
140
+ ---
141
+
142
+ ## 📈 Evaluation Workflows
143
+
144
+ The scripts in `backend/evaluation` support baseline comparison, guardrail checks, benchmark runs, and ablation studies.
145
+
146
+ Run from project root:
147
+
148
+ ```bash
149
+ cd backend
150
+
151
+ # Baseline comparison: MEXAR vs CRAG vs RAPTOR
152
+ python evaluation/baseline_runner.py
153
+
154
+ # Backbone comparison (restores original backbone after completion)
155
+ python evaluation/backbone_comparison.py
156
+
157
+ # Guardrail boundary query analysis
158
+ python evaluation/guardrail_analysis.py
159
+
160
+ # Benchmark dataset run (all rows by default) + save report
161
+ python evaluation/benchmark_runner.py --dataset-path ../test_data/medqa_sample.json --agent-name medical_agent --output evaluation_outputs/medqa_report.json
162
+
163
+ # Quick benchmark smoke test
164
+ python evaluation/benchmark_runner.py --dataset-path ../test_data/medqa_sample.json --agent-name medical_agent --max-samples 25
165
+
166
+ # McNemar significance helper
167
+ python evaluation/statistical_tests.py
168
+ ```
169
+
170
+ Notes:
171
+ - Faithfulness values are read from `explainability.confidence_breakdown.faithfulness` when available.
172
+ - Benchmark reports include per-query status and aggregate summary metrics.
173
+
174
+ ---
175
+
176
+ ## 📁 Project Structure
177
+
178
+ ```
179
+ mexar_ultimate/
180
+ ├── backend/ # FastAPI Backend
181
+ │ ├── api/ # REST API endpoints
182
+ │ │ ├── auth.py # Authentication (JWT)
183
+ │ │ ├── agents.py # Agent CRUD
184
+ │ │ ├── chat.py # Chat + multimodal
185
+ │ │ ├── compile.py # Knowledge compilation
186
+ │ │ └── websocket.py # Real-time updates
187
+ │ ├── core/ # Core configuration
188
+ │ │ ├── config.py # Settings
189
+ │ │ ├── database.py # SQLAlchemy setup
190
+ │ │ └── security.py # JWT handling
191
+ │ ├── models/ # Database models
192
+ │ │ ├── user.py # User model
193
+ │ │ ├── agent.py # Agent + CompilationJob
194
+ │ │ ├── chunk.py # DocumentChunk (pgvector)
195
+ │ │ └── conversation.py # Chat history
196
+ │ ├── modules/ # Core AI modules
197
+ │ │ ├── data_validator.py # File parsing
198
+ │ │ ├── prompt_analyzer.py # Domain extraction
199
+ │ │ ├── knowledge_compiler.py # Vector embeddings
200
+ │ │ ├── reasoning_engine.py # RAG pipeline
201
+ │ │ └── explainability.py # UI formatting
202
+ │ ├── utils/ # Utilities
203
+ │ │ ├── groq_client.py # Groq API wrapper
204
+ │ │ ├── hybrid_search.py # RRF search fusion
205
+ │ │ ├── reranker.py # Cross-encoder
206
+ │ │ ├── faithfulness.py # Claim verification
207
+ │ │ └── source_attribution.py # Citation extraction
208
+ │ ├── main.py # FastAPI entry point
209
+ │ └── requirements.txt # Python dependencies
210
+ │
211
+ ├── frontend/ # React Frontend
212
+ │ ├── src/
213
+ │ │ ├── pages/ # React pages
214
+ │ │ │ ├── Landing.jsx # Home page
215
+ │ │ │ ├── Login.jsx # Authentication
216
+ │ │ │ ├── Dashboard.jsx # User dashboard
217
+ │ │ │ ├── AgentCreation.jsx # Create agent
218
+ │ │ │ ├── CompilationProgress.jsx # Build progress
219
+ │ │ │ └── Chat.jsx # Chat interface
220
+ │ │ ├── components/ # Reusable UI
221
+ │ │ ├── contexts/ # React contexts
222
+ │ │ ├── api/ # API client
223
+ │ │ └── App.jsx # Main component
224
+ │ ├── package.json # Node dependencies
225
+ │ └── vercel.json # Vercel config
226
+ │
227
+ ├── Dockerfile # Docker config for HF Spaces
228
+ └── README.md # This file
229
+ ```
230
+ ---
231
+ ### 📸 Screenshot
232
+ <img width="1863" height="918" alt="image" src="https://github.com/user-attachments/assets/75ff2c32-4c90-4e65-ab5f-10c94bad2e65" />
233
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/2d9846c9-b002-4f05-b632-6dc1c8c58ad1" />
234
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/810de78d-4725-494e-94b5-e8aa71b8b7fd" />
235
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/155ea162-d8b9-4b45-acf9-42afc47c8498" />
236
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/cadf46a8-c679-4a76-a58d-a956f9cb9cc1" />
237
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/598b7925-8001-44a7-ae34-6fddb85b7531" />
238
+ <img width="330" height="330" alt="image" src="https://github.com/user-attachments/assets/e9e99b9d-05b0-4ae5-9990-ed9c9cc124c6" />
239
+
240
+ ---
241
+
242
+ ## 🌐 Deployment
243
+
244
+ ### Current Deployment (Free Tier)
245
+
246
+ - **Frontend**: Vercel - [https://mexar.vercel.app](https://mexar.vercel.app)
247
+ - **Backend**: Hugging Face Spaces - [https://devrajsinh2012-mexar.hf.space](https://devrajsinh2012-mexar.hf.space)
248
+ - **Database**: Supabase (PostgreSQL with pgvector)
249
+ - **Storage**: Supabase Storage
250
+ - **Total Cost**: $0/month
251
+
252
+ ### Deploy Your Own Instance
253
+
254
+ #### Deploy Backend to Hugging Face Spaces
255
+
256
+ 1. Fork this repository
257
+ 2. Create a new Space at [huggingface.co/new-space](https://huggingface.co/new-space)
258
+ 3. Select **Docker** as SDK
259
+ 4. Connect your GitHub repository
260
+ 5. Add Repository Secrets:
261
+ - `GROQ_API_KEY`
262
+ - `DATABASE_URL`
263
+ - `SUPABASE_URL`
264
+ - `SUPABASE_KEY`
265
+ - `SECRET_KEY`
266
+ - `FRONTEND_URL`
267
+
268
+ #### Deploy Frontend to Vercel
269
+
270
+ 1. Import repository at [vercel.com](https://vercel.com)
271
+ 2. Set **Root Directory** to `frontend`
272
+ 3. Add Environment Variable:
273
+ - `REACT_APP_API_URL` = Your HF Spaces URL
274
+
275
+ ---
276
+
277
+ ## 🔧 Environment Variables
278
+
279
+ ### Backend (`backend/.env`)
280
+
281
+ ```env
282
+ # Required: Get from console.groq.com
283
+ GROQ_API_KEY=your_groq_api_key_here
284
+
285
+ # Supabase Database
286
+ DATABASE_URL=postgresql://user:password@host:5432/database
287
+
288
+ # JWT Security
289
+ SECRET_KEY=generate-a-secure-random-key
290
+
291
+ # Supabase Storage
292
+ SUPABASE_URL=https://your-project.supabase.co
293
+ SUPABASE_KEY=your_supabase_service_role_key
294
+
295
+ # Optional: ElevenLabs TTS
296
+ ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
297
+
298
+ # Frontend URL for CORS
299
+ FRONTEND_URL=https://mexar.vercel.app
300
+ ```
301
+
302
+ ### Frontend (`frontend/.env`)
303
+
304
+ ```env
305
+ # Backend API URL
306
+ REACT_APP_API_URL=https://your-backend.hf.space
307
+ ```
308
+
309
+ ---
310
+
311
+ ## 🔍 API Documentation
312
+
313
+ Once the backend is running, interactive API docs are available at:
314
+
315
+ - **Swagger UI**: `http://localhost:8000/docs`
316
+ - **ReDoc**: `http://localhost:8000/redoc`
317
+
318
+ ### Key Endpoints
319
+
320
+ | Method | Endpoint | Description |
321
+ |--------|----------|-------------|
322
+ | POST | `/api/auth/register` | Register new user |
323
+ | POST | `/api/auth/login` | Login (returns JWT) |
324
+ | GET | `/api/agents/` | List all agents |
325
+ | POST | `/api/compile/` | Start agent compilation |
326
+ | GET | `/api/compile/{name}/status` | Check compilation status |
327
+ | POST | `/api/chat/` | Send message to agent |
328
+ | POST | `/api/chat/multimodal` | Send with audio/image |
329
+
330
+ ---
331
+
332
+ ## 🧪 Technologies
333
+
334
+ ### Backend
335
+ - **FastAPI** - Modern async Python web framework
336
+ - **SQLAlchemy** - ORM for PostgreSQL
337
+ - **pgvector** - Vector similarity search
338
+ - **FastEmbed** - Local embedding generation (BAAI/bge-small-en-v1.5)
339
+ - **sentence-transformers** - Cross-encoder reranking
340
+ - **Groq API** - LLM (Llama 3.1/3.3), Whisper (audio), Vision (images)
341
+
342
+ ### Frontend
343
+ - **React 18** - UI framework
344
+ - **Material-UI (MUI)** - Component library
345
+ - **React Router** - Navigation
346
+ - **Axios** - HTTP client
347
+
348
+ ### External Services
349
+ - **Supabase** - Managed PostgreSQL + Storage
350
+ - **Groq** - Fast AI inference (LPU architecture)
351
+ - **ElevenLabs** - Text-to-Speech (optional)
352
+
353
+ ---
354
+
355
+ ## 📊 How It Works
356
+
357
+ ### 1. Agent Creation Flow
358
+ ```
359
+ User uploads files → DataValidator parses content
360
+ → PromptAnalyzer extracts domain & keywords
361
+ → KnowledgeCompiler creates embeddings
362
+ → Stored in pgvector database
363
+ ```
364
+
365
+ ### 2. Query Processing Flow
366
+ ```
367
+ User query → Domain Guardrail check
368
+ → Hybrid Search (semantic + keyword)
369
+ → Cross-Encoder Reranking (top 5 results)
370
+ → LLM Generation with retrieved context
371
+ → Source Attribution (extract citations)
372
+ → Faithfulness Scoring (verify grounding)
373
+ → Explainability Formatting
374
+ ```
375
+
376
+ ### 3. Confidence Calculation
377
+ Confidence score is calculated from:
378
+ - **Retrieval Quality** (35%) - Relevance of retrieved chunks
379
+ - **Rerank Score** (30%) - Cross-encoder confidence
380
+ - **Faithfulness** (25%) - Answer grounding in context
381
+ - **Base Floor** (10%) - For in-domain queries
382
+
383
+ ---
384
+
385
+ ## ⚠️ Known Limitations (Free Tier)
386
+
387
+ 1. **Cold Start Delay**: First request after 15 min idle takes 45-90 seconds
388
+ 2. **Model Download**: Initial startup takes 3-5 minutes (FastEmbed caching)
389
+ 3. **Groq Rate Limits**: 30 requests/min, 14,400/day (free tier)
390
+ 4. **Concurrent Users**: 1-2 recommended on free tier (2GB RAM limit)
391
+ 5. **Ephemeral Storage**: HF Spaces `/tmp` data lost on restart (Supabase used for persistence)
392
+
393
+ **Production Migration**: Upgrade to paid tiers for ~$54/month (persistent instances, higher limits)
394
+
395
+ ---
396
+
397
+ ## 🙏 Acknowledgments
398
+
399
+ - [Groq](https://groq.com) - Fast AI inference with LPU technology
400
+ - [Supabase](https://supabase.com) - PostgreSQL + Storage platform
401
+ - [FastEmbed](https://github.com/qdrant/fastembed) - Lightweight embeddings library
402
+ - [sentence-transformers](https://www.sbert.net) - Reranking models
403
+ - [Hugging Face](https://huggingface.co) - Free ML model hosting
404
+
405
+ ---
406
+
407
+ ## 🤝 Contribution
408
+
409
+ This project is built by Devrajsinh Gohil and Jay Nasit under the guidance of Prof OM Prakash Suthar.
410
+
411
+ **Built with ❤️ using modern AI technologies**
412
+
413
+ ---
414
+
415
+ ## 📄 License
416
+
417
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
418
+
backend/.env.example CHANGED
@@ -40,3 +40,22 @@ ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
40
  # ===========================================
41
  # For development only, production uses Supabase Storage
42
  STORAGE_PATH=./data/storage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  # ===========================================
41
  # For development only, production uses Supabase Storage
42
  STORAGE_PATH=./data/storage
43
+
44
+ # ===========================================
45
+ # PHASE 2: Dataset Collection Scripts
46
+ # ===========================================
47
+ # Required for backend/scripts/fetch_courtlistener.py
48
+ # Free account + token at: https://www.courtlistener.com/sign-in/
49
+ COURTLISTENER_TOKEN=your_courtlistener_api_token_here
50
+
51
+ # Required for backend/scripts/fetch_pubmed.py
52
+ # NCBI usage policy requires a valid email address on every request.
53
+ NCBI_EMAIL=your_email@example.com
54
+
55
+ # Optional: NCBI API key raises rate limit from 3 req/sec → 10 req/sec.
56
+ # Register at: https://www.ncbi.nlm.nih.gov/account/
57
+ NCBI_API_KEY=
58
+
59
+ # Required for backend/scripts/fetch_secedgar.py
60
+ # SEC fair access policy: format must be "FirstName LastName email@example.com"
61
+ SEC_USER_AGENT=YourName your@email.com
backend/Dockerfile CHANGED
@@ -13,6 +13,9 @@ RUN apt-get update && apt-get install -y \
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
 
 
 
16
  # Copy application code
17
  COPY . .
18
 
 
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
16
+ # Download spaCy model for domain guardrail NER (Section III-A)
17
+ RUN python -m spacy download en_core_web_sm
18
+
19
  # Copy application code
20
  COPY . .
21
 
backend/core/config.py CHANGED
@@ -15,7 +15,8 @@ class Config:
15
 
16
  # AI Services
17
  GROQ_API_KEY = os.getenv("GROQ_API_KEY")
18
- LLM_BACKBONE = os.getenv("LLM_BACKBONE", "llama3") # Options: llama3, mixtral, gemma
 
19
 
20
  # Storage
21
  STORAGE_PATH = os.getenv("STORAGE_PATH", "./data/storage")
 
15
 
16
  # AI Services
17
  GROQ_API_KEY = os.getenv("GROQ_API_KEY")
18
+ LLM_BACKBONE = os.getenv("LLM_BACKBONE", "openai/gpt-oss-120b") # Options: openai/gpt-oss-120b, llama3, mixtral, gemma
19
+ GROQ_MODEL = os.getenv("GROQ_MODEL", os.getenv("LLM_MODEL", "openai/gpt-oss-120b"))
20
 
21
  # Storage
22
  STORAGE_PATH = os.getenv("STORAGE_PATH", "./data/storage")
backend/evaluation/metrics.py CHANGED
@@ -8,22 +8,35 @@ from typing import Any, Dict, Optional
8
 
9
  sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
 
11
- from utils.faithfulness import FaithfulnessScorer, BartNLIScorer, FActScoreCompat
12
 
13
 
14
  class MetricsRunner:
15
  def __init__(self):
16
  self.faith_scorer = FaithfulnessScorer()
17
- self.bart_nli = BartNLIScorer()
 
18
  self.factscore = FActScoreCompat()
19
 
20
- def evaluate_all(self, answer: str, context: str) -> Dict[str, float]:
21
- faith_res = self.faith_scorer.score(answer, context)
22
- bart_res = self.bart_nli.score(answer, context)
23
- fact_res = self.factscore.score(answer, context)
 
 
 
 
 
 
 
 
 
 
 
 
24
  return {
25
- "faithfulness": faith_res.score,
26
- "bart_nli": bart_res.score,
27
  "factscore": fact_res.score,
28
  }
29
 
 
8
 
9
  sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
 
11
+ from utils.faithfulness import FaithfulnessScorer, DebertaNLIScorer, FActScoreCompat
12
 
13
 
14
  class MetricsRunner:
15
  def __init__(self):
16
  self.faith_scorer = FaithfulnessScorer()
17
+ # Primary NLI scorer — DeBERTa-v3 (Section III-C, Eq. 6/7)
18
+ self.deberta_nli = DebertaNLIScorer()
19
  self.factscore = FActScoreCompat()
20
 
21
+ def evaluate_all(self, answer: str, context_documents: List[str]) -> Dict[str, float]:
22
+ """
23
+ Evaluate faithfulness using all available scorers.
24
+
25
+ Args:
26
+ answer: LLM-generated answer.
27
+ context_documents: List of individual retrieved chunk texts.
28
+ DeBERTa scorer requires individual docs (Eq. 6).
29
+ FaithfulnessScorer receives them joined as a single string.
30
+ """
31
+ # FaithfulnessScorer (LLM-judge baseline) still takes a single concatenated string
32
+ context_str = "\n\n---\n\n".join(context_documents)
33
+ faith_res = self.faith_scorer.score(answer, context_str)
34
+ # DeBERTa NLI (primary) takes individual chunk texts
35
+ deberta_res = self.deberta_nli.score(answer, context_documents)
36
+ fact_res = self.factscore.score(answer, context_str)
37
  return {
38
+ "faithfulness_llm_judge": faith_res.score,
39
+ "deberta_nli": deberta_res.score,
40
  "factscore": fact_res.score,
41
  }
42
 
backend/migrations/README.md CHANGED
@@ -34,14 +34,17 @@ Reciprocal Rank Fusion (RRF) algorithm.
34
  ```
35
  - Should return one row
36
 
37
- ## Alternative: Run from Command Line (Optional)
38
 
39
- If you have `psql` installed:
40
 
41
  ```bash
42
- psql "postgresql://postgres.xmfcidiwovxuihrkfzps:Yogiji@20122004@aws-1-ap-south-1.pooler.supabase.com:5432/postgres" -f migrations/hybrid_search_function.sql
43
  ```
44
 
 
 
 
45
  ## What Gets Created
46
 
47
  - **Function**: `hybrid_search(vector, text, integer, integer)`
 
34
  ```
35
  - Should return one row
36
 
37
+ ## Recommended: Automated Python Migration Runner
38
 
39
+ Run all migrations automatically using your `DATABASE_URL` configured in `backend/.env`:
40
 
41
  ```bash
42
+ python scripts/run_migrations.py
43
  ```
44
 
45
+ ## Alternative: Run manually via Supabase Dashboard
46
+
47
+
48
  ## What Gets Created
49
 
50
  - **Function**: `hybrid_search(vector, text, integer, integer)`
backend/migrations/add_domain_signature_columns.sql ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- MEXAR Phase 1 - Architecture Fix 1.1
2
+ -- Adds domain_signature_weights and domain_entities columns to agents table.
3
+ --
4
+ -- domain_signature_weights: JSONB dict of {term: tfidf_weight} from TF-IDF lexical signature (Eq. 1)
5
+ -- domain_entities: JSONB list of NER entity strings extracted from corpus (Section III-A)
6
+ --
7
+ -- Apply via Supabase SQL Editor (copy-paste and Run).
8
+ -- See backend/migrations/README.md for instructions.
9
+
10
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS domain_signature_weights JSONB;
11
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS domain_entities JSONB;
12
+
13
+ COMMENT ON COLUMN agents.domain_signature_weights IS
14
+ 'TF-IDF lexical weights dict {term: weight} for domain signature (Section III-A, Eq. 1)';
15
+
16
+ COMMENT ON COLUMN agents.domain_entities IS
17
+ 'NER-extracted domain entities list for domain signature (Section III-A)';
backend/migrations/switch_to_hnsw_index.sql ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- MEXAR Phase 1 - Architecture Fix 1.2
2
+ -- Switches the document_chunks embedding index from IVFFlat to HNSW.
3
+ -- Paper Section III-B explicitly claims HNSW indexing.
4
+ --
5
+ -- Pre-requisite: pgvector >= 0.5.0 required for HNSW.
6
+ -- Verify with: SELECT extversion FROM pg_extension WHERE extname = 'vector';
7
+ -- If below 0.5.0, run: ALTER EXTENSION vector UPDATE;
8
+ --
9
+ -- Apply via Supabase SQL Editor (copy-paste and Run).
10
+ -- See backend/migrations/README.md for instructions.
11
+
12
+ -- Drop the old IVFFlat index created in hybrid_search_function.sql
13
+ DROP INDEX IF EXISTS idx_document_chunks_embedding;
14
+
15
+ -- Create HNSW index to match paper Section III-B
16
+ -- m=16: number of bi-directional links per node (connectivity)
17
+ -- ef_construction=64: size of dynamic candidate list during index construction (recall/speed trade-off)
18
+ CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding_hnsw
19
+ ON document_chunks USING hnsw (embedding vector_cosine_ops)
20
+ WITH (m = 16, ef_construction = 64);
21
+
22
+ COMMENT ON INDEX idx_document_chunks_embedding_hnsw IS
23
+ 'HNSW index for cosine similarity search, m=16, ef_construction=64 (Section III-B)';
24
+
25
+ -- Verification query — run after applying to confirm:
26
+ -- SELECT indexdef FROM pg_indexes WHERE tablename = 'document_chunks';
27
+ -- Should show 'hnsw', not 'ivfflat'.
backend/models/agent.py CHANGED
@@ -18,6 +18,9 @@ class Agent(Base):
18
  # All metadata stored in Supabase (no filesystem)
19
  domain_keywords = Column(JSONB, nullable=True)
20
  domain_signature = Column(JSONB, nullable=True)
 
 
 
21
  prompt_analysis = Column(JSONB, nullable=True)
22
  knowledge_graph_json = Column(JSONB, nullable=True)
23
  compilation_stats = Column(JSONB, nullable=True)
 
18
  # All metadata stored in Supabase (no filesystem)
19
  domain_keywords = Column(JSONB, nullable=True)
20
  domain_signature = Column(JSONB, nullable=True)
21
+ # Phase 1 fix 1.1: TF-IDF weights and NER entities stored separately for inspection
22
+ domain_signature_weights = Column(JSONB, nullable=True) # {term: tfidf_weight} (Eq. 1)
23
+ domain_entities = Column(JSONB, nullable=True) # NER entity list (Section III-A)
24
  prompt_analysis = Column(JSONB, nullable=True)
25
  knowledge_graph_json = Column(JSONB, nullable=True)
26
  compilation_stats = Column(JSONB, nullable=True)
backend/modules/knowledge_compiler.py CHANGED
@@ -10,6 +10,7 @@ from typing import Dict, List, Any, Optional
10
  from pathlib import Path
11
 
12
  from utils.groq_client import get_groq_client, GroqClient
 
13
  from fastembed import TextEmbedding
14
  from core.database import SessionLocal
15
  from models.agent import Agent
@@ -102,7 +103,9 @@ class KnowledgeCompiler:
102
  self._save_agent(
103
  agent_name=agent_name,
104
  text_context=text_context,
105
- domain_signature=domain_signature,
 
 
106
  system_prompt=system_prompt,
107
  prompt_analysis=prompt_analysis,
108
  stats=stats
@@ -116,7 +119,7 @@ class KnowledgeCompiler:
116
  self._update_progress("complete", 100, "Compilation complete!")
117
 
118
  return {
119
- "domain_signature": domain_signature,
120
  "stats": stats,
121
  "agent_path": str(self.data_dir / agent_name)
122
  }
@@ -200,33 +203,47 @@ class KnowledgeCompiler:
200
  self,
201
  parsed_data: List[Dict[str, Any]],
202
  prompt_analysis: Dict[str, Any]
203
- ) -> List[str]:
204
  """
205
- Extract domain signature keywords for guardrail checking.
 
 
 
 
 
206
  """
207
- # Start with analyzed keywords (highest priority)
208
- domain_keywords = prompt_analysis.get("domain_keywords", [])
209
- signature = list(domain_keywords)
210
-
211
- # Add domain and sub-domains
212
- domain = prompt_analysis.get("domain", "")
213
- if domain and domain not in signature:
214
- signature.insert(0, domain)
215
-
216
- for sub_domain in prompt_analysis.get("sub_domains", []):
217
- if sub_domain and sub_domain.lower() not in [s.lower() for s in signature]:
218
- signature.append(sub_domain)
219
-
220
- # Extract column headers from structured data
221
  for file_data in parsed_data:
222
- if file_data.get("data") and isinstance(file_data["data"], list):
223
- if file_data["data"] and isinstance(file_data["data"][0], dict):
224
- for key in file_data["data"][0].keys():
225
- clean_key = key.lower().strip().replace("_", " ")
226
- if clean_key and clean_key not in [s.lower() for s in signature]:
227
- signature.append(clean_key)
228
-
229
- return signature[:80] # Limit for efficiency
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  def _calculate_stats(
232
  self,
@@ -249,11 +266,13 @@ class KnowledgeCompiler:
249
  agent_name: str,
250
  text_context: str,
251
  domain_signature: List[str],
 
 
252
  system_prompt: str,
253
  prompt_analysis: Dict[str, Any],
254
  stats: Dict[str, Any]
255
  ):
256
- """Save agent artifacts to filesystem."""
257
  agent_dir = self.data_dir / agent_name
258
  agent_dir.mkdir(parents=True, exist_ok=True)
259
 
@@ -267,12 +286,30 @@ class KnowledgeCompiler:
267
  "system_prompt": system_prompt,
268
  "prompt_analysis": prompt_analysis,
269
  "domain_signature": domain_signature,
 
 
270
  "stats": stats,
271
  "created_at": self._get_timestamp()
272
  }
273
  with open(agent_dir / "metadata.json", "w", encoding="utf-8") as f:
274
  json.dump(metadata, f, indent=2, ensure_ascii=False)
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  logger.info(f"Agent saved to: {agent_dir}")
277
 
278
  def _get_timestamp(self) -> str:
 
10
  from pathlib import Path
11
 
12
  from utils.groq_client import get_groq_client, GroqClient
13
+ from utils.domain_signature import build_domain_signature
14
  from fastembed import TextEmbedding
15
  from core.database import SessionLocal
16
  from models.agent import Agent
 
103
  self._save_agent(
104
  agent_name=agent_name,
105
  text_context=text_context,
106
+ domain_signature=domain_signature["combined"],
107
+ domain_signature_weights=domain_signature["lexical_weights"],
108
+ domain_entities=domain_signature["entities"],
109
  system_prompt=system_prompt,
110
  prompt_analysis=prompt_analysis,
111
  stats=stats
 
119
  self._update_progress("complete", 100, "Compilation complete!")
120
 
121
  return {
122
+ "domain_signature": domain_signature["combined"],
123
  "stats": stats,
124
  "agent_path": str(self.data_dir / agent_name)
125
  }
 
203
  self,
204
  parsed_data: List[Dict[str, Any]],
205
  prompt_analysis: Dict[str, Any]
206
+ ) -> Dict:
207
  """
208
+ Build domain signature using TF-IDF (Eq. 1) + NER entities (Section III-A).
209
+
210
+ Extracts per-file raw text to preserve per-document granularity needed for
211
+ TF-IDF's document-frequency term to be meaningful. Returns the full signature
212
+ dict from build_domain_signature() including 'combined', 'lexical_weights',
213
+ and 'entities' keys.
214
  """
215
+ # Collect per-file text documents (NOT a single concatenated string)
216
+ documents: List[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
217
  for file_data in parsed_data:
218
+ text = (
219
+ file_data.get("text") or
220
+ file_data.get("content") or
221
+ ""
222
+ )
223
+ # For structured data (CSV/JSON), build a text representation per file
224
+ if not text and file_data.get("data") and isinstance(file_data["data"], list):
225
+ rows = file_data["data"]
226
+ text = " ".join(
227
+ " ".join(str(v) for v in row.values() if v is not None)
228
+ for row in rows
229
+ if isinstance(row, dict)
230
+ )
231
+ if text and text.strip():
232
+ documents.append(text)
233
+
234
+ # Build TF-IDF + NER domain signature
235
+ signature = build_domain_signature(
236
+ documents=documents,
237
+ tau_tf=0.0,
238
+ tau_ent=2,
239
+ top_n_lexical=100,
240
+ )
241
+
242
+ logger.info(
243
+ f"Domain signature: {len(signature['combined'])} combined terms "
244
+ f"({len(signature['lexical'])} lexical + {len(signature['entities'])} NER entities)"
245
+ )
246
+ return signature
247
 
248
  def _calculate_stats(
249
  self,
 
266
  agent_name: str,
267
  text_context: str,
268
  domain_signature: List[str],
269
+ domain_signature_weights: Dict,
270
+ domain_entities: List[str],
271
  system_prompt: str,
272
  prompt_analysis: Dict[str, Any],
273
  stats: Dict[str, Any]
274
  ):
275
+ """Save agent artifacts to filesystem and Supabase DB."""
276
  agent_dir = self.data_dir / agent_name
277
  agent_dir.mkdir(parents=True, exist_ok=True)
278
 
 
286
  "system_prompt": system_prompt,
287
  "prompt_analysis": prompt_analysis,
288
  "domain_signature": domain_signature,
289
+ "domain_signature_weights": domain_signature_weights,
290
+ "domain_entities": domain_entities,
291
  "stats": stats,
292
  "created_at": self._get_timestamp()
293
  }
294
  with open(agent_dir / "metadata.json", "w", encoding="utf-8") as f:
295
  json.dump(metadata, f, indent=2, ensure_ascii=False)
296
 
297
+ # Persist new domain_signature_weights and domain_entities to Supabase DB
298
+ try:
299
+ with SessionLocal() as db:
300
+ agent = db.query(Agent).filter(Agent.name == agent_name).first()
301
+ if agent:
302
+ agent.domain_signature = domain_signature
303
+ agent.domain_signature_weights = domain_signature_weights
304
+ agent.domain_entities = domain_entities
305
+ db.commit()
306
+ logger.info(
307
+ f"Persisted domain_signature ({len(domain_signature)} terms), "
308
+ f"domain_signature_weights, and domain_entities for agent '{agent_name}'."
309
+ )
310
+ except Exception as e:
311
+ logger.warning(f"Could not persist domain signature to DB: {e}")
312
+
313
  logger.info(f"Agent saved to: {agent_dir}")
314
 
315
  def _get_timestamp(self) -> str:
backend/modules/reasoning_engine.py CHANGED
@@ -9,14 +9,13 @@ import logging
9
  from typing import Dict, List, Any, Optional, Tuple
10
  from pathlib import Path
11
  import networkx as nx
12
- from difflib import SequenceMatcher
13
  import numpy as np
14
 
15
  from utils.groq_client import get_groq_client, GroqClient
16
  from utils.hybrid_search import HybridSearcher
17
  from utils.reranker import Reranker
18
  from utils.source_attribution import SourceAttributor
19
- from utils.faithfulness import FaithfulnessScorer, BartNLIScorer
20
  from fastembed import TextEmbedding
21
  from core.database import SessionLocal
22
  from models.agent import Agent
@@ -68,8 +67,10 @@ class ReasoningEngine:
68
  self.searcher = HybridSearcher(self.embedding_model) if self.embedding_model else None
69
  self.reranker = Reranker()
70
  self.attributor = SourceAttributor(self.embedding_model)
 
 
 
71
  self.faithfulness_scorer = FaithfulnessScorer()
72
- self.bart_nli_scorer = BartNLIScorer()
73
 
74
  # Cache for loaded agents
75
  self._agent_cache: Dict[str, Dict] = {}
@@ -154,10 +155,15 @@ class ReasoningEngine:
154
  attribution = self.attributor.attribute(answer, top_chunks, chunk_embeddings)
155
 
156
  # Step 6: Faithfulness Scoring
157
- faithfulness_result = self.faithfulness_scorer.score(answer, context)
 
 
 
 
158
 
159
- # Independent NLI Baseline Scoring (for reviewer feedback)
160
- bart_nli_result = self.bart_nli_scorer.score(answer, context)
 
161
 
162
  # Step 7: Calculate Confidence
163
  top_similarity = rrf_scores[0] if rrf_scores else 0
@@ -166,7 +172,7 @@ class ReasoningEngine:
166
  confidence = self._calculate_confidence(
167
  top_similarity=top_similarity,
168
  rerank_score=top_rerank,
169
- faithfulness=faithfulness_result.score
170
  )
171
 
172
  # Step 8: Build Explainability
@@ -178,7 +184,7 @@ class ReasoningEngine:
178
  rerank_scores=rerank_scores,
179
  attribution=attribution,
180
  faithfulness=faithfulness_result,
181
- bart_nli_result=bart_nli_result,
182
  confidence=confidence,
183
  domain_score=domain_score
184
  )
@@ -228,65 +234,47 @@ class ReasoningEngine:
228
  domain_signature: List[str],
229
  prompt_analysis: Dict[str, Any]
230
  ) -> Tuple[bool, float]:
231
- """Check if query matches the domain."""
232
- query_lower = query.lower()
233
- query_words = set(query_lower.split())
234
-
235
- matches = 0
236
- bonus_matches = 0
237
-
238
- # Check domain match
239
- domain = prompt_analysis.get("domain", "")
240
- if domain.lower() in query_lower:
241
- bonus_matches += 3
242
-
243
- # Check sub-domains
244
- for sub_domain in prompt_analysis.get("sub_domains", []):
245
- if sub_domain.lower() in query_lower:
246
- bonus_matches += 2
247
-
248
- # Check domain keywords
249
- for keyword in prompt_analysis.get("domain_keywords", []):
250
- if keyword.lower() in query_lower:
251
- bonus_matches += 1.5
252
-
253
- # Check signature keywords with fuzzy matching
254
- signature_lower = [kw.lower() for kw in (domain_signature or [])]
255
-
256
- for word in query_words:
257
- if len(word) < 3:
258
- continue
259
- for kw in signature_lower[:100]:
260
- if self._fuzzy_match(word, kw) > 0.75:
261
- matches += 1
262
- break
263
- if word in kw or kw in word:
264
- matches += 0.5
265
- break
266
-
267
- # Calculate score
268
- max_possible = max(1, min(len(query_words), 10))
269
- base_score = matches / max_possible
270
- bonus_score = min(0.5, bonus_matches * 0.1)
271
- score = min(1.0, base_score + bonus_score)
272
-
273
- if bonus_matches >= 1:
274
- score = max(score, 0.2)
275
-
276
  is_in_domain = score >= self.DOMAIN_SIMILARITY_THRESHOLD
277
-
278
- # Analyze guardrail false-accept rate: Log boundary queries (close to threshold)
279
- if 0.05 <= score < 0.15:
280
- logger.info(f"GUARDRAIL_BOUNDARY_ACCEPT: score={score:.2f}, query='{query}' - Check for false positive")
281
-
282
- logger.info(f"Guardrail: score={score:.2f}, matches={matches}, bonus={bonus_matches}, in_domain={is_in_domain}")
283
-
284
  return is_in_domain, score
285
 
286
- def _fuzzy_match(self, s1: str, s2: str) -> float:
287
- """Calculate fuzzy match ratio."""
288
- return SequenceMatcher(None, s1, s2).ratio()
289
-
290
  def _generate_answer(
291
  self,
292
  query: str,
@@ -378,7 +366,7 @@ IMPORTANT INSTRUCTIONS:
378
  rerank_scores: List[float],
379
  attribution,
380
  faithfulness,
381
- bart_nli_result,
382
  confidence: float,
383
  domain_score: float
384
  ) -> Dict[str, Any]:
@@ -401,8 +389,10 @@ IMPORTANT INSTRUCTIONS:
401
  "domain_relevance": f"{domain_score*100:.0f}%",
402
  "retrieval_quality": f"{rrf_scores[0]*100:.1f}%" if rrf_scores else "N/A",
403
  "rerank_score": f"{rerank_scores[0]:.2f}" if rerank_scores else "N/A",
 
404
  "faithfulness": f"{faithfulness.score*100:.0f}%",
405
- "bart_nli_score": f"{bart_nli_result.score*100:.0f}%" if bart_nli_result else "N/A",
 
406
  "claims_supported": f"{faithfulness.supported_claims}/{faithfulness.total_claims}"
407
  },
408
  "unsupported_claims": faithfulness.unsupported_claims[:3],
 
9
  from typing import Dict, List, Any, Optional, Tuple
10
  from pathlib import Path
11
  import networkx as nx
 
12
  import numpy as np
13
 
14
  from utils.groq_client import get_groq_client, GroqClient
15
  from utils.hybrid_search import HybridSearcher
16
  from utils.reranker import Reranker
17
  from utils.source_attribution import SourceAttributor
18
+ from utils.faithfulness import FaithfulnessScorer, DebertaNLIScorer
19
  from fastembed import TextEmbedding
20
  from core.database import SessionLocal
21
  from models.agent import Agent
 
67
  self.searcher = HybridSearcher(self.embedding_model) if self.embedding_model else None
68
  self.reranker = Reranker()
69
  self.attributor = SourceAttributor(self.embedding_model)
70
+ # Primary faithfulness: DeBERTa-v3 NLI (Section III-C, Eq. 6/7)
71
+ self.deberta_nli_scorer = DebertaNLIScorer()
72
+ # Baseline faithfulness: LLM-as-judge (kept for paper Table I/III comparison)
73
  self.faithfulness_scorer = FaithfulnessScorer()
 
74
 
75
  # Cache for loaded agents
76
  self._agent_cache: Dict[str, Dict] = {}
 
155
  attribution = self.attributor.attribute(answer, top_chunks, chunk_embeddings)
156
 
157
  # Step 6: Faithfulness Scoring
158
+ # PRIMARY: DeBERTa-v3 NLI per-document max entailment (Eq. 6/7, Section III-C)
159
+ # Passes individual chunk texts (NOT concatenated) so per-doc max is meaningful.
160
+ faithfulness_result = self.deberta_nli_scorer.score(
161
+ answer, [c.content for c in top_chunks]
162
+ )
163
 
164
+ # BASELINE (LLM-as-judge): kept for paper Table I/III comparison
165
+ # Stored under a clearly-labelled key, NOT used as the headline faithfulness number.
166
+ llm_faithfulness_result = self.faithfulness_scorer.score(answer, context)
167
 
168
  # Step 7: Calculate Confidence
169
  top_similarity = rrf_scores[0] if rrf_scores else 0
 
172
  confidence = self._calculate_confidence(
173
  top_similarity=top_similarity,
174
  rerank_score=top_rerank,
175
+ faithfulness=faithfulness_result.score # DeBERTa is primary signal
176
  )
177
 
178
  # Step 8: Build Explainability
 
184
  rerank_scores=rerank_scores,
185
  attribution=attribution,
186
  faithfulness=faithfulness_result,
187
+ llm_faithfulness_result=llm_faithfulness_result,
188
  confidence=confidence,
189
  domain_score=domain_score
190
  )
 
234
  domain_signature: List[str],
235
  prompt_analysis: Dict[str, Any]
236
  ) -> Tuple[bool, float]:
237
+ """
238
+ Eq. 2: sim(q, Sigma) = |Phi(q) ∩ Sigma| / |Phi(q)|
239
+
240
+ Replaces the old fuzzy-string heuristic with real Jaccard similarity
241
+ over spaCy-lemmatized content tokens (Section III-A).
242
+
243
+ Phi(q): lemmatized content tokens from query, excluding stopwords/punctuation
244
+ and tokens shorter than 3 characters.
245
+ Sigma: domain_signature (combined lexical + NER terms from build_domain_signature)
246
+ """
247
+ from utils.domain_signature import get_nlp
248
+ nlp = get_nlp()
249
+
250
+ query_doc = nlp(query.lower())
251
+
252
+ # Phi(q): lemmatized meaningful tokens
253
+ phi_q = {
254
+ tok.lemma_
255
+ for tok in query_doc
256
+ if not tok.is_stop and not tok.is_punct and len(tok.text) > 2
257
+ }
258
+
259
+ sigma = set(s.lower() for s in (domain_signature or []))
260
+
261
+ if not phi_q:
262
+ logger.info("Guardrail: phi_q is empty — query has no content tokens, rejecting.")
263
+ return False, 0.0
264
+
265
+ intersection = phi_q & sigma
266
+ score = len(intersection) / len(phi_q)
267
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  is_in_domain = score >= self.DOMAIN_SIMILARITY_THRESHOLD
269
+
270
+ logger.info(
271
+ f"Guardrail (Eq. 2): sim={score:.3f}, |Phi(q)|={len(phi_q)}, "
272
+ f"|Sigma|={len(sigma)}, |intersection|={len(intersection)}, "
273
+ f"in_domain={is_in_domain}"
274
+ )
275
+
276
  return is_in_domain, score
277
 
 
 
 
 
278
  def _generate_answer(
279
  self,
280
  query: str,
 
366
  rerank_scores: List[float],
367
  attribution,
368
  faithfulness,
369
+ llm_faithfulness_result,
370
  confidence: float,
371
  domain_score: float
372
  ) -> Dict[str, Any]:
 
389
  "domain_relevance": f"{domain_score*100:.0f}%",
390
  "retrieval_quality": f"{rrf_scores[0]*100:.1f}%" if rrf_scores else "N/A",
391
  "rerank_score": f"{rerank_scores[0]:.2f}" if rerank_scores else "N/A",
392
+ # Primary faithfulness: DeBERTa-v3 NLI (Section III-C)
393
  "faithfulness": f"{faithfulness.score*100:.0f}%",
394
+ # Baseline: LLM-as-judge (for paper Table I/III comparison only)
395
+ "llm_judge_baseline_score": f"{llm_faithfulness_result.score*100:.0f}%" if llm_faithfulness_result else "N/A",
396
  "claims_supported": f"{faithfulness.supported_claims}/{faithfulness.total_claims}"
397
  },
398
  "unsupported_claims": faithfulness.unsupported_claims[:3],
backend/requirements.txt CHANGED
@@ -1,5 +1,9 @@
1
  # MEXAR Phase 1 - Backend Dependencies
2
 
 
 
 
 
3
  # Web Framework
4
  fastapi==0.109.0
5
  uvicorn[standard]==0.27.0
 
1
  # MEXAR Phase 1 - Backend Dependencies
2
 
3
+ # Domain Guardrail (Section III-A: TF-IDF + NER + Jaccard)
4
+ scikit-learn>=1.4.0
5
+ spacy>=3.7.0
6
+
7
  # Web Framework
8
  fastapi==0.109.0
9
  uvicorn[standard]==0.27.0
backend/scripts/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """
2
+ MEXAR Dataset Collection Scripts — Phase 2
3
+ Fetches real-world corpora from PubMed Central, CourtListener, and SEC EDGAR.
4
+ Run these scripts locally (not in a network-restricted sandbox).
5
+ """
backend/scripts/fetch_courtlistener.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MEXAR - CourtListener fetcher for the legal corpus.
3
+
4
+ Fetches ~150 judicial opinions from the CourtListener REST API v4 across three
5
+ legal subdomains (contract law, intellectual property, corporate governance),
6
+ saves opinion text as plain .txt files, and writes a manifest.json.
7
+
8
+ Usage:
9
+ python backend/scripts/fetch_courtlistener.py
10
+
11
+ Requirements:
12
+ pip install requests
13
+
14
+ Environment:
15
+ COURTLISTENER_TOKEN — API token from https://www.courtlistener.com/sign-in/
16
+ (free account required)
17
+
18
+ Run this locally (not in a network-restricted sandbox).
19
+
20
+ Notes on CourtListener v4 API schema:
21
+ The v4 /search/ endpoint returns a different schema from earlier versions.
22
+ This script uses the /search/?type=o endpoint which returns opinion cluster
23
+ results. Full opinion text must often be fetched via a separate
24
+ /api/rest/v4/opinions/{id}/ call — this script handles that two-step flow.
25
+ See https://www.courtlistener.com/help/api/rest/v4/
26
+ """
27
+ import argparse
28
+ import json
29
+ import logging
30
+ import os
31
+ import sys
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Dict, List, Optional
35
+
36
+ import requests
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Configuration
40
+ # ---------------------------------------------------------------------------
41
+
42
+ logging.basicConfig(
43
+ level=logging.INFO,
44
+ format="%(asctime)s [%(levelname)s] %(message)s",
45
+ )
46
+ logger = logging.getLogger(__name__)
47
+
48
+ TOKEN: Optional[str] = os.environ.get("COURTLISTENER_TOKEN")
49
+ BASE_URL = "https://www.courtlistener.com/api/rest/v4"
50
+ OUTPUT_DIR = Path("test_data/legal_real")
51
+
52
+ SEARCH_TERMS: Dict[str, str] = {
53
+ "contract_law": "breach of contract damages",
54
+ "intellectual_property": "patent infringement trademark",
55
+ "corporate_governance": "fiduciary duty shareholder derivative",
56
+ }
57
+ DOCS_PER_SUBDOMAIN: int = 50
58
+
59
+ # CourtListener is not publicly rate-limited, but be polite.
60
+ REQUEST_DELAY: float = 0.3 # seconds between requests
61
+
62
+ # Maximum characters to save per document (avoid multi-MB opinions dominating
63
+ # the corpus relative to shorter medical/financial docs).
64
+ MAX_CHARS: int = 15_000
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # API helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _headers() -> dict:
73
+ if not TOKEN:
74
+ raise EnvironmentError(
75
+ "COURTLISTENER_TOKEN environment variable is not set. "
76
+ "Get a free token at https://www.courtlistener.com/sign-in/"
77
+ )
78
+ return {"Authorization": f"Token {TOKEN}"}
79
+
80
+
81
+ def _get(url: str, params: dict = None) -> dict:
82
+ """GET with auth, raise on HTTP errors, return parsed JSON."""
83
+ resp = requests.get(url, headers=_headers(), params=params or {}, timeout=30)
84
+ resp.raise_for_status()
85
+ return resp.json()
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Search & text retrieval
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def search_opinions(query: str, count: int) -> List[dict]:
94
+ """
95
+ Search CourtListener for opinion clusters matching `query`.
96
+
97
+ Paginates through results until `count` items are collected or
98
+ there are no more pages.
99
+
100
+ Args:
101
+ query: Full-text search string.
102
+ count: Maximum number of results to return.
103
+
104
+ Returns:
105
+ List of result dicts from the CourtListener search API.
106
+ """
107
+ url = f"{BASE_URL}/search/"
108
+ params: dict = {
109
+ "q": query,
110
+ "type": "o", # opinions
111
+ "order_by": "score desc",
112
+ }
113
+ collected: List[dict] = []
114
+
115
+ while len(collected) < count:
116
+ try:
117
+ data = _get(url, params)
118
+ except requests.HTTPError as exc:
119
+ logger.error("Search request failed: %s", exc)
120
+ break
121
+
122
+ results = data.get("results", [])
123
+ collected.extend(results)
124
+
125
+ next_url = data.get("next")
126
+ if not next_url:
127
+ break
128
+ # Paginate: use next URL directly (already contains all params).
129
+ url = next_url
130
+ params = {}
131
+ time.sleep(REQUEST_DELAY)
132
+
133
+ return collected[:count]
134
+
135
+
136
+ def _extract_text_from_result(result: dict) -> str:
137
+ """
138
+ Pull opinion text from a search result dict.
139
+
140
+ CourtListener v4 search results for opinions may include:
141
+ - 'snippet' — highlighted search snippet (short)
142
+ - 'plain_text' — full plain text of the opinion (if indexed)
143
+
144
+ If neither is present (common for older opinions), fall back to
145
+ fetching the opinion's detail page via its cluster URL.
146
+ """
147
+ # Try direct text fields first (fast path).
148
+ for field in ("plain_text", "text", "snippet"):
149
+ val = result.get(field, "")
150
+ if val and len(val) > 200:
151
+ return val
152
+
153
+ # Slow path: fetch the first opinion document from the cluster.
154
+ cluster_id = result.get("cluster_id") or result.get("id")
155
+ if not cluster_id:
156
+ return ""
157
+
158
+ time.sleep(REQUEST_DELAY)
159
+ try:
160
+ cluster_data = _get(f"{BASE_URL}/clusters/{cluster_id}/")
161
+ sub_opinions = cluster_data.get("sub_opinions", [])
162
+ for opinion_url in sub_opinions:
163
+ # opinion_url is typically an absolute URL.
164
+ time.sleep(REQUEST_DELAY)
165
+ try:
166
+ op_data = _get(opinion_url)
167
+ for field in ("plain_text", "html_with_citations", "html", "xml_harvard"):
168
+ text = op_data.get(field, "") or ""
169
+ if len(text) > 200:
170
+ # Strip HTML tags if necessary.
171
+ if "<" in text:
172
+ text = _strip_html(text)
173
+ return text
174
+ except Exception as exc:
175
+ logger.debug("Opinion fetch error %s: %s", opinion_url, exc)
176
+ except Exception as exc:
177
+ logger.debug("Cluster fetch error %s: %s", cluster_id, exc)
178
+
179
+ return ""
180
+
181
+
182
+ def _strip_html(html: str) -> str:
183
+ """Very lightweight HTML tag stripper using the stdlib only."""
184
+ import re
185
+ text = re.sub(r"<[^>]+>", " ", html)
186
+ text = re.sub(r"&[a-z]+;", " ", text)
187
+ text = re.sub(r" {2,}", " ", text)
188
+ return text.strip()
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Main fetch loop
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def fetch_subdomain(
197
+ subdomain: str,
198
+ query: str,
199
+ n: int,
200
+ output_dir: Path,
201
+ ) -> List[Dict]:
202
+ """
203
+ Collect up to `n` opinion texts for `subdomain` and write them to disk.
204
+
205
+ Args:
206
+ subdomain: Label string used in the manifest.
207
+ query: CourtListener search query.
208
+ n: Target document count.
209
+ output_dir: Directory to write .txt files into.
210
+
211
+ Returns:
212
+ List of manifest entry dicts for successfully saved documents.
213
+ """
214
+ logger.info("Subdomain '%s': searching '%s' …", subdomain, query)
215
+ results = search_opinions(query, n)
216
+ logger.info(" Retrieved %d search results.", len(results))
217
+
218
+ entries: List[Dict] = []
219
+ for result in results:
220
+ # Cluster / opinion ID.
221
+ op_id = result.get("cluster_id") or result.get("id")
222
+ if not op_id:
223
+ continue
224
+
225
+ text = _extract_text_from_result(result)
226
+ if len(text) < 200:
227
+ logger.debug("Skipping %s — insufficient text (%d chars).", op_id, len(text))
228
+ continue
229
+
230
+ text = text[:MAX_CHARS]
231
+ out_path = output_dir / f"case_{op_id}.txt"
232
+ out_path.write_text(text, encoding="utf-8")
233
+
234
+ entries.append(
235
+ {
236
+ "id": op_id,
237
+ "subdomain": subdomain,
238
+ "case_name": result.get("caseName") or result.get("case_name", ""),
239
+ "court": result.get("court", ""),
240
+ "date_filed": result.get("dateFiled") or result.get("date_filed", ""),
241
+ "source_url": (
242
+ result.get("absolute_url")
243
+ or f"https://www.courtlistener.com/?q=id:{op_id}"
244
+ ),
245
+ "path": str(out_path),
246
+ "char_count": len(text),
247
+ }
248
+ )
249
+ logger.info(" Saved case_%s (%d chars)", op_id, len(text))
250
+
251
+ logger.info(" Subdomain '%s': %d documents saved.", subdomain, len(entries))
252
+ return entries
253
+
254
+
255
+ def main(docs_per_subdomain: int = DOCS_PER_SUBDOMAIN) -> None:
256
+ """Entry point."""
257
+ if not TOKEN:
258
+ sys.exit(
259
+ "Error: COURTLISTENER_TOKEN environment variable is not set.\n"
260
+ "Get a free API token at https://www.courtlistener.com/sign-in/"
261
+ )
262
+
263
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
264
+ manifest: List[Dict] = []
265
+
266
+ for subdomain, query in SEARCH_TERMS.items():
267
+ entries = fetch_subdomain(subdomain, query, docs_per_subdomain, OUTPUT_DIR)
268
+ manifest.extend(entries)
269
+
270
+ manifest_path = OUTPUT_DIR / "manifest.json"
271
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
272
+ logger.info(
273
+ "Done. %d legal documents fetched. Manifest → %s",
274
+ len(manifest),
275
+ manifest_path,
276
+ )
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # CLI
281
+ # ---------------------------------------------------------------------------
282
+
283
+ if __name__ == "__main__":
284
+ parser = argparse.ArgumentParser(
285
+ description="Fetch CourtListener opinions for the MEXAR legal corpus."
286
+ )
287
+ parser.add_argument(
288
+ "--docs-per-subdomain",
289
+ type=int,
290
+ default=DOCS_PER_SUBDOMAIN,
291
+ help="Documents to fetch per legal subdomain (default: %(default)s).",
292
+ )
293
+ args = parser.parse_args()
294
+ main(docs_per_subdomain=args.docs_per_subdomain)
backend/scripts/fetch_pubmed.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MEXAR - PubMed Central OA fetcher for the medical corpus.
3
+
4
+ Pulls ~220 documents from PubMed Central Open Access across three medical
5
+ subdomains (cardiology, oncology, internal medicine), parses the full-text XML
6
+ into plain text, and writes one .txt file per document into
7
+ test_data/medical_real/, along with a manifest.json for auditing.
8
+
9
+ Usage:
10
+ python backend/scripts/fetch_pubmed.py
11
+
12
+ Requirements:
13
+ pip install requests lxml
14
+
15
+ Environment:
16
+ NCBI_EMAIL — your email address (required by NCBI usage policy)
17
+ NCBI_API_KEY — optional; raises rate limit from 3 req/sec to 10 req/sec
18
+
19
+ Run this locally (not in a network-restricted sandbox).
20
+ """
21
+ import argparse
22
+ import json
23
+ import logging
24
+ import os
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Dict, List, Optional
29
+
30
+ import requests
31
+
32
+ try:
33
+ from lxml import etree
34
+ except ImportError:
35
+ sys.exit(
36
+ "lxml is required — run: pip install lxml"
37
+ )
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Configuration
41
+ # ---------------------------------------------------------------------------
42
+
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format="%(asctime)s [%(levelname)s] %(message)s",
46
+ )
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # NCBI email is mandatory per NCBI E-utilities usage policy.
50
+ # Override with the NCBI_EMAIL environment variable or the --email CLI flag.
51
+ DEFAULT_NCBI_EMAIL: str = os.getenv("NCBI_EMAIL", "your_email@example.com")
52
+ NCBI_API_KEY: Optional[str] = os.getenv("NCBI_API_KEY") # optional; raises rate cap to 10/s
53
+
54
+ # One .txt per doc, one manifest.json — relative to repo root.
55
+ OUTPUT_DIR = Path("test_data/medical_real")
56
+
57
+ # Three subdomains × 75 docs ≈ 220 total (some may fail or be empty).
58
+ SEARCH_TERMS: Dict[str, str] = {
59
+ "cardiology": (
60
+ '"cardiology treatment guideline"[Title/Abstract] AND "open access"[filter]'
61
+ ),
62
+ "oncology": (
63
+ '"oncology treatment protocol"[Title/Abstract] AND "open access"[filter]'
64
+ ),
65
+ "internal_medicine": (
66
+ '"internal medicine diagnostic criteria"[Title/Abstract] AND "open access"[filter]'
67
+ ),
68
+ }
69
+ DOCS_PER_SUBDOMAIN: int = 75
70
+
71
+ # NCBI rate limits: 3 req/sec without key, 10/sec with key.
72
+ REQUEST_DELAY_NO_KEY: float = 0.35 # ≈ 2.9 req/sec — safe without key
73
+ REQUEST_DELAY_WITH_KEY: float = 0.12 # ≈ 8.3 req/sec — safe with key
74
+
75
+ NCBI_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # NCBI E-utilities helpers
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def _ncbi_params(extra: dict, email: str) -> dict:
83
+ """Return base params dict merged with extra, including optional API key."""
84
+ params = {"email": email, **extra}
85
+ if NCBI_API_KEY:
86
+ params["api_key"] = NCBI_API_KEY
87
+ return params
88
+
89
+
90
+ def esearch(term: str, retmax: int, email: str) -> List[str]:
91
+ """
92
+ Run an E-search against the PMC database and return a list of PMC IDs.
93
+
94
+ Args:
95
+ term: NCBI search query string.
96
+ retmax: Maximum number of IDs to retrieve.
97
+ email: Requester email (required by NCBI).
98
+
99
+ Returns:
100
+ List of PMC ID strings (numeric, without the 'PMC' prefix).
101
+ """
102
+ url = f"{NCBI_BASE}/esearch.fcgi"
103
+ params = _ncbi_params(
104
+ {"db": "pmc", "term": term, "retmax": retmax, "retmode": "json"},
105
+ email,
106
+ )
107
+ resp = requests.get(url, params=params, timeout=30)
108
+ resp.raise_for_status()
109
+ return resp.json()["esearchresult"]["idlist"]
110
+
111
+
112
+ def efetch_xml(pmc_id: str, email: str) -> bytes:
113
+ """
114
+ Fetch the full-text XML for a single PMC article.
115
+
116
+ Args:
117
+ pmc_id: Numeric PMC ID string (without the 'PMC' prefix).
118
+ email: Requester email.
119
+
120
+ Returns:
121
+ Raw XML bytes from the PMC OA full-text endpoint.
122
+ """
123
+ url = f"{NCBI_BASE}/efetch.fcgi"
124
+ params = _ncbi_params(
125
+ {"db": "pmc", "id": pmc_id, "rettype": "full", "retmode": "xml"},
126
+ email,
127
+ )
128
+ resp = requests.get(url, params=params, timeout=60)
129
+ resp.raise_for_status()
130
+ return resp.content
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # XML → plain-text extraction
135
+ # ---------------------------------------------------------------------------
136
+
137
+ # Tags in the JATS PMC XML that contain useful body text.
138
+ _BODY_TAGS = {"abstract", "body", "sec", "p", "title"}
139
+
140
+
141
+ def _iter_text(element) -> str:
142
+ """
143
+ Recursively collect all text inside an lxml element, with
144
+ paragraph-level whitespace to keep the output readable.
145
+ """
146
+ parts = []
147
+ if element.text:
148
+ parts.append(element.text.strip())
149
+ for child in element:
150
+ child_text = _iter_text(child)
151
+ if child_text:
152
+ # Add a blank line between sections/paragraphs.
153
+ sep = "\n\n" if child.tag in {"sec", "p", "title", "abstract"} else " "
154
+ parts.append(sep + child_text)
155
+ if child.tail:
156
+ parts.append(child.tail.strip())
157
+ return " ".join(p for p in parts if p)
158
+
159
+
160
+ def xml_to_plain_text(xml_bytes: bytes) -> str:
161
+ """
162
+ Parse PMC full-text XML and extract human-readable plain text.
163
+
164
+ Pulls text from <abstract>, <body>, and nested <sec>/<p> elements,
165
+ strips all XML markup, and returns a clean, whitespace-normalised string.
166
+
167
+ Args:
168
+ xml_bytes: Raw XML bytes as returned by efetch.
169
+
170
+ Returns:
171
+ Plain-text string. Empty string if parsing fails.
172
+ """
173
+ try:
174
+ root = etree.fromstring(xml_bytes)
175
+ except etree.XMLSyntaxError as exc:
176
+ logger.warning("XML parse error: %s", exc)
177
+ return ""
178
+
179
+ sections: List[str] = []
180
+ # Pull <article-title>, <abstract>, and <body> in document order.
181
+ for tag in ("article-title", "abstract", "body"):
182
+ for element in root.iter(tag):
183
+ text = _iter_text(element).strip()
184
+ if text:
185
+ sections.append(text)
186
+
187
+ plain = "\n\n".join(sections)
188
+ # Collapse excessive whitespace.
189
+ import re
190
+ plain = re.sub(r" {2,}", " ", plain)
191
+ plain = re.sub(r"\n{3,}", "\n\n", plain)
192
+ return plain.strip()
193
+
194
+
195
+ def _get_article_meta(xml_bytes: bytes) -> Dict[str, str]:
196
+ """Extract title and journal name from parsed PMC XML for the manifest."""
197
+ meta: Dict[str, str] = {"title": "", "journal": ""}
198
+ try:
199
+ root = etree.fromstring(xml_bytes)
200
+ title_el = root.find(".//article-title")
201
+ if title_el is not None:
202
+ meta["title"] = (_iter_text(title_el) or "").strip()
203
+ journal_el = root.find(".//journal-title")
204
+ if journal_el is not None:
205
+ meta["journal"] = (journal_el.text or "").strip()
206
+ except Exception:
207
+ pass
208
+ return meta
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Main fetch loop
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def fetch_subdomain(
217
+ subdomain: str,
218
+ term: str,
219
+ n: int,
220
+ output_dir: Path,
221
+ email: str,
222
+ delay: float,
223
+ ) -> List[Dict]:
224
+ """
225
+ Search for and download up to `n` PMC articles matching `term`.
226
+
227
+ Args:
228
+ subdomain: Label string used in the manifest.
229
+ term: NCBI search query.
230
+ n: Target number of documents.
231
+ output_dir: Directory to write .txt files into.
232
+ email: NCBI requester email.
233
+ delay: Seconds to sleep between requests.
234
+
235
+ Returns:
236
+ List of manifest entry dicts for successfully fetched documents.
237
+ """
238
+ logger.info("Subdomain '%s': searching for %d docs …", subdomain, n)
239
+ try:
240
+ ids = esearch(term, n, email)
241
+ except Exception as exc:
242
+ logger.error("esearch failed for '%s': %s", subdomain, exc)
243
+ return []
244
+
245
+ logger.info(" Found %d IDs; fetching full text …", len(ids))
246
+ entries: List[Dict] = []
247
+
248
+ for pmc_id in ids:
249
+ time.sleep(delay)
250
+ try:
251
+ xml_bytes = efetch_xml(pmc_id, email)
252
+ plain = xml_to_plain_text(xml_bytes)
253
+ except requests.HTTPError as exc:
254
+ logger.warning("HTTP error for PMC%s: %s", pmc_id, exc)
255
+ continue
256
+ except Exception as exc:
257
+ logger.warning("Failed PMC%s: %s", pmc_id, exc)
258
+ continue
259
+
260
+ if len(plain) < 200:
261
+ logger.debug("PMC%s: text too short (%d chars), skipping.", pmc_id, len(plain))
262
+ continue
263
+
264
+ out_path = output_dir / f"PMC{pmc_id}.txt"
265
+ out_path.write_text(plain, encoding="utf-8")
266
+
267
+ meta = _get_article_meta(xml_bytes)
268
+ entries.append(
269
+ {
270
+ "pmc_id": pmc_id,
271
+ "subdomain": subdomain,
272
+ "title": meta["title"],
273
+ "journal": meta["journal"],
274
+ "source_url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC{pmc_id}/",
275
+ "path": str(out_path),
276
+ "char_count": len(plain),
277
+ }
278
+ )
279
+ logger.info(" Saved PMC%s (%d chars)", pmc_id, len(plain))
280
+
281
+ logger.info(" Subdomain '%s': %d documents saved.", subdomain, len(entries))
282
+ return entries
283
+
284
+
285
+ def main(email: str = DEFAULT_NCBI_EMAIL, docs_per_subdomain: int = DOCS_PER_SUBDOMAIN) -> None:
286
+ """
287
+ Entry point. Iterates over all three medical subdomains and writes results.
288
+ """
289
+ if email == "your_email@example.com":
290
+ logger.warning(
291
+ "Using placeholder email — set the NCBI_EMAIL env var or pass --email. "
292
+ "NCBI may throttle requests without a valid email."
293
+ )
294
+
295
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
296
+
297
+ delay = REQUEST_DELAY_WITH_KEY if NCBI_API_KEY else REQUEST_DELAY_NO_KEY
298
+ manifest: List[Dict] = []
299
+
300
+ for subdomain, term in SEARCH_TERMS.items():
301
+ entries = fetch_subdomain(subdomain, term, docs_per_subdomain, OUTPUT_DIR, email, delay)
302
+ manifest.extend(entries)
303
+
304
+ manifest_path = OUTPUT_DIR / "manifest.json"
305
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
306
+ logger.info(
307
+ "Done. %d medical documents fetched. Manifest → %s",
308
+ len(manifest),
309
+ manifest_path,
310
+ )
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # CLI
315
+ # ---------------------------------------------------------------------------
316
+
317
+ if __name__ == "__main__":
318
+ parser = argparse.ArgumentParser(
319
+ description="Fetch PubMed Central OA articles for the MEXAR medical corpus."
320
+ )
321
+ parser.add_argument(
322
+ "--email",
323
+ default=DEFAULT_NCBI_EMAIL,
324
+ help="Your email address (required by NCBI usage policy).",
325
+ )
326
+ parser.add_argument(
327
+ "--docs-per-subdomain",
328
+ type=int,
329
+ default=DOCS_PER_SUBDOMAIN,
330
+ help="Number of documents to fetch per subdomain (default: %(default)s).",
331
+ )
332
+ args = parser.parse_args()
333
+ main(email=args.email, docs_per_subdomain=args.docs_per_subdomain)
backend/scripts/fetch_secedgar.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MEXAR - SEC EDGAR fetcher for the financial corpus.
3
+
4
+ Fetches ~130 10-K filing sections (MD&A, Risk Factors) from SEC EDGAR using
5
+ the full-text search API, follows through to the actual EDGAR filing documents,
6
+ extracts the relevant section text, and saves one .txt file per document plus
7
+ a manifest.json.
8
+
9
+ Usage:
10
+ python backend/scripts/fetch_secedgar.py
11
+
12
+ Requirements:
13
+ pip install requests beautifulsoup4 lxml
14
+
15
+ SEC Fair Access Policy:
16
+ Every request MUST include a descriptive User-Agent header in the format:
17
+ "FirstName LastName email@example.com"
18
+ without this, SEC will block your IP.
19
+
20
+ Override with the SEC_USER_AGENT environment variable.
21
+
22
+ Run this locally (not in a network-restricted sandbox).
23
+ """
24
+ import argparse
25
+ import json
26
+ import logging
27
+ import os
28
+ import re
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Dict, List, Optional, Tuple
33
+ from urllib.parse import urljoin
34
+
35
+ import requests
36
+
37
+ try:
38
+ from bs4 import BeautifulSoup
39
+ except ImportError:
40
+ sys.exit("beautifulsoup4 is required — run: pip install beautifulsoup4 lxml")
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Configuration
44
+ # ---------------------------------------------------------------------------
45
+
46
+ logging.basicConfig(
47
+ level=logging.INFO,
48
+ format="%(asctime)s [%(levelname)s] %(message)s",
49
+ )
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # SEC requires a descriptive User-Agent on every request.
53
+ # Format: "Name Email" — use yours or set SEC_USER_AGENT env var.
54
+ _DEFAULT_USER_AGENT = "Devrajsinh Gohil djgohil2012@gmail.com"
55
+ USER_AGENT: str = os.getenv("SEC_USER_AGENT", _DEFAULT_USER_AGENT)
56
+
57
+ HEADERS: Dict[str, str] = {
58
+ "User-Agent": USER_AGENT,
59
+ "Accept-Encoding": "gzip, deflate",
60
+ "Host": "efts.sec.gov",
61
+ }
62
+ EDGAR_HEADERS: Dict[str, str] = {
63
+ "User-Agent": USER_AGENT,
64
+ "Accept-Encoding": "gzip, deflate",
65
+ }
66
+
67
+ OUTPUT_DIR = Path("test_data/financial_real")
68
+
69
+ # Search terms and per-term document targets (~130 total).
70
+ SEARCH_CONFIG: List[Tuple[str, str, int]] = [
71
+ ("risk_factors", "risk factors", 45),
72
+ ("management_discussion", "management discussion and analysis", 45),
73
+ ("accounting_standards", "GAAP IFRS accounting standards", 40),
74
+ ]
75
+
76
+ # Maximum characters per saved document — keep consistent with medical/legal sizes.
77
+ MAX_CHARS: int = 15_000
78
+
79
+ # Be polite to SEC servers.
80
+ REQUEST_DELAY: float = 0.25 # seconds
81
+
82
+ # EDGAR full-text search endpoint.
83
+ EFTS_URL = "https://efts.sec.gov/LATEST/search-index"
84
+ EDGAR_ARCHIVES = "https://www.sec.gov/Archives/edgar/data"
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # SEC EDGAR API helpers
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _get(url: str, params: dict = None, extra_headers: dict = None) -> requests.Response:
92
+ """GET with SEC-compliant headers and error handling."""
93
+ h = {**EDGAR_HEADERS}
94
+ if extra_headers:
95
+ h.update(extra_headers)
96
+ resp = requests.get(url, headers=h, params=params or {}, timeout=30)
97
+ resp.raise_for_status()
98
+ return resp
99
+
100
+
101
+ def full_text_search(query: str, count: int) -> List[dict]:
102
+ """
103
+ Query the SEC EDGAR full-text search API for 10-K filings.
104
+
105
+ The EFTS search endpoint returns hit metadata only — not the filing body.
106
+ Each hit contains the accession number and CIK needed to locate the filing
107
+ document in EDGAR archives.
108
+
109
+ Args:
110
+ query: Search phrase (e.g. "risk factors").
111
+ count: Maximum number of hits to return.
112
+
113
+ Returns:
114
+ List of _source dicts from the EFTS hits array.
115
+ """
116
+ # EFTS search uses query-string encoding.
117
+ params = {
118
+ "q": f'"{query}"',
119
+ "forms": "10-K",
120
+ "dateRange": "custom",
121
+ "startdt": "2020-01-01",
122
+ "enddt": "2024-12-31",
123
+ }
124
+ try:
125
+ resp = _get(
126
+ EFTS_URL,
127
+ params=params,
128
+ extra_headers={"Host": "efts.sec.gov"},
129
+ )
130
+ except Exception as exc:
131
+ logger.error("EFTS search failed for '%s': %s", query, exc)
132
+ return []
133
+
134
+ data = resp.json()
135
+ hits = data.get("hits", {}).get("hits", [])
136
+ logger.info(" EFTS returned %d hits for '%s'", len(hits), query)
137
+ return [h.get("_source", {}) for h in hits[:count]]
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Filing document text extraction
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def _accession_to_path(accession: str) -> str:
145
+ """Convert '0001234567-23-012345' → '000123456723012345'."""
146
+ return accession.replace("-", "")
147
+
148
+
149
+ def _get_filing_index(cik: str, accession: str) -> Optional[dict]:
150
+ """
151
+ Fetch the filing index JSON from EDGAR to find the primary document URL.
152
+
153
+ Returns the parsed index data dict, or None on failure.
154
+ """
155
+ cik_int = str(int(cik)) if cik.isdigit() else cik
156
+ acc_path = _accession_to_path(accession)
157
+ url = (
158
+ f"https://data.sec.gov/submissions/CIK{cik_int.zfill(10)}.json"
159
+ )
160
+ # Use the filing index endpoint instead.
161
+ index_url = (
162
+ f"https://www.sec.gov/cgi-bin/browse-edgar"
163
+ f"?action=getcompany&CIK={cik_int}&type=10-K&dateb=&owner=include&count=1&search_text="
164
+ )
165
+ # Directly hit the accession-number based index.
166
+ direct_url = (
167
+ f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_path}/{accession}-index.json"
168
+ )
169
+ try:
170
+ resp = _get(direct_url)
171
+ return resp.json()
172
+ except Exception:
173
+ pass
174
+ # Fallback: try the .htm index.
175
+ try:
176
+ htm_url = (
177
+ f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_path}/{accession}-index.htm"
178
+ )
179
+ resp = _get(htm_url)
180
+ # Parse the index page for the primary document link.
181
+ soup = BeautifulSoup(resp.text, "lxml")
182
+ return {"_html": resp.text, "_base": f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_path}/"}
183
+ except Exception as exc:
184
+ logger.debug("Filing index fetch failed for %s/%s: %s", cik, accession, exc)
185
+ return None
186
+
187
+
188
+ def _find_primary_document_url(cik: str, accession: str, index_data: dict) -> Optional[str]:
189
+ """
190
+ Given filing index data, return the URL of the primary 10-K HTML document.
191
+ """
192
+ acc_path = _accession_to_path(accession)
193
+ base = f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc_path}/"
194
+
195
+ # JSON index format (modern filings).
196
+ if "documents" in index_data:
197
+ for doc in index_data.get("documents", []):
198
+ doc_type = doc.get("type", "").upper()
199
+ if doc_type in ("10-K", "10-K/A"):
200
+ filename = doc.get("filename") or doc.get("documentName", "")
201
+ if filename:
202
+ return base + filename
203
+
204
+ # HTML index fallback.
205
+ if "_html" in index_data:
206
+ soup = BeautifulSoup(index_data["_html"], "lxml")
207
+ for row in soup.find_all("tr"):
208
+ cells = row.find_all("td")
209
+ if len(cells) >= 3:
210
+ doc_type = cells[3].get_text(strip=True) if len(cells) > 3 else ""
211
+ if "10-K" in doc_type:
212
+ link = cells[2].find("a")
213
+ if link and link.get("href"):
214
+ href = link["href"]
215
+ if href.startswith("/"):
216
+ return "https://www.sec.gov" + href
217
+ return href
218
+ # If no 10-K row found, grab any .htm link from the table.
219
+ for link in soup.select("table a[href]"):
220
+ href = link["href"]
221
+ if href.endswith(".htm") or href.endswith(".html"):
222
+ if href.startswith("/"):
223
+ return "https://www.sec.gov" + href
224
+ return href
225
+
226
+ return None
227
+
228
+
229
+ def _extract_section(html: str, section: str) -> str:
230
+ """
231
+ Extract a named section (e.g. 'risk factors', 'management discussion')
232
+ from a 10-K HTML document.
233
+
234
+ Strategy:
235
+ 1. Parse with BeautifulSoup.
236
+ 2. Find the first heading element (<b>, <strong>, <h2>, <h3>, <p>) whose
237
+ text closely matches the section name.
238
+ 3. Collect all subsequent paragraph text until the next major heading.
239
+
240
+ Falls back to returning the first 15,000 chars of the full body text if
241
+ no matching section heading is found.
242
+ """
243
+ soup = BeautifulSoup(html, "lxml")
244
+
245
+ # Remove script/style noise.
246
+ for tag in soup(["script", "style", "meta", "link"]):
247
+ tag.decompose()
248
+
249
+ section_lower = section.lower()
250
+ # Headings to search in.
251
+ heading_candidates = soup.find_all(
252
+ ["b", "strong", "h1", "h2", "h3", "h4", "p"],
253
+ )
254
+
255
+ section_start = None
256
+ for el in heading_candidates:
257
+ text = el.get_text(" ", strip=True).lower()
258
+ if section_lower in text and len(text) < 200:
259
+ section_start = el
260
+ break
261
+
262
+ if section_start is None:
263
+ # No section heading found — return the full text (truncated).
264
+ full_text = soup.get_text(" ", strip=True)
265
+ return re.sub(r" {2,}", " ", full_text)
266
+
267
+ # Collect text from siblings/following elements until next major heading.
268
+ parts: List[str] = []
269
+ total = 0
270
+ for sibling in section_start.find_all_next():
271
+ tag_name = sibling.name or ""
272
+ text = sibling.get_text(" ", strip=True)
273
+ if not text:
274
+ continue
275
+
276
+ # Stop at the next major heading that looks like a different section.
277
+ if tag_name in ("h1", "h2", "h3"):
278
+ break
279
+ if tag_name in ("b", "strong", "p") and len(text) < 200:
280
+ text_lower = text.lower()
281
+ # Check for common 10-K section heading patterns.
282
+ if re.match(r"(item\s+\d|part\s+[iv]+)", text_lower):
283
+ break
284
+
285
+ parts.append(text)
286
+ total += len(text)
287
+ if total >= MAX_CHARS:
288
+ break
289
+
290
+ extracted = " ".join(parts)
291
+ extracted = re.sub(r" {2,}", " ", extracted)
292
+ return extracted.strip()
293
+
294
+
295
+ def fetch_and_extract(source: dict, query_label: str) -> Tuple[str, str, int]:
296
+ """
297
+ Follow through from an EFTS hit's metadata to the actual filing text.
298
+
299
+ Args:
300
+ source: _source dict from an EFTS hit.
301
+ query_label: Human-readable label for the search term (used for
302
+ section extraction hints).
303
+
304
+ Returns:
305
+ (text, filing_url, char_count) — text is empty string on failure.
306
+ """
307
+ cik_list = source.get("ciks", [])
308
+ cik = str(cik_list[0]) if cik_list else source.get("cik", "")
309
+ accession = source.get("adsh", "")
310
+
311
+ if not cik or not accession:
312
+ return "", "", 0
313
+
314
+ acc_path = _accession_to_path(accession)
315
+ filing_url = f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc_path}/"
316
+
317
+ time.sleep(REQUEST_DELAY)
318
+
319
+ # Step 1: Get filing index to find the primary document.
320
+ index_data = _get_filing_index(cik, accession)
321
+ if not index_data:
322
+ logger.debug("No index for %s / %s", cik, accession)
323
+ return "", filing_url, 0
324
+
325
+ doc_url = _find_primary_document_url(cik, acc_path, index_data)
326
+ if not doc_url:
327
+ logger.debug("Could not locate primary doc for %s / %s", cik, accession)
328
+ return "", filing_url, 0
329
+
330
+ time.sleep(REQUEST_DELAY)
331
+
332
+ # Step 2: Fetch the 10-K document.
333
+ try:
334
+ resp = _get(doc_url)
335
+ html = resp.text
336
+ except Exception as exc:
337
+ logger.debug("Failed to fetch filing document %s: %s", doc_url, exc)
338
+ return "", filing_url, 0
339
+
340
+ # Step 3: Extract the relevant section.
341
+ section_hint = query_label.replace("_", " ")
342
+ text = _extract_section(html, section_hint)
343
+ if len(text) > MAX_CHARS:
344
+ text = text[:MAX_CHARS]
345
+
346
+ return text, doc_url, len(text)
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # Main fetch loop
351
+ # ---------------------------------------------------------------------------
352
+
353
+
354
+ def fetch_term(
355
+ label: str,
356
+ query: str,
357
+ n: int,
358
+ output_dir: Path,
359
+ ) -> List[Dict]:
360
+ """
361
+ Search EDGAR for `n` filings matching `query`, extract section text, save.
362
+
363
+ Args:
364
+ label: Short label for the search term (used in filename + manifest).
365
+ query: EDGAR full-text search phrase.
366
+ n: Target document count.
367
+ output_dir: Directory to write .txt files into.
368
+
369
+ Returns:
370
+ List of manifest entry dicts.
371
+ """
372
+ logger.info("Term '%s': searching EDGAR for %d docs …", label, n)
373
+ sources = full_text_search(query, n)
374
+
375
+ entries: List[Dict] = []
376
+ seen = set()
377
+
378
+ for source in sources:
379
+ cik_list = source.get("ciks", [])
380
+ cik = str(cik_list[0]) if cik_list else source.get("cik", "")
381
+ accession = source.get("adsh", "")
382
+
383
+ if not cik or not accession:
384
+ continue
385
+
386
+ doc_key = f"{cik}_{accession}"
387
+ if doc_key in seen:
388
+ continue
389
+ seen.add(doc_key)
390
+
391
+ text, doc_url, char_count = fetch_and_extract(source, label)
392
+ if char_count < 200:
393
+ logger.debug("Skipping %s — insufficient text (%d chars).", doc_key, char_count)
394
+ continue
395
+
396
+ out_path = output_dir / f"{doc_key}.txt"
397
+ out_path.write_text(text, encoding="utf-8")
398
+
399
+ company_names = source.get("display_names", [])
400
+ company = company_names[0] if company_names else source.get("entity_name", "")
401
+
402
+ entries.append(
403
+ {
404
+ "cik": cik,
405
+ "accession": accession,
406
+ "company": company,
407
+ "term": label,
408
+ "filing_date": source.get("period_of_report", ""),
409
+ "source_url": doc_url,
410
+ "path": str(out_path),
411
+ "char_count": char_count,
412
+ }
413
+ )
414
+ logger.info(" Saved %s (%d chars)", doc_key, char_count)
415
+
416
+ logger.info(" Term '%s': %d documents saved.", label, len(entries))
417
+ return entries
418
+
419
+
420
+ def main() -> None:
421
+ """Entry point."""
422
+ if USER_AGENT == _DEFAULT_USER_AGENT:
423
+ logger.warning(
424
+ "Using default User-Agent '%s'. "
425
+ "Set the SEC_USER_AGENT env var to 'YourName your@email.com' "
426
+ "to comply with SEC fair access policy.",
427
+ USER_AGENT,
428
+ )
429
+
430
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
431
+ manifest: List[Dict] = []
432
+
433
+ for label, query, target_n in SEARCH_CONFIG:
434
+ entries = fetch_term(label, query, target_n, OUTPUT_DIR)
435
+ manifest.extend(entries)
436
+
437
+ manifest_path = OUTPUT_DIR / "manifest.json"
438
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
439
+ logger.info(
440
+ "Done. %d financial documents fetched. Manifest → %s",
441
+ len(manifest),
442
+ manifest_path,
443
+ )
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # CLI
448
+ # ---------------------------------------------------------------------------
449
+
450
+ if __name__ == "__main__":
451
+ parser = argparse.ArgumentParser(
452
+ description="Fetch SEC EDGAR 10-K sections for the MEXAR financial corpus."
453
+ )
454
+ parser.add_argument(
455
+ "--user-agent",
456
+ default=None,
457
+ help=(
458
+ "SEC-compliant User-Agent string ('FirstName LastName email@example.com'). "
459
+ "Overrides the SEC_USER_AGENT environment variable."
460
+ ),
461
+ )
462
+ args = parser.parse_args()
463
+ if args.user_agent:
464
+ USER_AGENT = args.user_agent
465
+ HEADERS["User-Agent"] = USER_AGENT
466
+ EDGAR_HEADERS["User-Agent"] = USER_AGENT
467
+ main()
backend/scripts/run_migrations.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import psycopg2
4
+ from dotenv import load_dotenv
5
+
6
+ if sys.stdout.encoding.lower() != 'utf-8':
7
+ sys.stdout.reconfigure(encoding='utf-8')
8
+
9
+ load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
10
+
11
+ db_url = os.getenv("DATABASE_URL")
12
+ if not db_url:
13
+ raise ValueError("DATABASE_URL environment variable is not set in backend/.env")
14
+
15
+ print("Connecting to database to apply migrations...")
16
+ conn = psycopg2.connect(db_url)
17
+ conn.autocommit = True
18
+ cur = conn.cursor()
19
+
20
+ migrations = [
21
+ (
22
+ "Phase 1.1: Add domain_signature_weights & domain_entities columns to agents table",
23
+ """
24
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS domain_signature_weights JSONB;
25
+ ALTER TABLE agents ADD COLUMN IF NOT EXISTS domain_entities JSONB;
26
+ COMMENT ON COLUMN agents.domain_signature_weights IS
27
+ 'TF-IDF lexical weights dict {term: weight} for domain signature (Section III-A, Eq. 1)';
28
+ COMMENT ON COLUMN agents.domain_entities IS
29
+ 'NER-extracted domain entities list for domain signature (Section III-A)';
30
+ """
31
+ ),
32
+ (
33
+ "Phase 1.2: Switch vector index on document_chunks from IVFFlat to HNSW",
34
+ """
35
+ DROP INDEX IF EXISTS idx_document_chunks_embedding;
36
+ DROP INDEX IF EXISTS chunks_embedding_idx;
37
+ CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding_hnsw
38
+ ON document_chunks USING hnsw (embedding vector_cosine_ops)
39
+ WITH (m = 16, ef_construction = 64);
40
+ COMMENT ON INDEX idx_document_chunks_embedding_hnsw IS
41
+ 'HNSW index for cosine similarity search, m=16, ef_construction=64 (Section III-B)';
42
+ """
43
+ ),
44
+ (
45
+ "Add preferences column to users table",
46
+ """
47
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences JSON DEFAULT '{}';
48
+ """
49
+ )
50
+ ]
51
+
52
+ for name, sql in migrations:
53
+ print(f"\nApplying migration: {name}...")
54
+ try:
55
+ cur.execute(sql)
56
+ print(f"[SUCCESS] {name}")
57
+ except Exception as e:
58
+ print(f"[ERROR] applying {name}: {e}")
59
+
60
+ print("\n--- Migration Verification ---")
61
+
62
+ # Verify agents table columns
63
+ cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'agents';")
64
+ agent_cols = [r[0] for r in cur.fetchall()]
65
+ print("Agents columns:", agent_cols)
66
+ assert "domain_signature_weights" in agent_cols, "domain_signature_weights missing from agents!"
67
+ assert "domain_entities" in agent_cols, "domain_entities missing from agents!"
68
+ print(" [OK] agents.domain_signature_weights present")
69
+ print(" [OK] agents.domain_entities present")
70
+
71
+ # Verify document_chunks index
72
+ cur.execute("SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'document_chunks';")
73
+ chunk_indexes = cur.fetchall()
74
+ print("\nDocument Chunks indexes:")
75
+ hnsw_found = False
76
+ for idx_name, idx_def in chunk_indexes:
77
+ print(f" - {idx_name}: {idx_def}")
78
+ if "hnsw" in idx_def.lower():
79
+ hnsw_found = True
80
+
81
+ assert hnsw_found, "HNSW index missing on document_chunks!"
82
+ print(" [OK] HNSW index verified on document_chunks.embedding")
83
+
84
+ # Verify users preferences column
85
+ cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';")
86
+ user_cols = [r[0] for r in cur.fetchall()]
87
+ assert "preferences" in user_cols, "preferences missing from users!"
88
+ print(" [OK] users.preferences column present")
89
+
90
+ cur.close()
91
+ conn.close()
92
+ print("\nALL MIGRATIONS COMPLETED AND VERIFIED SUCCESSFULLY!")
backend/utils/domain_signature.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MEXAR - Domain Signature Construction
3
+ Implements Section III-A: TF-IDF lexical signature + NER entity signature.
4
+
5
+ build_domain_signature() produces Sigma = Sigma_lex ∪ Sigma_ent, kept separate
6
+ for inspection. The 'combined' list is stored in the domain_signature JSONB column
7
+ on agents; 'lexical_weights' is stored in domain_signature_weights for optional
8
+ weighted Jaccard use later.
9
+ """
10
+ from typing import List, Dict, Set, Tuple
11
+ from sklearn.feature_extraction.text import TfidfVectorizer
12
+ import spacy
13
+ import logging
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Lazy-loaded spaCy model (avoid paying load cost at import time)
18
+ _nlp = None
19
+
20
+ # Entity labels relevant to professional domains (medical / legal / financial)
21
+ RELEVANT_ENTITY_LABELS = {"ORG", "LAW", "PRODUCT", "GPE", "PERSON", "NORP", "EVENT", "FAC"}
22
+
23
+
24
+ def get_nlp():
25
+ """Lazy-load and cache the spaCy en_core_web_sm model."""
26
+ global _nlp
27
+ if _nlp is None:
28
+ try:
29
+ _nlp = spacy.load("en_core_web_sm")
30
+ logger.info("spaCy en_core_web_sm loaded for domain guardrail NER.")
31
+ except OSError:
32
+ logger.error(
33
+ "spaCy model 'en_core_web_sm' not found. "
34
+ "Run: python -m spacy download en_core_web_sm"
35
+ )
36
+ raise
37
+ return _nlp
38
+
39
+
40
+ def build_tfidf_signature(
41
+ documents: List[str],
42
+ top_n: int = 100,
43
+ tau_tf: float = 0.0,
44
+ ) -> List[Tuple[str, float]]:
45
+ """
46
+ Eq. 1: w(t, D) = tf(t, D) * log(|D| / |{d in D : t in d}|)
47
+
48
+ Returns top_n (term, weight) pairs above tau_tf, sorted descending by weight.
49
+
50
+ Args:
51
+ documents: List of per-source document texts.
52
+ top_n: Maximum number of terms to return.
53
+ tau_tf: Minimum TF-IDF weight threshold.
54
+
55
+ Returns:
56
+ List of (term, weight) tuples sorted by weight descending.
57
+ """
58
+ if not documents:
59
+ return []
60
+
61
+ # TF-IDF requires a corpus of multiple documents so IDF is non-degenerate.
62
+ # If only a single document is provided (e.g. one uploaded file), split it
63
+ # into paragraph-level pseudo-documents.
64
+ if len(documents) < 2:
65
+ documents = _split_into_pseudo_documents(documents[0]) if documents[0] else []
66
+ if not documents:
67
+ return []
68
+
69
+ vectorizer = TfidfVectorizer(
70
+ max_features=2000,
71
+ stop_words="english",
72
+ ngram_range=(1, 2), # unigrams + bigrams for better domain specificity
73
+ min_df=1,
74
+ )
75
+ try:
76
+ matrix = vectorizer.fit_transform(documents)
77
+ except ValueError as e:
78
+ logger.warning(f"TF-IDF vectorizer failed: {e}")
79
+ return []
80
+
81
+ # Sum TF-IDF weights across all documents for each term
82
+ scores = matrix.sum(axis=0).A1
83
+ terms = vectorizer.get_feature_names_out()
84
+
85
+ pairs = [(t, float(s)) for t, s in zip(terms, scores) if s > tau_tf]
86
+ pairs.sort(key=lambda x: x[1], reverse=True)
87
+ return pairs[:top_n]
88
+
89
+
90
+ def _split_into_pseudo_documents(text: str, chunk_size: int = 500) -> List[str]:
91
+ """Split a single long text into word-chunk pseudo-documents for TF-IDF."""
92
+ words = text.split()
93
+ chunks = [" ".join(words[i:i + chunk_size]) for i in range(0, len(words), chunk_size)]
94
+ return chunks or [text]
95
+
96
+
97
+ def extract_ner_entities(
98
+ documents: List[str],
99
+ tau_ent: int = 2,
100
+ max_docs_to_scan: int = 200,
101
+ ) -> List[str]:
102
+ """
103
+ Extracts named entities (Sigma_ent) appearing in at least tau_ent documents.
104
+
105
+ Capped at max_docs_to_scan for latency (spaCy NER is not free at compile time).
106
+ Per-document de-duplication avoids inflating doc-frequency for repeated mentions.
107
+
108
+ Args:
109
+ documents: List of per-source document texts.
110
+ tau_ent: Minimum document frequency for an entity to be included.
111
+ max_docs_to_scan: Cap on number of documents processed (speed/latency trade-off).
112
+
113
+ Returns:
114
+ List of entity strings appearing in >= tau_ent documents.
115
+ """
116
+ if not documents:
117
+ return []
118
+
119
+ nlp = get_nlp()
120
+ doc_freq: Dict[str, int] = {}
121
+
122
+ for doc_text in documents[:max_docs_to_scan]:
123
+ seen_in_this_doc: Set[str] = set()
124
+ try:
125
+ for ent in nlp(doc_text[:5000]).ents: # cap per-doc chars for speed
126
+ if ent.label_ in RELEVANT_ENTITY_LABELS:
127
+ key = ent.text.lower().strip()
128
+ if key and len(key) > 2 and key not in seen_in_this_doc:
129
+ seen_in_this_doc.add(key)
130
+ except Exception as e:
131
+ logger.warning(f"NER pass failed for a document: {e}")
132
+ continue
133
+
134
+ for key in seen_in_this_doc:
135
+ doc_freq[key] = doc_freq.get(key, 0) + 1
136
+
137
+ return [k for k, v in doc_freq.items() if v >= tau_ent]
138
+
139
+
140
+ def build_domain_signature(
141
+ documents: List[str],
142
+ tau_tf: float = 0.0,
143
+ tau_ent: int = 2,
144
+ top_n_lexical: int = 100,
145
+ ) -> Dict[str, object]:
146
+ """
147
+ Returns Sigma = Sigma_lex ∪ Sigma_ent (Section III-A).
148
+
149
+ The two components are kept separate for inspection and future weighted Jaccard use.
150
+
151
+ Args:
152
+ documents: List of per-source document texts (NOT a single concatenated string).
153
+ tau_tf: TF-IDF weight threshold for lexical terms.
154
+ tau_ent: Minimum doc-frequency for NER entities.
155
+ top_n_lexical: Number of top TF-IDF terms to retain.
156
+
157
+ Returns:
158
+ Dict with keys:
159
+ - 'lexical': list of top TF-IDF terms
160
+ - 'lexical_weights': dict of {term: weight}
161
+ - 'entities': list of NER entities meeting tau_ent threshold
162
+ - 'combined': sorted list of all unique terms (lexical ∪ entities)
163
+ """
164
+ logger.info(
165
+ f"Building domain signature from {len(documents)} documents "
166
+ f"(tau_tf={tau_tf}, tau_ent={tau_ent}, top_n={top_n_lexical})"
167
+ )
168
+
169
+ lexical_pairs = build_tfidf_signature(documents, top_n=top_n_lexical, tau_tf=tau_tf)
170
+ lexical_terms = [t for t, _ in lexical_pairs]
171
+ lexical_weights = {t: w for t, w in lexical_pairs}
172
+
173
+ entities = extract_ner_entities(documents, tau_ent=tau_ent)
174
+
175
+ combined = sorted(set(lexical_terms) | set(entities))
176
+
177
+ logger.info(
178
+ f"Domain signature built: {len(lexical_terms)} lexical terms, "
179
+ f"{len(entities)} NER entities, {len(combined)} combined."
180
+ )
181
+
182
+ return {
183
+ "lexical": lexical_terms,
184
+ "lexical_weights": lexical_weights,
185
+ "entities": entities,
186
+ "combined": combined,
187
+ }
backend/utils/faithfulness.py CHANGED
@@ -213,66 +213,142 @@ def create_faithfulness_scorer() -> FaithfulnessScorer:
213
  return FaithfulnessScorer()
214
 
215
 
216
- class BartNLIScorer:
217
  """
218
- Evaluates faithfulness using a local NLI model (BART-Large-MNLI)
219
- to break the circular evaluation where the generator evaluates itself.
 
 
 
 
 
 
 
 
 
220
  """
 
 
 
221
  def __init__(self):
222
- self._pipe = None
223
-
 
224
  @property
225
- def pipe(self):
226
- if self._pipe is None:
227
- import logging
228
- logger = logging.getLogger(__name__)
 
 
 
 
 
 
229
  try:
230
- from transformers import pipeline
231
- logger.info("Loading BART NLI model...")
232
- # 'contradiction' (0), 'neutral' (1), 'entailment' (2)
233
- self._pipe = pipeline("text-classification", model="facebook/bart-large-mnli")
234
- logger.info("BART NLI loaded.")
235
- except ImportError:
236
- logger.error("transformers not installed. Cannot use BartNLIScorer.")
237
- self._pipe = "UNAVAILABLE"
238
- return self._pipe
 
 
 
 
 
 
 
 
 
239
 
240
- def score(self, answer: str, context: str) -> FaithfulnessResult:
241
- if not answer or not context or self.pipe == "UNAVAILABLE":
242
- return FaithfulnessResult(score=1.0, total_claims=0, supported_claims=0, unsupported_claims=[])
243
-
 
 
 
 
 
 
 
 
244
  import re
245
- sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', answer) if len(s.strip()) > 20][:10]
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  if not sentences:
247
- return FaithfulnessResult(score=1.0, total_claims=0, supported_claims=0, unsupported_claims=[])
248
-
 
 
249
  supported = 0
250
- unsupported = []
251
-
252
  try:
253
  for sentence in sentences:
254
- # Format for bart-large-mnli: premise </s></s> hypothesis
255
- input_text = f"{context[:3000]} </s></s> {sentence}"
256
- result = self.pipe(input_text, truncation=True, max_length=1024)[0]
257
- label = result['label'].lower()
258
- # Consider neutral or entailment as supported for broad QA, or strict entailment
259
- if 'entail' in label:
 
 
 
 
 
 
 
260
  supported += 1
261
  else:
262
- unsupported.append(sentence)
 
263
  except Exception as e:
264
- logger.error(f"BART NLI Error: {e}")
265
- return FaithfulnessResult(score=0.5, total_claims=len(sentences), supported_claims=0, unsupported_claims=sentences[:5])
 
 
 
 
 
266
 
 
267
  score = supported / len(sentences)
268
- logger.info(f"BART NLI Faithfulness: {supported}/{len(sentences)} claims supported ({score*100:.0f}%)")
 
 
 
 
269
  return FaithfulnessResult(
270
  score=round(score, 3),
271
  total_claims=len(sentences),
272
  supported_claims=supported,
273
- unsupported_claims=unsupported[:5]
274
  )
275
 
 
 
 
 
 
 
 
276
 
277
  class FActScoreCompat:
278
  """
@@ -287,3 +363,4 @@ class FActScoreCompat:
287
  result = self._scorer.score(answer, context)
288
  logger.info(f"FActScore: {result.score * 100:.1f}% ({result.supported_claims}/{result.total_claims} facts)")
289
  return result
 
 
213
  return FaithfulnessScorer()
214
 
215
 
216
+ class DebertaNLIScorer:
217
  """
218
+ Primary faithfulness verifier per Section III-C.
219
+
220
+ Uses cross-encoder/nli-deberta-v3-large (DeBERTa-v3-large fine-tuned on MNLI)
221
+ to break the circular LLM-judges-LLM evaluation pattern.
222
+
223
+ Implements:
224
+ - Eq. 6: per-claim, per-document max entailment probability
225
+ - Eq. 7: faithfulness score = fraction of claims exceeding TAU_ENT
226
+
227
+ score() receives individual retrieved chunk texts (NOT concatenated), so the
228
+ per-document max in Eq. 6 is meaningful.
229
  """
230
+
231
+ TAU_ENT = 0.7 # Entailment probability threshold (Eq. 7)
232
+
233
  def __init__(self):
234
+ self._model = None
235
+ self._entailment_idx: int = 1 # Verified at load time from id2label
236
+
237
  @property
238
+ def model(self):
239
+ """Lazy-load CrossEncoder to avoid slow startup when not needed."""
240
+ if self._model is None:
241
+ from sentence_transformers import CrossEncoder
242
+ logger.info("Loading cross-encoder/nli-deberta-v3-large for faithfulness scoring...")
243
+ self._model = CrossEncoder("cross-encoder/nli-deberta-v3-large")
244
+
245
+ # CRITICAL: verify the actual label order from the model's config,
246
+ # NLI checkpoints do NOT always use the same label ordering.
247
+ # Do not hardcode entailment_idx=1 without confirming here.
248
  try:
249
+ id2label = self._model.model.config.id2label
250
+ logger.info(f"DeBERTa NLI id2label: {id2label}")
251
+ # Find the index whose label contains 'entail' (case-insensitive)
252
+ for idx, label in id2label.items():
253
+ if "entail" in label.lower():
254
+ self._entailment_idx = int(idx)
255
+ break
256
+ logger.info(
257
+ f"DeBERTa-v3 NLI loaded. "
258
+ f"Confirmed entailment_idx={self._entailment_idx} "
259
+ f"(label='{id2label.get(self._entailment_idx, 'unknown')}')"
260
+ )
261
+ except Exception as e:
262
+ logger.warning(
263
+ f"Could not read id2label from DeBERTa config ({e}). "
264
+ f"Defaulting entailment_idx=1. Verify this is correct for this checkpoint."
265
+ )
266
+ return self._model
267
 
268
+ def score(self, answer: str, context_documents: List[str]) -> FaithfulnessResult:
269
+ """
270
+ Score faithfulness of an answer against a list of individual retrieved chunks.
271
+
272
+ Args:
273
+ answer: LLM-generated answer text.
274
+ context_documents: List of individual chunk texts (NOT concatenated).
275
+ Per-document max in Eq. 6 requires individual documents.
276
+
277
+ Returns:
278
+ FaithfulnessResult with per-sentence entailment-based score.
279
+ """
280
  import re
281
+ import numpy as np
282
+
283
+ if not answer or not context_documents:
284
+ return FaithfulnessResult(
285
+ score=1.0, total_claims=0, supported_claims=0, unsupported_claims=[]
286
+ )
287
+
288
+ # Split answer into sentences (Eq. 6 operates per-claim / per-sentence)
289
+ sentences = [
290
+ s.strip()
291
+ for s in re.split(r'(?<=[.!?])\s+', answer)
292
+ if len(s.strip()) > 15
293
+ ][:10] # cap at 10 sentences for latency
294
+
295
  if not sentences:
296
+ return FaithfulnessResult(
297
+ score=1.0, total_claims=0, supported_claims=0, unsupported_claims=[]
298
+ )
299
+
300
  supported = 0
301
+ unsupported_sentences = []
302
+
303
  try:
304
  for sentence in sentences:
305
+ # Build (premise=doc, hypothesis=sentence) pairs for all docs
306
+ pairs = [(doc[:2000], sentence) for doc in context_documents]
307
+
308
+ # predict() returns raw logits shape: (n_docs, n_labels)
309
+ raw_scores = self.model.predict(pairs)
310
+
311
+ # Apply softmax to get probabilities
312
+ probs = self._softmax(raw_scores)
313
+
314
+ # Eq. 6: max entailment probability across all retrieved documents
315
+ max_entailment = float(np.max(probs[:, self._entailment_idx]))
316
+
317
+ if max_entailment > self.TAU_ENT:
318
  supported += 1
319
  else:
320
+ unsupported_sentences.append(sentence)
321
+
322
  except Exception as e:
323
+ logger.error(f"DebertaNLIScorer inference failed: {e}")
324
+ return FaithfulnessResult(
325
+ score=0.5,
326
+ total_claims=len(sentences),
327
+ supported_claims=0,
328
+ unsupported_claims=sentences[:5],
329
+ )
330
 
331
+ # Eq. 7: faithfulness = fraction of supported claims
332
  score = supported / len(sentences)
333
+ logger.info(
334
+ f"DeBERTa NLI Faithfulness: {supported}/{len(sentences)} sentences "
335
+ f"supported (score={score:.3f}, TAU_ENT={self.TAU_ENT})"
336
+ )
337
+
338
  return FaithfulnessResult(
339
  score=round(score, 3),
340
  total_claims=len(sentences),
341
  supported_claims=supported,
342
+ unsupported_claims=unsupported_sentences[:5],
343
  )
344
 
345
+ @staticmethod
346
+ def _softmax(x):
347
+ """Numerically stable softmax over last axis."""
348
+ import numpy as np
349
+ e_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
350
+ return e_x / e_x.sum(axis=-1, keepdims=True)
351
+
352
 
353
  class FActScoreCompat:
354
  """
 
363
  result = self._scorer.score(answer, context)
364
  logger.info(f"FActScore: {result.score * 100:.1f}% ({result.supported_claims}/{result.total_claims} facts)")
365
  return result
366
+
backend/utils/groq_client.py CHANGED
@@ -9,7 +9,7 @@ from typing import Optional, List, Dict, Any
9
  from groq import Groq
10
  from dotenv import load_dotenv
11
 
12
- # Load environment variables
13
  load_dotenv()
14
 
15
 
@@ -34,8 +34,9 @@ class GroqClient:
34
 
35
  from core.config import settings
36
 
37
- # Model configurations based on LLM_BACKBONE
38
- backbone = getattr(settings, "LLM_BACKBONE", "llama3").lower()
 
39
 
40
  if backbone == "mixtral":
41
  self.models = {
@@ -53,13 +54,21 @@ class GroqClient:
53
  "vision": "meta-llama/llama-4-scout-17b-16e-instruct",
54
  "whisper": "whisper-large-v3"
55
  }
 
 
 
 
 
 
 
 
56
  else:
57
  self.models = {
58
- "chat": "llama-3.1-8b-instant", # Primary LLM (fast & conversational)
59
- "advanced": "llama-3.3-70b-versatile", # Advanced reasoning
60
- "fast": "llama-3.1-8b-instant", # Fast responses
61
- "vision": "meta-llama/llama-4-scout-17b-16e-instruct", # Llama 4 Vision model (Jan 2025)
62
- "whisper": "whisper-large-v3" # Audio transcription
63
  }
64
 
65
  def chat_completion(
 
9
  from groq import Groq
10
  from dotenv import load_dotenv
11
 
12
+ # Load environment variables (.env) from root/backend
13
  load_dotenv()
14
 
15
 
 
34
 
35
  from core.config import settings
36
 
37
+ # Model configurations based on LLM_BACKBONE and environment settings
38
+ backbone = getattr(settings, "LLM_BACKBONE", "openai/gpt-oss-120b").lower()
39
+ active_model = getattr(settings, "GROQ_MODEL", os.getenv("GROQ_MODEL", "openai/gpt-oss-120b"))
40
 
41
  if backbone == "mixtral":
42
  self.models = {
 
54
  "vision": "meta-llama/llama-4-scout-17b-16e-instruct",
55
  "whisper": "whisper-large-v3"
56
  }
57
+ elif backbone == "llama3":
58
+ self.models = {
59
+ "chat": "llama-3.1-8b-instant",
60
+ "advanced": "llama-3.3-70b-versatile",
61
+ "fast": "llama-3.1-8b-instant",
62
+ "vision": "meta-llama/llama-4-scout-17b-16e-instruct",
63
+ "whisper": "whisper-large-v3"
64
+ }
65
  else:
66
  self.models = {
67
+ "chat": active_model, # Primary LLM (openai/gpt-oss-120b)
68
+ "advanced": active_model, # Advanced reasoning
69
+ "fast": active_model, # Fast responses
70
+ "vision": "meta-llama/llama-4-scout-17b-16e-instruct",
71
+ "whisper": "whisper-large-v3"
72
  }
73
 
74
  def chat_completion(
pyrightconfig.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "include": [
3
+ "backend"
4
+ ],
5
+ "extraPaths": [
6
+ "C:/Users/Vasu/AppData/Local/Programs/Python/Python312/Lib/site-packages",
7
+ "d:/DG Workspace/Mexar-main/backend"
8
+ ],
9
+ "pythonVersion": "3.12",
10
+ "pythonPlatform": "Windows"
11
+ }
test_data/financial_real/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MEXAR Financial Corpus — Real Data Directory
2
+
3
+ This directory is populated by running:
4
+
5
+ ```bash
6
+ export SEC_USER_AGENT="YourName your@email.com"
7
+ python backend/scripts/fetch_secedgar.py
8
+ ```
9
+
10
+ **Contents (after fetch):**
11
+ - `<CIK>_<accession>.txt` — one plain-text file per 10-K section (MD&A or Risk Factors)
12
+ - `manifest.json` — CIK → accession → company → term → source URL mapping
13
+
14
+ **Target:** ~130 documents across 3 search terms (risk factors, MD&A, GAAP/IFRS standards)
15
+
16
+ See `MEXAR_Phase2_Dataset_Collection.md` for full instructions.
test_data/legal_real/README.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MEXAR Legal Corpus — Real Data Directory
2
+
3
+ This directory is populated by running:
4
+
5
+ ```bash
6
+ export COURTLISTENER_TOKEN=your_token_here
7
+ python backend/scripts/fetch_courtlistener.py
8
+ ```
9
+
10
+ Get a free CourtListener API token at: https://www.courtlistener.com/sign-in/
11
+
12
+ **Contents (after fetch):**
13
+ - `case_<id>.txt` — one plain-text file per CourtListener opinion
14
+ - `manifest.json` — opinion ID → case name → subdomain → court → source URL mapping
15
+
16
+ **Target:** ~150 documents across 3 subdomains (contract law, intellectual property, corporate governance)
17
+
18
+ See `MEXAR_Phase2_Dataset_Collection.md` for full instructions.
test_data/medical_real/README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MEXAR Medical Corpus — Real Data Directory
2
+
3
+ This directory is populated by running:
4
+
5
+ ```bash
6
+ python backend/scripts/fetch_pubmed.py --email your_email@example.com
7
+ ```
8
+
9
+ **Contents (after fetch):**
10
+ - `PMC<ID>.txt` — one plain-text file per PubMed Central OA article
11
+ - `manifest.json` — PMCID → title → subdomain → source URL mapping
12
+
13
+ **Target:** ~220 documents across 3 subdomains (cardiology, oncology, internal medicine)
14
+
15
+ See `MEXAR_Phase2_Dataset_Collection.md` for full instructions.
test_data/query_sets/financial_queries.json ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "query": "What risk factors are typically disclosed in a technology company's 10-K filing under SEC Regulation S-K Item 1A?",
4
+ "domain": "financial",
5
+ "is_in_domain": true,
6
+ "expected_source_docs": [],
7
+ "annotator": "devrajsinh",
8
+ "notes": "Risk factors subdomain."
9
+ },
10
+ {
11
+ "query": "How do companies disclose cybersecurity risks in their annual SEC filings following the 2023 SEC cybersecurity disclosure rules?",
12
+ "domain": "financial",
13
+ "is_in_domain": true,
14
+ "expected_source_docs": [],
15
+ "annotator": "devrajsinh",
16
+ "notes": "Risk factors subdomain."
17
+ },
18
+ {
19
+ "query": "What types of macroeconomic risks are commonly reported as material risk factors in 10-K filings?",
20
+ "domain": "financial",
21
+ "is_in_domain": true,
22
+ "expected_source_docs": [],
23
+ "annotator": "devrajsinh",
24
+ "notes": "Risk factors subdomain — interest rate, FX, inflation."
25
+ },
26
+ {
27
+ "query": "How do pharmaceutical companies describe clinical trial failure as a risk factor in their annual reports?",
28
+ "domain": "financial",
29
+ "is_in_domain": true,
30
+ "expected_source_docs": [],
31
+ "annotator": "devrajsinh",
32
+ "notes": "Risk factors subdomain."
33
+ },
34
+ {
35
+ "query": "What supply chain risk disclosures are typically included in manufacturing company 10-Ks?",
36
+ "domain": "financial",
37
+ "is_in_domain": true,
38
+ "expected_source_docs": [],
39
+ "annotator": "devrajsinh",
40
+ "notes": "Risk factors subdomain."
41
+ },
42
+ {
43
+ "query": "How do companies disclose geopolitical and sanctions-related risk factors in their SEC filings?",
44
+ "domain": "financial",
45
+ "is_in_domain": true,
46
+ "expected_source_docs": [],
47
+ "annotator": "devrajsinh",
48
+ "notes": "Risk factors subdomain."
49
+ },
50
+ {
51
+ "query": "What liquidity and capital resources risks are companies required to disclose in their 10-K filings?",
52
+ "domain": "financial",
53
+ "is_in_domain": true,
54
+ "expected_source_docs": [],
55
+ "annotator": "devrajsinh",
56
+ "notes": "Risk factors subdomain."
57
+ },
58
+ {
59
+ "query": "How do companies in the energy sector describe commodity price risk in their annual reports?",
60
+ "domain": "financial",
61
+ "is_in_domain": true,
62
+ "expected_source_docs": [],
63
+ "annotator": "devrajsinh",
64
+ "notes": "Risk factors subdomain."
65
+ },
66
+ {
67
+ "query": "What regulatory and compliance risks are disclosed by financial services companies in their 10-K filings?",
68
+ "domain": "financial",
69
+ "is_in_domain": true,
70
+ "expected_source_docs": [],
71
+ "annotator": "devrajsinh",
72
+ "notes": "Risk factors subdomain."
73
+ },
74
+ {
75
+ "query": "How do companies disclose risks related to customer concentration in their annual SEC reports?",
76
+ "domain": "financial",
77
+ "is_in_domain": true,
78
+ "expected_source_docs": [],
79
+ "annotator": "devrajsinh",
80
+ "notes": "Risk factors subdomain."
81
+ },
82
+ {
83
+ "query": "What is included in the Management Discussion and Analysis (MD&A) section of a 10-K, and what SEC rules govern it?",
84
+ "domain": "financial",
85
+ "is_in_domain": true,
86
+ "expected_source_docs": [],
87
+ "annotator": "devrajsinh",
88
+ "notes": "MD&A subdomain."
89
+ },
90
+ {
91
+ "query": "How do companies describe their revenue recognition policies in the MD&A section of annual reports?",
92
+ "domain": "financial",
93
+ "is_in_domain": true,
94
+ "expected_source_docs": [],
95
+ "annotator": "devrajsinh",
96
+ "notes": "MD&A subdomain — ASC 606 disclosures."
97
+ },
98
+ {
99
+ "query": "How is operating cash flow discussed in the MD&A, and what non-GAAP measures are typically explained there?",
100
+ "domain": "financial",
101
+ "is_in_domain": true,
102
+ "expected_source_docs": [],
103
+ "annotator": "devrajsinh",
104
+ "notes": "MD&A subdomain — EBITDA, adjusted earnings."
105
+ },
106
+ {
107
+ "query": "What forward-looking statements are typically made in the MD&A section and what safe harbor protections apply?",
108
+ "domain": "financial",
109
+ "is_in_domain": true,
110
+ "expected_source_docs": [],
111
+ "annotator": "devrajsinh",
112
+ "notes": "MD&A subdomain."
113
+ },
114
+ {
115
+ "query": "How do companies discuss goodwill impairment testing in their MD&A and related disclosures?",
116
+ "domain": "financial",
117
+ "is_in_domain": true,
118
+ "expected_source_docs": [],
119
+ "annotator": "devrajsinh",
120
+ "notes": "MD&A subdomain."
121
+ },
122
+ {
123
+ "query": "What segment reporting disclosures are included in the MD&A of diversified companies?",
124
+ "domain": "financial",
125
+ "is_in_domain": true,
126
+ "expected_source_docs": [],
127
+ "annotator": "devrajsinh",
128
+ "notes": "MD&A subdomain."
129
+ },
130
+ {
131
+ "query": "How does the MD&A section address year-over-year changes in gross margin for product companies?",
132
+ "domain": "financial",
133
+ "is_in_domain": true,
134
+ "expected_source_docs": [],
135
+ "annotator": "devrajsinh",
136
+ "notes": "MD&A subdomain."
137
+ },
138
+ {
139
+ "query": "What disclosure is required in the MD&A regarding off-balance-sheet arrangements and contingent liabilities?",
140
+ "domain": "financial",
141
+ "is_in_domain": true,
142
+ "expected_source_docs": [],
143
+ "annotator": "devrajsinh",
144
+ "notes": "MD&A subdomain."
145
+ },
146
+ {
147
+ "query": "How do companies disclose critical accounting estimates in the MD&A under SEC Staff Accounting Bulletin guidance?",
148
+ "domain": "financial",
149
+ "is_in_domain": true,
150
+ "expected_source_docs": [],
151
+ "annotator": "devrajsinh",
152
+ "notes": "MD&A subdomain."
153
+ },
154
+ {
155
+ "query": "What working capital and liquidity disclosures are expected in the MD&A of a growth-stage company?",
156
+ "domain": "financial",
157
+ "is_in_domain": true,
158
+ "expected_source_docs": [],
159
+ "annotator": "devrajsinh",
160
+ "notes": "MD&A subdomain."
161
+ },
162
+ {
163
+ "query": "What are the key differences between GAAP and IFRS in the treatment of lease accounting under ASC 842 vs IFRS 16?",
164
+ "domain": "financial",
165
+ "is_in_domain": true,
166
+ "expected_source_docs": [],
167
+ "annotator": "devrajsinh",
168
+ "notes": "Accounting standards subdomain."
169
+ },
170
+ {
171
+ "query": "How does IFRS 9 differ from ASC 326 (CECL) in the recognition of expected credit losses?",
172
+ "domain": "financial",
173
+ "is_in_domain": true,
174
+ "expected_source_docs": [],
175
+ "annotator": "devrajsinh",
176
+ "notes": "Accounting standards subdomain."
177
+ },
178
+ {
179
+ "query": "What are the disclosure requirements under GAAP ASC 820 for fair value measurements at Level 3?",
180
+ "domain": "financial",
181
+ "is_in_domain": true,
182
+ "expected_source_docs": [],
183
+ "annotator": "devrajsinh",
184
+ "notes": "Accounting standards subdomain."
185
+ },
186
+ {
187
+ "query": "How does IFRS 15 govern revenue recognition for multi-element software licensing arrangements?",
188
+ "domain": "financial",
189
+ "is_in_domain": true,
190
+ "expected_source_docs": [],
191
+ "annotator": "devrajsinh",
192
+ "notes": "Accounting standards subdomain."
193
+ },
194
+ {
195
+ "query": "What are the required disclosures for pension and post-retirement benefit obligations under ASC 715?",
196
+ "domain": "financial",
197
+ "is_in_domain": true,
198
+ "expected_source_docs": [],
199
+ "annotator": "devrajsinh",
200
+ "notes": "Accounting standards subdomain."
201
+ },
202
+ {
203
+ "query": "How does GAAP treat research and development costs compared to IFRS, and what disclosures are required?",
204
+ "domain": "financial",
205
+ "is_in_domain": true,
206
+ "expected_source_docs": [],
207
+ "annotator": "devrajsinh",
208
+ "notes": "Accounting standards subdomain."
209
+ },
210
+ {
211
+ "query": "What are the first-line treatment guidelines for stage 3 non-small cell lung cancer?",
212
+ "domain": "financial",
213
+ "is_in_domain": false,
214
+ "expected_source_docs": [],
215
+ "annotator": "devrajsinh",
216
+ "notes": "Out-of-domain: medical/oncology query posed to financial corpus — should trigger guardrail."
217
+ },
218
+ {
219
+ "query": "How is sepsis diagnosed and what is the initial antibiotic management protocol?",
220
+ "domain": "financial",
221
+ "is_in_domain": false,
222
+ "expected_source_docs": [],
223
+ "annotator": "devrajsinh",
224
+ "notes": "Out-of-domain: medical query posed to financial corpus."
225
+ },
226
+ {
227
+ "query": "What are the elements of a valid breach of contract claim under common law?",
228
+ "domain": "financial",
229
+ "is_in_domain": false,
230
+ "expected_source_docs": [],
231
+ "annotator": "devrajsinh",
232
+ "notes": "Out-of-domain: legal query posed to financial corpus."
233
+ },
234
+ {
235
+ "query": "How does the doctrine of fair use apply in copyright infringement cases involving parody works?",
236
+ "domain": "financial",
237
+ "is_in_domain": false,
238
+ "expected_source_docs": [],
239
+ "annotator": "devrajsinh",
240
+ "notes": "Out-of-domain: intellectual property/legal query posed to financial corpus."
241
+ },
242
+ {
243
+ "query": "What are the ADA diagnostic thresholds for prediabetes and type 2 diabetes using fasting glucose and HbA1c?",
244
+ "domain": "financial",
245
+ "is_in_domain": false,
246
+ "expected_source_docs": [],
247
+ "annotator": "devrajsinh",
248
+ "notes": "Out-of-domain: medical query posed to financial corpus."
249
+ }
250
+ ]
test_data/query_sets/legal_queries.json ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "query": "What remedies are available to a non-breaching party when the other party materially breaches a contract?",
4
+ "domain": "legal",
5
+ "is_in_domain": true,
6
+ "expected_source_docs": [],
7
+ "annotator": "devrajsinh",
8
+ "notes": "Contract law subdomain."
9
+ },
10
+ {
11
+ "query": "How do courts determine whether consequential damages are recoverable for breach of contract under the Hadley v Baxendale rule?",
12
+ "domain": "legal",
13
+ "is_in_domain": true,
14
+ "expected_source_docs": [],
15
+ "annotator": "devrajsinh",
16
+ "notes": "Contract law subdomain."
17
+ },
18
+ {
19
+ "query": "What is the legal standard for determining whether a contract has been formed by conduct rather than by written agreement?",
20
+ "domain": "legal",
21
+ "is_in_domain": true,
22
+ "expected_source_docs": [],
23
+ "annotator": "devrajsinh",
24
+ "notes": "Contract law subdomain — implied-in-fact contracts."
25
+ },
26
+ {
27
+ "query": "Under the UCC Article 2, what are the implied warranties that attach to the sale of goods?",
28
+ "domain": "legal",
29
+ "is_in_domain": true,
30
+ "expected_source_docs": [],
31
+ "annotator": "devrajsinh",
32
+ "notes": "Contract law subdomain — implied warranty of merchantability and fitness."
33
+ },
34
+ {
35
+ "query": "How does the doctrine of promissory estoppel differ from consideration as a basis for enforcing a promise?",
36
+ "domain": "legal",
37
+ "is_in_domain": true,
38
+ "expected_source_docs": [],
39
+ "annotator": "devrajsinh",
40
+ "notes": "Contract law subdomain."
41
+ },
42
+ {
43
+ "query": "What factors do courts weigh when deciding whether to grant specific performance as a remedy for breach of contract?",
44
+ "domain": "legal",
45
+ "is_in_domain": true,
46
+ "expected_source_docs": [],
47
+ "annotator": "devrajsinh",
48
+ "notes": "Contract law subdomain."
49
+ },
50
+ {
51
+ "query": "How does the parol evidence rule limit the use of extrinsic evidence to interpret a written contract?",
52
+ "domain": "legal",
53
+ "is_in_domain": true,
54
+ "expected_source_docs": [],
55
+ "annotator": "devrajsinh",
56
+ "notes": "Contract law subdomain."
57
+ },
58
+ {
59
+ "query": "What constitutes anticipatory repudiation of a contract and what rights does it give to the aggrieved party?",
60
+ "domain": "legal",
61
+ "is_in_domain": true,
62
+ "expected_source_docs": [],
63
+ "annotator": "devrajsinh",
64
+ "notes": "Contract law subdomain."
65
+ },
66
+ {
67
+ "query": "How is the enforceability of a non-compete clause assessed by courts in employment contracts?",
68
+ "domain": "legal",
69
+ "is_in_domain": true,
70
+ "expected_source_docs": [],
71
+ "annotator": "devrajsinh",
72
+ "notes": "Contract law subdomain."
73
+ },
74
+ {
75
+ "query": "Under force majeure clauses, what events typically excuse performance of a contract obligation?",
76
+ "domain": "legal",
77
+ "is_in_domain": true,
78
+ "expected_source_docs": [],
79
+ "annotator": "devrajsinh",
80
+ "notes": "Contract law subdomain."
81
+ },
82
+ {
83
+ "query": "What is the standard for proving patent infringement under the doctrine of equivalents?",
84
+ "domain": "legal",
85
+ "is_in_domain": true,
86
+ "expected_source_docs": [],
87
+ "annotator": "devrajsinh",
88
+ "notes": "Intellectual property subdomain."
89
+ },
90
+ {
91
+ "query": "How does the likelihood of confusion test apply in trademark infringement cases?",
92
+ "domain": "legal",
93
+ "is_in_domain": true,
94
+ "expected_source_docs": [],
95
+ "annotator": "devrajsinh",
96
+ "notes": "Intellectual property subdomain — Polaroid factors."
97
+ },
98
+ {
99
+ "query": "What is the fair use doctrine in copyright law and what are the four statutory factors courts apply?",
100
+ "domain": "legal",
101
+ "is_in_domain": true,
102
+ "expected_source_docs": [],
103
+ "annotator": "devrajsinh",
104
+ "notes": "Intellectual property subdomain."
105
+ },
106
+ {
107
+ "query": "How do courts determine whether trade secrets qualify for protection under the Defend Trade Secrets Act?",
108
+ "domain": "legal",
109
+ "is_in_domain": true,
110
+ "expected_source_docs": [],
111
+ "annotator": "devrajsinh",
112
+ "notes": "Intellectual property subdomain."
113
+ },
114
+ {
115
+ "query": "What is required to establish willful patent infringement and what enhanced damages can result?",
116
+ "domain": "legal",
117
+ "is_in_domain": true,
118
+ "expected_source_docs": [],
119
+ "annotator": "devrajsinh",
120
+ "notes": "Intellectual property subdomain."
121
+ },
122
+ {
123
+ "query": "How does inter partes review (IPR) at the USPTO differ from district court patent litigation?",
124
+ "domain": "legal",
125
+ "is_in_domain": true,
126
+ "expected_source_docs": [],
127
+ "annotator": "devrajsinh",
128
+ "notes": "Intellectual property subdomain."
129
+ },
130
+ {
131
+ "query": "What is the legal standard for trademark dilution claims under the Lanham Act?",
132
+ "domain": "legal",
133
+ "is_in_domain": true,
134
+ "expected_source_docs": [],
135
+ "annotator": "devrajsinh",
136
+ "notes": "Intellectual property subdomain — dilution by blurring vs tarnishment."
137
+ },
138
+ {
139
+ "query": "How do courts evaluate obviousness in patent validity challenges under 35 U.S.C. § 103?",
140
+ "domain": "legal",
141
+ "is_in_domain": true,
142
+ "expected_source_docs": [],
143
+ "annotator": "devrajsinh",
144
+ "notes": "Intellectual property subdomain — Graham v. John Deere factors."
145
+ },
146
+ {
147
+ "query": "What are the elements of a successful copyright infringement claim?",
148
+ "domain": "legal",
149
+ "is_in_domain": true,
150
+ "expected_source_docs": [],
151
+ "annotator": "devrajsinh",
152
+ "notes": "Intellectual property subdomain."
153
+ },
154
+ {
155
+ "query": "How is secondary liability (contributory and vicarious infringement) established in patent cases?",
156
+ "domain": "legal",
157
+ "is_in_domain": true,
158
+ "expected_source_docs": [],
159
+ "annotator": "devrajsinh",
160
+ "notes": "Intellectual property subdomain."
161
+ },
162
+ {
163
+ "query": "What is the business judgment rule and when does it protect corporate directors from liability?",
164
+ "domain": "legal",
165
+ "is_in_domain": true,
166
+ "expected_source_docs": [],
167
+ "annotator": "devrajsinh",
168
+ "notes": "Corporate governance subdomain."
169
+ },
170
+ {
171
+ "query": "What are the elements of a shareholder derivative suit and what is the demand requirement?",
172
+ "domain": "legal",
173
+ "is_in_domain": true,
174
+ "expected_source_docs": [],
175
+ "annotator": "devrajsinh",
176
+ "notes": "Corporate governance subdomain."
177
+ },
178
+ {
179
+ "query": "How does the duty of loyalty differ from the duty of care for corporate officers and directors?",
180
+ "domain": "legal",
181
+ "is_in_domain": true,
182
+ "expected_source_docs": [],
183
+ "annotator": "devrajsinh",
184
+ "notes": "Corporate governance subdomain."
185
+ },
186
+ {
187
+ "query": "What legal standard applies to board approval of a merger under Delaware's entire fairness review?",
188
+ "domain": "legal",
189
+ "is_in_domain": true,
190
+ "expected_source_docs": [],
191
+ "annotator": "devrajsinh",
192
+ "notes": "Corporate governance subdomain."
193
+ },
194
+ {
195
+ "query": "What is the duty of disclosure (candor) owed by directors to shareholders in a merger transaction?",
196
+ "domain": "legal",
197
+ "is_in_domain": true,
198
+ "expected_source_docs": [],
199
+ "annotator": "devrajsinh",
200
+ "notes": "Corporate governance subdomain."
201
+ },
202
+ {
203
+ "query": "How do courts assess whether executive compensation decisions are protected by the business judgment rule?",
204
+ "domain": "legal",
205
+ "is_in_domain": true,
206
+ "expected_source_docs": [],
207
+ "annotator": "devrajsinh",
208
+ "notes": "Corporate governance subdomain."
209
+ },
210
+ {
211
+ "query": "What is the first-line pharmacotherapy for newly diagnosed type 2 diabetes mellitus?",
212
+ "domain": "legal",
213
+ "is_in_domain": false,
214
+ "expected_source_docs": [],
215
+ "annotator": "devrajsinh",
216
+ "notes": "Out-of-domain: medical query posed to legal corpus — should trigger guardrail."
217
+ },
218
+ {
219
+ "query": "What is the recommended chemotherapy protocol for stage III non-small cell lung cancer?",
220
+ "domain": "legal",
221
+ "is_in_domain": false,
222
+ "expected_source_docs": [],
223
+ "annotator": "devrajsinh",
224
+ "notes": "Out-of-domain: medical/oncology query posed to legal corpus."
225
+ },
226
+ {
227
+ "query": "How is EBITDA calculated and what does it measure in company financial performance?",
228
+ "domain": "legal",
229
+ "is_in_domain": false,
230
+ "expected_source_docs": [],
231
+ "annotator": "devrajsinh",
232
+ "notes": "Out-of-domain: financial query posed to legal corpus."
233
+ },
234
+ {
235
+ "query": "What are the disclosure requirements under IFRS 9 for financial instruments at fair value through profit or loss?",
236
+ "domain": "legal",
237
+ "is_in_domain": false,
238
+ "expected_source_docs": [],
239
+ "annotator": "devrajsinh",
240
+ "notes": "Out-of-domain: accounting/financial standards query posed to legal corpus."
241
+ },
242
+ {
243
+ "query": "What is the pathophysiology of heart failure with reduced ejection fraction?",
244
+ "domain": "legal",
245
+ "is_in_domain": false,
246
+ "expected_source_docs": [],
247
+ "annotator": "devrajsinh",
248
+ "notes": "Out-of-domain: medical/cardiology query posed to legal corpus."
249
+ }
250
+ ]
test_data/query_sets/medical_queries.json ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "query": "What is the first-line treatment for stage 2 hypertension according to current guidelines?",
4
+ "domain": "medical",
5
+ "is_in_domain": true,
6
+ "expected_source_docs": [],
7
+ "annotator": "devrajsinh",
8
+ "notes": "Cardiology subdomain. Expected answer: combination antihypertensive therapy (ACE inhibitor/ARB + CCB or thiazide)."
9
+ },
10
+ {
11
+ "query": "What are the recommended beta-blocker choices for patients with heart failure with reduced ejection fraction (HFrEF)?",
12
+ "domain": "medical",
13
+ "is_in_domain": true,
14
+ "expected_source_docs": [],
15
+ "annotator": "devrajsinh",
16
+ "notes": "Cardiology subdomain."
17
+ },
18
+ {
19
+ "query": "What diagnostic criteria are used to classify atrial fibrillation subtypes?",
20
+ "domain": "medical",
21
+ "is_in_domain": true,
22
+ "expected_source_docs": [],
23
+ "annotator": "devrajsinh",
24
+ "notes": "Cardiology subdomain — paroxysmal, persistent, long-standing persistent, permanent."
25
+ },
26
+ {
27
+ "query": "What are the ESC or ACC/AHA guideline recommendations for statin therapy in secondary prevention after myocardial infarction?",
28
+ "domain": "medical",
29
+ "is_in_domain": true,
30
+ "expected_source_docs": [],
31
+ "annotator": "devrajsinh",
32
+ "notes": "Cardiology subdomain."
33
+ },
34
+ {
35
+ "query": "Which anticoagulants are recommended for stroke prevention in non-valvular atrial fibrillation?",
36
+ "domain": "medical",
37
+ "is_in_domain": true,
38
+ "expected_source_docs": [],
39
+ "annotator": "devrajsinh",
40
+ "notes": "Cardiology subdomain — DOACs vs warfarin."
41
+ },
42
+ {
43
+ "query": "What is the CHADS2-VASc score and how is it used to guide anticoagulation decisions?",
44
+ "domain": "medical",
45
+ "is_in_domain": true,
46
+ "expected_source_docs": [],
47
+ "annotator": "devrajsinh",
48
+ "notes": "Cardiology subdomain."
49
+ },
50
+ {
51
+ "query": "What is the current guideline-directed medical therapy for stable ischemic heart disease?",
52
+ "domain": "medical",
53
+ "is_in_domain": true,
54
+ "expected_source_docs": [],
55
+ "annotator": "devrajsinh",
56
+ "notes": "Cardiology subdomain."
57
+ },
58
+ {
59
+ "query": "How is cardiac resynchronization therapy indicated in heart failure patients?",
60
+ "domain": "medical",
61
+ "is_in_domain": true,
62
+ "expected_source_docs": [],
63
+ "annotator": "devrajsinh",
64
+ "notes": "Cardiology subdomain."
65
+ },
66
+ {
67
+ "query": "What are the diagnostic criteria for heart failure with preserved ejection fraction (HFpEF)?",
68
+ "domain": "medical",
69
+ "is_in_domain": true,
70
+ "expected_source_docs": [],
71
+ "annotator": "devrajsinh",
72
+ "notes": "Cardiology subdomain."
73
+ },
74
+ {
75
+ "query": "What is the recommended approach to treating ST-elevation myocardial infarction (STEMI) within the first 12 hours?",
76
+ "domain": "medical",
77
+ "is_in_domain": true,
78
+ "expected_source_docs": [],
79
+ "annotator": "devrajsinh",
80
+ "notes": "Cardiology subdomain — primary PCI vs thrombolysis."
81
+ },
82
+ {
83
+ "query": "What is the standard chemotherapy regimen for first-line treatment of metastatic non-small cell lung cancer?",
84
+ "domain": "medical",
85
+ "is_in_domain": true,
86
+ "expected_source_docs": [],
87
+ "annotator": "devrajsinh",
88
+ "notes": "Oncology subdomain."
89
+ },
90
+ {
91
+ "query": "What are the NCCN guidelines for adjuvant chemotherapy in stage III colon cancer?",
92
+ "domain": "medical",
93
+ "is_in_domain": true,
94
+ "expected_source_docs": [],
95
+ "annotator": "devrajsinh",
96
+ "notes": "Oncology subdomain."
97
+ },
98
+ {
99
+ "query": "How are PD-L1 expression levels used to select patients for pembrolizumab therapy in lung cancer?",
100
+ "domain": "medical",
101
+ "is_in_domain": true,
102
+ "expected_source_docs": [],
103
+ "annotator": "devrajsinh",
104
+ "notes": "Oncology subdomain."
105
+ },
106
+ {
107
+ "query": "What treatment protocol is used for BRCA-mutated ovarian cancer in first-line maintenance?",
108
+ "domain": "medical",
109
+ "is_in_domain": true,
110
+ "expected_source_docs": [],
111
+ "annotator": "devrajsinh",
112
+ "notes": "Oncology subdomain — PARP inhibitors."
113
+ },
114
+ {
115
+ "query": "What are the standard treatment options for relapsed/refractory diffuse large B-cell lymphoma?",
116
+ "domain": "medical",
117
+ "is_in_domain": true,
118
+ "expected_source_docs": [],
119
+ "annotator": "devrajsinh",
120
+ "notes": "Oncology subdomain."
121
+ },
122
+ {
123
+ "query": "What are the first-line treatment options for hormone receptor-positive HER2-negative metastatic breast cancer?",
124
+ "domain": "medical",
125
+ "is_in_domain": true,
126
+ "expected_source_docs": [],
127
+ "annotator": "devrajsinh",
128
+ "notes": "Oncology subdomain."
129
+ },
130
+ {
131
+ "query": "How does tumor mutational burden (TMB) influence immunotherapy selection in solid tumors?",
132
+ "domain": "medical",
133
+ "is_in_domain": true,
134
+ "expected_source_docs": [],
135
+ "annotator": "devrajsinh",
136
+ "notes": "Oncology subdomain."
137
+ },
138
+ {
139
+ "query": "What is the role of neoadjuvant chemotherapy in locally advanced rectal cancer?",
140
+ "domain": "medical",
141
+ "is_in_domain": true,
142
+ "expected_source_docs": [],
143
+ "annotator": "devrajsinh",
144
+ "notes": "Oncology subdomain."
145
+ },
146
+ {
147
+ "query": "What are current guidelines for surveillance imaging after complete remission in Hodgkin lymphoma?",
148
+ "domain": "medical",
149
+ "is_in_domain": true,
150
+ "expected_source_docs": [],
151
+ "annotator": "devrajsinh",
152
+ "notes": "Oncology subdomain."
153
+ },
154
+ {
155
+ "query": "How are CDK4/6 inhibitors used in the treatment of advanced breast cancer?",
156
+ "domain": "medical",
157
+ "is_in_domain": true,
158
+ "expected_source_docs": [],
159
+ "annotator": "devrajsinh",
160
+ "notes": "Oncology subdomain."
161
+ },
162
+ {
163
+ "query": "What are the diagnostic criteria for type 2 diabetes mellitus according to ADA guidelines?",
164
+ "domain": "medical",
165
+ "is_in_domain": true,
166
+ "expected_source_docs": [],
167
+ "annotator": "devrajsinh",
168
+ "notes": "Internal medicine subdomain — fasting glucose, HbA1c, OGTT thresholds."
169
+ },
170
+ {
171
+ "query": "What are the Rome IV diagnostic criteria for irritable bowel syndrome?",
172
+ "domain": "medical",
173
+ "is_in_domain": true,
174
+ "expected_source_docs": [],
175
+ "annotator": "devrajsinh",
176
+ "notes": "Internal medicine subdomain."
177
+ },
178
+ {
179
+ "query": "How is community-acquired pneumonia severity classified using the CURB-65 or PSI scoring systems?",
180
+ "domain": "medical",
181
+ "is_in_domain": true,
182
+ "expected_source_docs": [],
183
+ "annotator": "devrajsinh",
184
+ "notes": "Internal medicine subdomain."
185
+ },
186
+ {
187
+ "query": "What are the diagnostic criteria for systemic lupus erythematosus (SLE) per the 2019 EULAR/ACR classification?",
188
+ "domain": "medical",
189
+ "is_in_domain": true,
190
+ "expected_source_docs": [],
191
+ "annotator": "devrajsinh",
192
+ "notes": "Internal medicine subdomain."
193
+ },
194
+ {
195
+ "query": "What is the initial workup for a patient presenting with unexplained iron deficiency anemia?",
196
+ "domain": "medical",
197
+ "is_in_domain": true,
198
+ "expected_source_docs": [],
199
+ "annotator": "devrajsinh",
200
+ "notes": "Internal medicine subdomain."
201
+ },
202
+ {
203
+ "query": "What are the diagnostic criteria for chronic kidney disease (CKD) and how are GFR stages defined?",
204
+ "domain": "medical",
205
+ "is_in_domain": true,
206
+ "expected_source_docs": [],
207
+ "annotator": "devrajsinh",
208
+ "notes": "Internal medicine subdomain."
209
+ },
210
+ {
211
+ "query": "How is the diagnosis of pulmonary embolism confirmed, and what anticoagulation is recommended?",
212
+ "domain": "medical",
213
+ "is_in_domain": true,
214
+ "expected_source_docs": [],
215
+ "annotator": "devrajsinh",
216
+ "notes": "Internal medicine subdomain."
217
+ },
218
+ {
219
+ "query": "What are the WHO diagnostic criteria for metabolic syndrome?",
220
+ "domain": "medical",
221
+ "is_in_domain": true,
222
+ "expected_source_docs": [],
223
+ "annotator": "devrajsinh",
224
+ "notes": "Internal medicine subdomain."
225
+ },
226
+ {
227
+ "query": "How is sepsis defined and what is the SOFA score used for?",
228
+ "domain": "medical",
229
+ "is_in_domain": true,
230
+ "expected_source_docs": [],
231
+ "annotator": "devrajsinh",
232
+ "notes": "Internal medicine subdomain — Sepsis-3 definition."
233
+ },
234
+ {
235
+ "query": "What are the diagnostic criteria for rheumatoid arthritis according to the 2010 ACR/EULAR classification?",
236
+ "domain": "medical",
237
+ "is_in_domain": true,
238
+ "expected_source_docs": [],
239
+ "annotator": "devrajsinh",
240
+ "notes": "Internal medicine subdomain."
241
+ },
242
+ {
243
+ "query": "What is the best therapeutic approach for a breach of contract involving consequential damages under common law?",
244
+ "domain": "medical",
245
+ "is_in_domain": false,
246
+ "expected_source_docs": [],
247
+ "annotator": "devrajsinh",
248
+ "notes": "Out-of-domain: legal query posed to medical corpus — should trigger domain guardrail."
249
+ },
250
+ {
251
+ "query": "How does SEC Regulation S-K require companies to disclose material risk factors in annual reports?",
252
+ "domain": "medical",
253
+ "is_in_domain": false,
254
+ "expected_source_docs": [],
255
+ "annotator": "devrajsinh",
256
+ "notes": "Out-of-domain: financial/regulatory query posed to medical corpus."
257
+ },
258
+ {
259
+ "query": "What is the amortization schedule for a 30-year fixed-rate mortgage at 6.5% interest?",
260
+ "domain": "medical",
261
+ "is_in_domain": false,
262
+ "expected_source_docs": [],
263
+ "annotator": "devrajsinh",
264
+ "notes": "Out-of-domain: financial query posed to medical corpus."
265
+ },
266
+ {
267
+ "query": "Under the UCC, when does title to goods transfer from seller to buyer in a sale of goods contract?",
268
+ "domain": "medical",
269
+ "is_in_domain": false,
270
+ "expected_source_docs": [],
271
+ "annotator": "devrajsinh",
272
+ "notes": "Out-of-domain: legal/UCC query posed to medical corpus."
273
+ },
274
+ {
275
+ "query": "What are the fiduciary duties of a corporate board of directors to shareholders under Delaware law?",
276
+ "domain": "medical",
277
+ "is_in_domain": false,
278
+ "expected_source_docs": [],
279
+ "annotator": "devrajsinh",
280
+ "notes": "Out-of-domain: corporate law query posed to medical corpus."
281
+ }
282
+ ]