Spaces:
Sleeping
Sleeping
feat: deploy Phase 1 architecture fixes and Phase 2 dataset collection & evaluation infrastructure
Browse files- MEXAR_Phase1_Architecture_Fixes.md +309 -0
- MEXAR_Phase2_Dataset_Collection.md +291 -0
- README.md +418 -1
- backend/.env.example +19 -0
- backend/Dockerfile +3 -0
- backend/core/config.py +2 -1
- backend/evaluation/metrics.py +21 -8
- backend/migrations/README.md +6 -3
- backend/migrations/add_domain_signature_columns.sql +17 -0
- backend/migrations/switch_to_hnsw_index.sql +27 -0
- backend/models/agent.py +3 -0
- backend/modules/knowledge_compiler.py +64 -27
- backend/modules/reasoning_engine.py +56 -66
- backend/requirements.txt +4 -0
- backend/scripts/__init__.py +5 -0
- backend/scripts/fetch_courtlistener.py +294 -0
- backend/scripts/fetch_pubmed.py +333 -0
- backend/scripts/fetch_secedgar.py +467 -0
- backend/scripts/run_migrations.py +92 -0
- backend/utils/domain_signature.py +187 -0
- backend/utils/faithfulness.py +115 -38
- backend/utils/groq_client.py +17 -8
- pyrightconfig.json +11 -0
- test_data/financial_real/README.md +16 -0
- test_data/legal_real/README.md +18 -0
- test_data/medical_real/README.md +15 -0
- test_data/query_sets/financial_queries.json +250 -0
- test_data/query_sets/legal_queries.json +250 -0
- test_data/query_sets/medical_queries.json +282 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
[](https://www.python.org/downloads/)
|
| 15 |
+
[](https://reactjs.org/)
|
| 16 |
+
[](https://fastapi.tiangolo.com/)
|
| 17 |
+
[](LICENSE)
|
| 18 |
+
[](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", "
|
|
|
|
| 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,
|
| 12 |
|
| 13 |
|
| 14 |
class MetricsRunner:
|
| 15 |
def __init__(self):
|
| 16 |
self.faith_scorer = FaithfulnessScorer()
|
| 17 |
-
|
|
|
|
| 18 |
self.factscore = FActScoreCompat()
|
| 19 |
|
| 20 |
-
def evaluate_all(self, answer: str,
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
return {
|
| 25 |
-
"
|
| 26 |
-
"
|
| 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 |
-
##
|
| 38 |
|
| 39 |
-
|
| 40 |
|
| 41 |
```bash
|
| 42 |
-
|
| 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 |
-
) ->
|
| 204 |
"""
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
"""
|
| 207 |
-
#
|
| 208 |
-
|
| 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 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
#
|
| 160 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
"""
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
if
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 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 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 217 |
"""
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
"""
|
|
|
|
|
|
|
|
|
|
| 221 |
def __init__(self):
|
| 222 |
-
self.
|
| 223 |
-
|
|
|
|
| 224 |
@property
|
| 225 |
-
def
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
try:
|
| 230 |
-
|
| 231 |
-
logger.info("
|
| 232 |
-
#
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
-
def score(self, answer: str,
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
import re
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
if not sentences:
|
| 247 |
-
return FaithfulnessResult(
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
supported = 0
|
| 250 |
-
|
| 251 |
-
|
| 252 |
try:
|
| 253 |
for sentence in sentences:
|
| 254 |
-
#
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
supported += 1
|
| 261 |
else:
|
| 262 |
-
|
|
|
|
| 263 |
except Exception as e:
|
| 264 |
-
logger.error(f"
|
| 265 |
-
return FaithfulnessResult(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
|
|
|
| 267 |
score = supported / len(sentences)
|
| 268 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
return FaithfulnessResult(
|
| 270 |
score=round(score, 3),
|
| 271 |
total_claims=len(sentences),
|
| 272 |
supported_claims=supported,
|
| 273 |
-
unsupported_claims=
|
| 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", "
|
|
|
|
| 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":
|
| 59 |
-
"advanced":
|
| 60 |
-
"fast":
|
| 61 |
-
"vision": "meta-llama/llama-4-scout-17b-16e-instruct",
|
| 62 |
-
"whisper": "whisper-large-v3"
|
| 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 |
+
]
|