Spaces:
Sleeping
feat: retrieval grounding, trial quality, and drug-name typo suggestions
Browse filesSession of improvements to retrieval accuracy, clinical-trial matching, and
query robustness for the physician-facing tool.
Retrieval grounding & citation integrity:
- Grounding gate: suppress spurious semantic matches when a query's focus
entity is absent from the corpus (papers=0 → honest "no evidence"),
preventing citation-grafting hallucinations.
- Landscape-mention labeling: flag papers that name a compound only in
full-text pipeline tables (not the abstract) so the model labels such
citations instead of presenting them as primary studies.
- Keyword search + abstract-scoped grounding helpers in rag/retriever.py.
- Stricter synthesis prompt: inline citations, grounding rule, no training-
knowledge gap-filling.
Clinical trials:
- Include Expanded Access Programs (studyType:int exp); tag study_type /
is_expanded_access and surface "Expanded Access" as the phase label.
- Compound-specific trial matching: match only the queried compound/target
(never the KG-expanded galaxy), normalized so CNM-Au8/CNMAu8/cnm_au8 unify;
drop short-fragment false positives; rank available/recruiting first.
Drug-name typo tolerance (normalization/drug_vocab.py):
- Tiered canonicalization: variance (hyphen/space/case) and aliases resolve
automatically; true typos yield a transparent "did you mean?" suggestion
only — never a silent substitution (biomedical names are 1 edit apart, so
auto-correct could redirect onto a different drug).
Ingestion & cost:
- Prompt caching + parallel extraction batches (config/extractor).
- PMC idconv batching, PubMed retstart pagination, citation+recency reranking.
- BioLORD MPS device + capped section chunking in the indexer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- README.md +4 -1
- agents/research_agent.py +256 -39
- config.py +4 -7
- extraction/extractor.py +58 -35
- ingestion/clinicaltrials.py +16 -7
- ingestion/pmc.py +51 -33
- ingestion/pubmed.py +31 -6
- normalization/__init__.py +0 -0
- normalization/drug_vocab.py +114 -0
- prompts.py +8 -2
- pyproject.toml +1 -0
- rag/indexer.py +31 -9
- rag/retriever.py +169 -4
- scripts/ingest_trials.py +5 -18
- uv.lock +81 -0
|
@@ -66,7 +66,10 @@ uv run python scripts/build_index.py
|
|
| 66 |
### Launch
|
| 67 |
|
| 68 |
```bash
|
| 69 |
-
# Web UI
|
|
|
|
|
|
|
|
|
|
| 70 |
uv run python app.py
|
| 71 |
|
| 72 |
# CLI
|
|
|
|
| 66 |
### Launch
|
| 67 |
|
| 68 |
```bash
|
| 69 |
+
# Web UI (auto-reloads on file changes — use during development)
|
| 70 |
+
uv run gradio app.py
|
| 71 |
+
|
| 72 |
+
# Web UI (production / one-shot)
|
| 73 |
uv run python app.py
|
| 74 |
|
| 75 |
# CLI
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import json
|
|
|
|
| 5 |
from collections.abc import Generator
|
| 6 |
|
| 7 |
import anthropic
|
|
@@ -21,6 +22,7 @@ from config import (
|
|
| 21 |
from graph import query as kg_query
|
| 22 |
from llm import cached_system, cached_tools
|
| 23 |
from logging_config import get_logger
|
|
|
|
| 24 |
from prompts import SYNTHESIS_SYSTEM
|
| 25 |
from rag import retriever as rag_retriever
|
| 26 |
from tools import RESEARCH_TOOLS
|
|
@@ -30,6 +32,20 @@ _logger = get_logger("agents.research_agent")
|
|
| 30 |
# Loaded once at startup — ~80MB model, ~80ms/pair on CPU
|
| 31 |
_cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
def stream_research_agent(
|
| 35 |
client: anthropic.Anthropic,
|
|
@@ -51,15 +67,22 @@ def stream_research_agent(
|
|
| 51 |
]
|
| 52 |
# Attach graph reference so _handle_search can use KG expansion
|
| 53 |
_graph = graph
|
|
|
|
| 54 |
|
| 55 |
while True:
|
| 56 |
stream_text = ""
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
with client.messages.stream(
|
| 59 |
model=SYNTHESIS_MODEL,
|
| 60 |
max_tokens=4096,
|
| 61 |
system=cached_system(SYNTHESIS_SYSTEM),
|
| 62 |
tools=cached_tools(RESEARCH_TOOLS),
|
|
|
|
| 63 |
messages=messages,
|
| 64 |
) as stream:
|
| 65 |
# Accumulate tool-use input JSON alongside streaming text
|
|
@@ -99,6 +122,8 @@ def stream_research_agent(
|
|
| 99 |
|
| 100 |
messages.append({"role": "assistant", "content": final_msg.content})
|
| 101 |
|
|
|
|
|
|
|
| 102 |
if final_msg.stop_reason == "end_turn":
|
| 103 |
yield ("done", stream_text)
|
| 104 |
return
|
|
@@ -127,6 +152,53 @@ def stream_research_agent(
|
|
| 127 |
return
|
| 128 |
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def _handle_search(
|
| 131 |
tool_input: dict,
|
| 132 |
collection: chromadb.Collection,
|
|
@@ -152,9 +224,52 @@ def _handle_search(
|
|
| 152 |
collection, expanded_entities, n_results=RETRIEVAL_ENTITY_N
|
| 153 |
)
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
# Step 4: RRF merge → top 20 papers
|
| 156 |
merged = rag_retriever.rrf_merge(
|
| 157 |
-
[semantic_results, entity_results], k=RRF_K, top_n=RRF_TOP_N
|
| 158 |
)
|
| 159 |
|
| 160 |
# Step 5: Cross-encoder rerank → top 15 papers
|
|
@@ -165,6 +280,29 @@ def _handle_search(
|
|
| 165 |
# Step 6: Citation boost — final score = ce_score × log(citation_count + 2)
|
| 166 |
top_papers = rag_retriever.apply_citation_boost(reranked)
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
_logger.info(
|
| 169 |
"KG+RAG+CE search",
|
| 170 |
extra={"data": {
|
|
@@ -172,49 +310,123 @@ def _handle_search(
|
|
| 172 |
"expanded_entities": len(expanded_entities),
|
| 173 |
"semantic_hits": len(semantic_results),
|
| 174 |
"entity_hits": len(entity_results),
|
|
|
|
| 175 |
"rrf_merged": len(merged),
|
| 176 |
"after_cross_encoder": len(top_papers),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
"kg_active": graph is not None,
|
| 178 |
}},
|
| 179 |
)
|
| 180 |
|
| 181 |
-
# Step 7: Trial matching —
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
}
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
if nct_id in trial_by_nct:
|
| 194 |
-
t = trial_by_nct[nct_id]
|
| 195 |
-
related_trials.append({
|
| 196 |
-
"nct_id": t.get("nct_id", ""),
|
| 197 |
-
"title": t.get("title", ""),
|
| 198 |
-
"phase": t.get("phase", ""),
|
| 199 |
-
"status": t.get("status", ""),
|
| 200 |
-
"url": t.get("url", ""),
|
| 201 |
-
})
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
return {
|
| 220 |
"papers": [
|
|
@@ -225,14 +437,19 @@ def _handle_search(
|
|
| 225 |
"doi": r["doi"],
|
| 226 |
"citation_count": r["citation_count"],
|
| 227 |
"section": r["section"],
|
| 228 |
-
"excerpt": r["document"]
|
| 229 |
"score": round(r["score"], 3),
|
|
|
|
|
|
|
| 230 |
}
|
| 231 |
-
for r in
|
| 232 |
],
|
| 233 |
"query_entities": query_entities,
|
| 234 |
"expanded_entities": expanded_entities,
|
|
|
|
|
|
|
| 235 |
"trials": related_trials,
|
| 236 |
-
"evidence_count": len(
|
| 237 |
"kg_expansion_active": graph is not None,
|
|
|
|
| 238 |
}
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import json
|
| 5 |
+
import re
|
| 6 |
from collections.abc import Generator
|
| 7 |
|
| 8 |
import anthropic
|
|
|
|
| 22 |
from graph import query as kg_query
|
| 23 |
from llm import cached_system, cached_tools
|
| 24 |
from logging_config import get_logger
|
| 25 |
+
from normalization.drug_vocab import build_drug_vocab, suggest_drug_term
|
| 26 |
from prompts import SYNTHESIS_SYSTEM
|
| 27 |
from rag import retriever as rag_retriever
|
| 28 |
from tools import RESEARCH_TOOLS
|
|
|
|
| 32 |
# Loaded once at startup — ~80MB model, ~80ms/pair on CPU
|
| 33 |
_cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL)
|
| 34 |
|
| 35 |
+
# Drug vocabulary is derived from the (startup-loaded) trials + graph; cache by identity
|
| 36 |
+
# so it is built once per session rather than on every query.
|
| 37 |
+
_DRUG_VOCAB_CACHE: dict[int, dict] = {}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _get_drug_vocab(trials: list[dict], graph: nx.DiGraph | None) -> dict:
|
| 41 |
+
key = id(trials)
|
| 42 |
+
vocab = _DRUG_VOCAB_CACHE.get(key)
|
| 43 |
+
if vocab is None:
|
| 44 |
+
vocab = build_drug_vocab(trials, graph)
|
| 45 |
+
_DRUG_VOCAB_CACHE.clear() # session has one trials object; avoid unbounded growth
|
| 46 |
+
_DRUG_VOCAB_CACHE[key] = vocab
|
| 47 |
+
return vocab
|
| 48 |
+
|
| 49 |
|
| 50 |
def stream_research_agent(
|
| 51 |
client: anthropic.Anthropic,
|
|
|
|
| 67 |
]
|
| 68 |
# Attach graph reference so _handle_search can use KG expansion
|
| 69 |
_graph = graph
|
| 70 |
+
first_turn = True
|
| 71 |
|
| 72 |
while True:
|
| 73 |
stream_text = ""
|
| 74 |
|
| 75 |
+
# Force tool use on the first turn so Claude always searches before synthesizing.
|
| 76 |
+
# Unknown proper nouns (drug codes, gene IDs) would otherwise trigger a
|
| 77 |
+
# "I don't recognize X" response straight from training knowledge.
|
| 78 |
+
tool_choice: dict = {"type": "any"} if first_turn else {"type": "auto"}
|
| 79 |
+
|
| 80 |
with client.messages.stream(
|
| 81 |
model=SYNTHESIS_MODEL,
|
| 82 |
max_tokens=4096,
|
| 83 |
system=cached_system(SYNTHESIS_SYSTEM),
|
| 84 |
tools=cached_tools(RESEARCH_TOOLS),
|
| 85 |
+
tool_choice=tool_choice,
|
| 86 |
messages=messages,
|
| 87 |
) as stream:
|
| 88 |
# Accumulate tool-use input JSON alongside streaming text
|
|
|
|
| 122 |
|
| 123 |
messages.append({"role": "assistant", "content": final_msg.content})
|
| 124 |
|
| 125 |
+
first_turn = False
|
| 126 |
+
|
| 127 |
if final_msg.stop_reason == "end_turn":
|
| 128 |
yield ("done", stream_text)
|
| 129 |
return
|
|
|
|
| 152 |
return
|
| 153 |
|
| 154 |
|
| 155 |
+
# Ubiquitous ALS disease descriptors — grounded across the whole corpus, so they
|
| 156 |
+
# must never count as a query "focus" entity for the grounding gate.
|
| 157 |
+
_GENERIC_EXACT = {"als", "mnd", "mnds", "ftd", "als/ftd", "disease", "neurodegeneration", "therapy", "treatment"}
|
| 158 |
+
_GENERIC_SUBSTRINGS = ("amyotrophic", "lateral sclerosis", "motor neuron")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _is_generic_term(term: str) -> bool:
|
| 162 |
+
"""True for disease-generic terms that are grounded everywhere (ALS, MND, etc.)."""
|
| 163 |
+
t = term.lower().strip()
|
| 164 |
+
if t in _GENERIC_EXACT:
|
| 165 |
+
return True
|
| 166 |
+
return any(sub in t for sub in _GENERIC_SUBSTRINGS)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _norm_alnum(s: str) -> str:
|
| 170 |
+
"""Lowercase, alphanumeric-only form so 'CNM-Au8', 'CNMAu8', 'cnm_au8' all unify."""
|
| 171 |
+
return "".join(c for c in s.lower() if c.isalnum())
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# Trial status display order — available/recruiting first, closed/unavailable last.
|
| 175 |
+
# Expanded Access uses AVAILABLE / TEMPORARILY_NOT_AVAILABLE / NO_LONGER_AVAILABLE.
|
| 176 |
+
_TRIAL_STATUS_RANK = {
|
| 177 |
+
"AVAILABLE": 0,
|
| 178 |
+
"RECRUITING": 1,
|
| 179 |
+
"NOT_YET_RECRUITING": 2,
|
| 180 |
+
"ENROLLING_BY_INVITATION": 3,
|
| 181 |
+
"ACTIVE_NOT_RECRUITING": 4,
|
| 182 |
+
"TEMPORARILY_NOT_AVAILABLE": 5,
|
| 183 |
+
"COMPLETED": 6,
|
| 184 |
+
"SUSPENDED": 7,
|
| 185 |
+
"TERMINATED": 8,
|
| 186 |
+
"WITHDRAWN": 9,
|
| 187 |
+
"NO_LONGER_AVAILABLE": 10,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _term_excerpt(document: str, terms: list[str], window: int = 600) -> str:
|
| 192 |
+
"""Return a 600-char excerpt centred on the first keyword match, or the document start."""
|
| 193 |
+
doc_lower = document.lower()
|
| 194 |
+
for term in terms:
|
| 195 |
+
idx = doc_lower.find(term.lower())
|
| 196 |
+
if idx != -1:
|
| 197 |
+
start = max(0, idx - 200)
|
| 198 |
+
return document[start : start + window]
|
| 199 |
+
return document[:window]
|
| 200 |
+
|
| 201 |
+
|
| 202 |
def _handle_search(
|
| 203 |
tool_input: dict,
|
| 204 |
collection: chromadb.Collection,
|
|
|
|
| 224 |
collection, expanded_entities, n_results=RETRIEVAL_ENTITY_N
|
| 225 |
)
|
| 226 |
|
| 227 |
+
# Step 3b: Keyword search — exact $contains match for specific named terms.
|
| 228 |
+
# Also extracts alphanumeric tokens from query_text (e.g. "SPG302", "C9orf72",
|
| 229 |
+
# "AMX0035") that Claude may not include in query_entities because it doesn't
|
| 230 |
+
# recognize them as known biological entities.
|
| 231 |
+
_entity_tokens = list({
|
| 232 |
+
tok for tok in re.findall(r'\b[A-Za-z]+\d+\w*|\b[A-Z]{2,}\d*\w*', query_text)
|
| 233 |
+
if len(tok) >= 3
|
| 234 |
+
})
|
| 235 |
+
keyword_terms = list(dict.fromkeys(query_entities + _entity_tokens)) # dedup, preserve order
|
| 236 |
+
keyword_results = rag_retriever.search_by_keyword(collection, keyword_terms)
|
| 237 |
+
|
| 238 |
+
# Grounding gate — determine whether the query's *specific* focus entities are
|
| 239 |
+
# genuinely present in the PAPER corpus. Semantic search always returns nearest
|
| 240 |
+
# neighbors regardless of relevance, so we check exact literal presence
|
| 241 |
+
# ($contains); otherwise Claude grafts real PMIDs onto topically-adjacent-but-
|
| 242 |
+
# unrelated papers. Two exclusions from the focus set:
|
| 243 |
+
# - Ubiquitous disease descriptors (ALS / motor neuron disease) — grounded
|
| 244 |
+
# everywhere, never the subject of the query.
|
| 245 |
+
# - KG-node existence is deliberately NOT used as grounding: a compound can
|
| 246 |
+
# have a graph node purely from trial data while having zero paper evidence
|
| 247 |
+
# (e.g. SPG302). "In the corpus" means "written in a paper".
|
| 248 |
+
focus_terms = [t for t in keyword_terms if t.strip() and not _is_generic_term(t)]
|
| 249 |
+
|
| 250 |
+
# "Did you mean?" — for unrecognized focus terms, suggest the nearest known drug name
|
| 251 |
+
# (typo tolerance). SUGGESTION ONLY — the original term is still what gets searched, so a
|
| 252 |
+
# wrong suggestion can never silently redirect the query onto a different drug.
|
| 253 |
+
drug_vocab = _get_drug_vocab(trials, graph)
|
| 254 |
+
did_you_mean = {
|
| 255 |
+
t: s for t in focus_terms if (s := suggest_drug_term(t, drug_vocab))
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
grounded_terms: list[str] = []
|
| 259 |
+
ungrounded_terms: list[str] = []
|
| 260 |
+
for term in focus_terms:
|
| 261 |
+
if rag_retriever.is_grounded_in_corpus(collection, term):
|
| 262 |
+
grounded_terms.append(term)
|
| 263 |
+
else:
|
| 264 |
+
ungrounded_terms.append(term)
|
| 265 |
+
|
| 266 |
+
# If the query names specific entities and NONE are grounded in papers, the
|
| 267 |
+
# corpus holds no genuine evidence — Claude gets zero papers so it cannot graft.
|
| 268 |
+
evidence_ungrounded = bool(focus_terms) and not grounded_terms
|
| 269 |
+
|
| 270 |
# Step 4: RRF merge → top 20 papers
|
| 271 |
merged = rag_retriever.rrf_merge(
|
| 272 |
+
[semantic_results, entity_results, keyword_results], k=RRF_K, top_n=RRF_TOP_N
|
| 273 |
)
|
| 274 |
|
| 275 |
# Step 5: Cross-encoder rerank → top 15 papers
|
|
|
|
| 280 |
# Step 6: Citation boost — final score = ce_score × log(citation_count + 2)
|
| 281 |
top_papers = rag_retriever.apply_citation_boost(reranked)
|
| 282 |
|
| 283 |
+
# Apply the grounding gate: suppress spurious semantic matches when the query's
|
| 284 |
+
# focus entity is absent from the corpus. Trials are still returned below.
|
| 285 |
+
paper_pool = [] if evidence_ungrounded else top_papers
|
| 286 |
+
|
| 287 |
+
# Guarantee the papers that literally name a *landscape-only* focus compound are
|
| 288 |
+
# citable. When a compound appears only in full-text pipeline tables (no abstract
|
| 289 |
+
# anywhere — e.g. SPG302), the cross-encoder ranks those table chunks below generic
|
| 290 |
+
# semantic neighbors and they never reach the model, so it can neither cite nor
|
| 291 |
+
# label them. Inject their keyword-hits (capped). Well-grounded entities (C9orf72,
|
| 292 |
+
# tofersen) already surface primary papers via semantic/CE — skip injection for them.
|
| 293 |
+
landscape_only_terms = [
|
| 294 |
+
t for t in grounded_terms
|
| 295 |
+
if not rag_retriever.is_grounded_in_abstract(collection, t)
|
| 296 |
+
]
|
| 297 |
+
if not evidence_ungrounded and landscape_only_terms:
|
| 298 |
+
present = {r["pmid"] for r in paper_pool}
|
| 299 |
+
focus_hits = rag_retriever.search_by_keyword(collection, landscape_only_terms)
|
| 300 |
+
for r in focus_hits[:5]:
|
| 301 |
+
if r["pmid"] not in present:
|
| 302 |
+
r.setdefault("score", r.get("similarity", 0.0))
|
| 303 |
+
paper_pool.append(r)
|
| 304 |
+
present.add(r["pmid"])
|
| 305 |
+
|
| 306 |
_logger.info(
|
| 307 |
"KG+RAG+CE search",
|
| 308 |
extra={"data": {
|
|
|
|
| 310 |
"expanded_entities": len(expanded_entities),
|
| 311 |
"semantic_hits": len(semantic_results),
|
| 312 |
"entity_hits": len(entity_results),
|
| 313 |
+
"keyword_hits": len(keyword_results),
|
| 314 |
"rrf_merged": len(merged),
|
| 315 |
"after_cross_encoder": len(top_papers),
|
| 316 |
+
"grounded_terms": grounded_terms,
|
| 317 |
+
"ungrounded_terms": ungrounded_terms,
|
| 318 |
+
"evidence_ungrounded": evidence_ungrounded,
|
| 319 |
+
"papers_returned": len(paper_pool),
|
| 320 |
"kg_active": graph is not None,
|
| 321 |
}},
|
| 322 |
)
|
| 323 |
|
| 324 |
+
# Step 7: Trial matching — return ONLY trials genuinely about the queried compound
|
| 325 |
+
# or target, ranked available/recruiting first (EAPs, completed, and terminated all
|
| 326 |
+
# included). Match on the SPECIFIC query terms — never expanded_entities, whose KG
|
| 327 |
+
# expansion balloons to thousands of terms and floods results with unrelated ALS
|
| 328 |
+
# trials. Normalized (alphanumeric-only) matching unifies "CNM-Au8" / "CNMAu8" /
|
| 329 |
+
# "cnm_au8" across the query, trial interventions, and enriched target_entities.
|
| 330 |
+
specific_terms = [
|
| 331 |
+
t for t in dict.fromkeys(query_entities + focus_terms)
|
| 332 |
+
if t.strip() and not _is_generic_term(t)
|
| 333 |
+
]
|
| 334 |
+
norm_terms = [n for n in (_norm_alnum(t) for t in specific_terms) if len(n) >= 3]
|
| 335 |
+
# Drop fragments that are substrings of a longer matched term — the query regex
|
| 336 |
+
# splits "CNM-Au8" into "CNM"/"Au8", whose short normalized forms ("cnm"/"au8")
|
| 337 |
+
# over-match unrelated trials. Keep only maximal terms (e.g. "cnmau8").
|
| 338 |
+
norm_terms = [n for n in norm_terms if not any(n != m and n in m for m in norm_terms)]
|
| 339 |
+
nct_ids_in_query = {w.upper() for w in query_text.split() if w.upper().startswith("NCT")}
|
| 340 |
+
|
| 341 |
+
matched: dict[str, dict] = {}
|
| 342 |
+
for trial in trials:
|
| 343 |
+
nct = trial.get("nct_id", "")
|
| 344 |
+
if not nct:
|
| 345 |
+
continue
|
| 346 |
+
iv_names = " ".join(iv.get("name", "") for iv in trial.get("interventions", []))
|
| 347 |
+
targets = " ".join(trial.get("target_entities", []))
|
| 348 |
+
hay = _norm_alnum(f"{trial.get('title', '')} {iv_names} {targets} {trial.get('summary', '')}")
|
| 349 |
+
if nct.upper() in nct_ids_in_query or any(nt in hay for nt in norm_terms):
|
| 350 |
+
matched[nct] = trial
|
| 351 |
+
|
| 352 |
+
ranked = sorted(matched.values(), key=lambda t: _TRIAL_STATUS_RANK.get(t.get("status", ""), 99))
|
| 353 |
+
related_trials = [
|
| 354 |
+
{
|
| 355 |
+
"nct_id": t.get("nct_id", ""),
|
| 356 |
+
"title": t.get("title", ""),
|
| 357 |
+
"phase": t.get("phase", ""),
|
| 358 |
+
"status": t.get("status", ""),
|
| 359 |
+
"study_type": t.get("study_type", ""),
|
| 360 |
+
"url": t.get("url", ""),
|
| 361 |
}
|
| 362 |
+
for t in ranked[:10]
|
| 363 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
|
| 365 |
+
# Context-aware grounding note steers the synthesis model away from hallucination.
|
| 366 |
+
if evidence_ungrounded:
|
| 367 |
+
_terms = ", ".join(ungrounded_terms) or "the queried entity"
|
| 368 |
+
grounding_note = (
|
| 369 |
+
f"NO paper evidence exists in this database for: {_terms}. "
|
| 370 |
+
"Do NOT synthesize a mechanism or any factual claim from training knowledge, and do "
|
| 371 |
+
"NOT cite any PMID. State explicitly that the paper database contains no evidence for "
|
| 372 |
+
f"{_terms}. Report ONLY the clinical trials listed below (if any) as the sole grounded "
|
| 373 |
+
"information."
|
| 374 |
+
)
|
| 375 |
+
elif not paper_pool:
|
| 376 |
+
grounding_note = (
|
| 377 |
+
"NO papers were retrieved. Do not synthesize from training knowledge — state that the "
|
| 378 |
+
"database does not contain evidence for this topic. Report only trials below (if any)."
|
| 379 |
+
)
|
| 380 |
+
else:
|
| 381 |
+
note = f"{len(paper_pool)} papers retrieved. Cite a PMID only for claims stated in that paper's excerpt below."
|
| 382 |
+
if ungrounded_terms:
|
| 383 |
+
note += (
|
| 384 |
+
f" IMPORTANT: the database has NO evidence for: {', '.join(ungrounded_terms)}. "
|
| 385 |
+
"Say so explicitly and never attach a PMID to any claim about those terms."
|
| 386 |
+
)
|
| 387 |
+
note += (
|
| 388 |
+
" Papers marked evidence_tier='landscape_mention' name a compound only in their full "
|
| 389 |
+
"text (e.g. a drug-pipeline table), not their abstract — when citing such a paper for "
|
| 390 |
+
"that compound, label the citation as a full-text/pipeline-table mention, not a primary study."
|
| 391 |
+
)
|
| 392 |
+
grounding_note = note
|
| 393 |
+
|
| 394 |
+
# Typo suggestions (never substituted into the search). Surface as "did you mean?".
|
| 395 |
+
if did_you_mean:
|
| 396 |
+
hints = "; ".join(f"'{k}' → '{v}'" for k, v in did_you_mean.items())
|
| 397 |
+
grounding_note += (
|
| 398 |
+
f" POSSIBLE TYPOS (unrecognized query terms with a near match in the database): {hints}. "
|
| 399 |
+
"If a suggestion looks right, tell the physician there was no exact match and ask whether "
|
| 400 |
+
"they meant the suggested name, inviting them to re-query with it. Do NOT assume the "
|
| 401 |
+
"suggestion is correct and do NOT search it yourself."
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
# Evidence tier — for each paper, flag focus terms (drug codes) that appear only in its
|
| 405 |
+
# full text, not its abstract. A compound named only in a full-text pipeline/landscape
|
| 406 |
+
# table means the paper is not a primary source for it; the synthesis model labels such
|
| 407 |
+
# citations accordingly. Checks the paper's full concatenated text (all chunks), because
|
| 408 |
+
# the retrieved representative chunk often is not the one holding the compound name.
|
| 409 |
+
texts_by_pmid = rag_retriever.paper_texts_for_pmids(
|
| 410 |
+
collection, [r["pmid"] for r in paper_pool]
|
| 411 |
+
)
|
| 412 |
+
# Only compounds that are landscape-only across the WHOLE corpus (absent from every
|
| 413 |
+
# abstract, e.g. SPG302) can be reliably flagged from an abstract-vs-fulltext check.
|
| 414 |
+
# A common gene like C9orf72 is discussed in many paper bodies without appearing in
|
| 415 |
+
# their abstract — flagging those would wrongly demote primary studies, so restrict
|
| 416 |
+
# the check to landscape_only_terms.
|
| 417 |
+
fulltext_only_by_pmid: dict[str, list[str]] = {}
|
| 418 |
+
for r in paper_pool:
|
| 419 |
+
pmid = r["pmid"]
|
| 420 |
+
texts = texts_by_pmid.get(pmid, {"abstract": "", "full": ""})
|
| 421 |
+
fulltext_only_by_pmid[pmid] = [
|
| 422 |
+
t for t in landscape_only_terms
|
| 423 |
+
if rag_retriever.term_matches_text(texts["full"], t)
|
| 424 |
+
and not rag_retriever.term_matches_text(texts["abstract"], t)
|
| 425 |
+
]
|
| 426 |
+
|
| 427 |
+
_landscape = {pmid: terms for pmid, terms in fulltext_only_by_pmid.items() if terms}
|
| 428 |
+
if _landscape:
|
| 429 |
+
_logger.info("landscape-mention citations flagged", extra={"data": {"papers": _landscape}})
|
| 430 |
|
| 431 |
return {
|
| 432 |
"papers": [
|
|
|
|
| 437 |
"doi": r["doi"],
|
| 438 |
"citation_count": r["citation_count"],
|
| 439 |
"section": r["section"],
|
| 440 |
+
"excerpt": _term_excerpt(r["document"], keyword_terms),
|
| 441 |
"score": round(r["score"], 3),
|
| 442 |
+
"fulltext_only_mentions": fulltext_only_by_pmid.get(r["pmid"], []),
|
| 443 |
+
"evidence_tier": "landscape_mention" if fulltext_only_by_pmid.get(r["pmid"]) else "primary",
|
| 444 |
}
|
| 445 |
+
for r in paper_pool
|
| 446 |
],
|
| 447 |
"query_entities": query_entities,
|
| 448 |
"expanded_entities": expanded_entities,
|
| 449 |
+
"ungrounded_terms": ungrounded_terms,
|
| 450 |
+
"did_you_mean": did_you_mean,
|
| 451 |
"trials": related_trials,
|
| 452 |
+
"evidence_count": len(paper_pool),
|
| 453 |
"kg_expansion_active": graph is not None,
|
| 454 |
+
"grounding_note": grounding_note,
|
| 455 |
}
|
|
@@ -33,21 +33,18 @@ PUBMED_BASE_QUERY = (
|
|
| 33 |
'"amyotrophic lateral sclerosis"[MeSH Major Topic] '
|
| 34 |
"AND hasabstract[text]"
|
| 35 |
)
|
| 36 |
-
PUBMED_DEFAULT_QUERY =
|
| 37 |
-
'"amyotrophic lateral sclerosis"[MeSH Major Topic] '
|
| 38 |
-
'AND ("2018"[PDAT]:"2024"[PDAT]) '
|
| 39 |
-
"AND hasabstract[text]"
|
| 40 |
-
)
|
| 41 |
PUBMED_REFRESH_QUERY_TEMPLATE = (
|
| 42 |
'"amyotrophic lateral sclerosis"[MeSH Major Topic] '
|
| 43 |
'AND ("{since_date}"[PDAT]:"3000"[PDAT]) '
|
| 44 |
"AND hasabstract[text]"
|
| 45 |
)
|
| 46 |
-
PUBMED_DEFAULT_MAX =
|
| 47 |
PUBMED_BATCH_SIZE = 200 # PMIDs per Entrez efetch call
|
| 48 |
|
| 49 |
# Entity extraction
|
| 50 |
-
EXTRACTION_BATCH_SIZE =
|
|
|
|
| 51 |
|
| 52 |
# RAG — retrieval counts per stage
|
| 53 |
CHROMA_N_RESULTS = 10 # legacy default (kept for backward compat)
|
|
|
|
| 33 |
'"amyotrophic lateral sclerosis"[MeSH Major Topic] '
|
| 34 |
"AND hasabstract[text]"
|
| 35 |
)
|
| 36 |
+
PUBMED_DEFAULT_QUERY = PUBMED_BASE_QUERY # no date cap — fetch all 19k+ ALS papers
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
PUBMED_REFRESH_QUERY_TEMPLATE = (
|
| 38 |
'"amyotrophic lateral sclerosis"[MeSH Major Topic] '
|
| 39 |
'AND ("{since_date}"[PDAT]:"3000"[PDAT]) '
|
| 40 |
"AND hasabstract[text]"
|
| 41 |
)
|
| 42 |
+
PUBMED_DEFAULT_MAX = 20000
|
| 43 |
PUBMED_BATCH_SIZE = 200 # PMIDs per Entrez efetch call
|
| 44 |
|
| 45 |
# Entity extraction
|
| 46 |
+
EXTRACTION_BATCH_SIZE = 20 # papers per Claude call
|
| 47 |
+
EXTRACTION_WORKERS = 8 # parallel Claude calls (Haiku limit: 1000 RPM on paid tier)
|
| 48 |
|
| 49 |
# RAG — retrieval counts per stage
|
| 50 |
CHROMA_N_RESULTS = 10 # legacy default (kept for backward compat)
|
|
@@ -1,12 +1,15 @@
|
|
| 1 |
"""
|
| 2 |
-
Claude
|
| 3 |
-
Batches
|
| 4 |
Uses full_text when available, otherwise abstract.
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
import json
|
|
|
|
| 9 |
import time
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
import anthropic
|
|
@@ -17,6 +20,7 @@ from config import (
|
|
| 17 |
EXTRACTION_BATCH_SIZE,
|
| 18 |
EXTRACTION_MODEL,
|
| 19 |
EXTRACTION_PROGRESS_PATH,
|
|
|
|
| 20 |
PAPERS_PATH,
|
| 21 |
)
|
| 22 |
from extraction.normalizer import CanonicalRegistry, guess_entity_type, normalize_entity
|
|
@@ -44,7 +48,11 @@ def extract_all(
|
|
| 44 |
progress_path: Path = EXTRACTION_PROGRESS_PATH,
|
| 45 |
client: anthropic.Anthropic | None = None,
|
| 46 |
) -> list[PaperExtractionResult]:
|
| 47 |
-
"""Extract entities from all papers. Skips already-processed PMIDs.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
if client is None:
|
| 49 |
client = anthropic.Anthropic()
|
| 50 |
|
|
@@ -60,7 +68,9 @@ def extract_all(
|
|
| 60 |
registry = CanonicalRegistry()
|
| 61 |
entities_path.parent.mkdir(parents=True, exist_ok=True)
|
| 62 |
|
|
|
|
| 63 |
results: list[PaperExtractionResult] = []
|
|
|
|
| 64 |
|
| 65 |
with (
|
| 66 |
open(entities_path, "a", encoding="utf-8") as out_f,
|
|
@@ -73,22 +83,22 @@ def extract_all(
|
|
| 73 |
):
|
| 74 |
task = progress.add_task("Extracting entities", total=len(pending))
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
for
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
|
| 93 |
return results
|
| 94 |
|
|
@@ -160,29 +170,38 @@ def _extract_batch(
|
|
| 160 |
|
| 161 |
|
| 162 |
def _call_claude(client: anthropic.Anthropic, batch: list[ALSPaper]) -> list:
|
| 163 |
-
"""Raw Claude call — returns response.content blocks.
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
response = client.messages.create(
|
| 166 |
model=EXTRACTION_MODEL,
|
| 167 |
-
max_tokens=
|
| 168 |
-
system=
|
| 169 |
-
tools=
|
| 170 |
tool_choice={"type": "any"},
|
| 171 |
messages=[{"role": "user", "content": _format_batch(batch)}],
|
| 172 |
)
|
| 173 |
return response.content
|
|
|
|
|
|
|
|
|
|
| 174 |
except anthropic.RateLimitError:
|
| 175 |
_logger.warning("Rate limited — sleeping 30s")
|
| 176 |
time.sleep(30)
|
| 177 |
-
|
| 178 |
-
model=EXTRACTION_MODEL,
|
| 179 |
-
max_tokens=4096,
|
| 180 |
-
system=_EXTRACTION_SYSTEM,
|
| 181 |
-
tools=EXTRACTION_TOOLS,
|
| 182 |
-
tool_choice={"type": "any"},
|
| 183 |
-
messages=[{"role": "user", "content": _format_batch(batch)}],
|
| 184 |
-
)
|
| 185 |
-
return response.content
|
| 186 |
|
| 187 |
|
| 188 |
def _format_batch(batch: list[ALSPaper]) -> str:
|
|
@@ -192,8 +211,8 @@ def _format_batch(batch: list[ALSPaper]) -> str:
|
|
| 192 |
]
|
| 193 |
for paper in batch:
|
| 194 |
text = paper.full_text if paper.full_text else paper.abstract
|
| 195 |
-
# Cap at
|
| 196 |
-
excerpt = text[:
|
| 197 |
parts.append(
|
| 198 |
f"--- PMID:{paper.pmid} ---\n"
|
| 199 |
f"Title: {paper.title}\n\n"
|
|
@@ -209,6 +228,8 @@ def _parse_entities(
|
|
| 209 |
) -> list[ExtractedEntity]:
|
| 210 |
entities = []
|
| 211 |
for item in raw:
|
|
|
|
|
|
|
| 212 |
name = item.get("name", "").strip()
|
| 213 |
entity_type = item.get("type", "").strip()
|
| 214 |
if not name or not entity_type:
|
|
@@ -233,6 +254,8 @@ def _parse_relationships(
|
|
| 233 |
) -> list[EntityRelationship]:
|
| 234 |
rels = []
|
| 235 |
for item in raw:
|
|
|
|
|
|
|
| 236 |
source_name = item.get("source", "").strip()
|
| 237 |
target_name = item.get("target", "").strip()
|
| 238 |
rel_type = item.get("type", "").strip()
|
|
|
|
| 1 |
"""
|
| 2 |
+
Claude Haiku entity extractor.
|
| 3 |
+
Batches 20 papers per API call; resumable via .progress.json.
|
| 4 |
Uses full_text when available, otherwise abstract.
|
| 5 |
+
Prompt caching on system + tools reduces per-call cost ~40%.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import json
|
| 10 |
+
import threading
|
| 11 |
import time
|
| 12 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
import anthropic
|
|
|
|
| 20 |
EXTRACTION_BATCH_SIZE,
|
| 21 |
EXTRACTION_MODEL,
|
| 22 |
EXTRACTION_PROGRESS_PATH,
|
| 23 |
+
EXTRACTION_WORKERS,
|
| 24 |
PAPERS_PATH,
|
| 25 |
)
|
| 26 |
from extraction.normalizer import CanonicalRegistry, guess_entity_type, normalize_entity
|
|
|
|
| 48 |
progress_path: Path = EXTRACTION_PROGRESS_PATH,
|
| 49 |
client: anthropic.Anthropic | None = None,
|
| 50 |
) -> list[PaperExtractionResult]:
|
| 51 |
+
"""Extract entities from all papers. Skips already-processed PMIDs.
|
| 52 |
+
|
| 53 |
+
Runs EXTRACTION_WORKERS batches in parallel. A lock serializes file writes
|
| 54 |
+
and progress saves so threads don't corrupt each other.
|
| 55 |
+
"""
|
| 56 |
if client is None:
|
| 57 |
client = anthropic.Anthropic()
|
| 58 |
|
|
|
|
| 68 |
registry = CanonicalRegistry()
|
| 69 |
entities_path.parent.mkdir(parents=True, exist_ok=True)
|
| 70 |
|
| 71 |
+
batches = [pending[i : i + EXTRACTION_BATCH_SIZE] for i in range(0, len(pending), EXTRACTION_BATCH_SIZE)]
|
| 72 |
results: list[PaperExtractionResult] = []
|
| 73 |
+
write_lock = threading.Lock()
|
| 74 |
|
| 75 |
with (
|
| 76 |
open(entities_path, "a", encoding="utf-8") as out_f,
|
|
|
|
| 83 |
):
|
| 84 |
task = progress.add_task("Extracting entities", total=len(pending))
|
| 85 |
|
| 86 |
+
def _process_batch(batch: list[ALSPaper]) -> list[PaperExtractionResult]:
|
| 87 |
+
return _extract_batch(client, batch, registry)
|
| 88 |
+
|
| 89 |
+
with ThreadPoolExecutor(max_workers=EXTRACTION_WORKERS) as pool:
|
| 90 |
+
futures = {pool.submit(_process_batch, b): b for b in batches}
|
| 91 |
+
for future in as_completed(futures):
|
| 92 |
+
batch_results = future.result()
|
| 93 |
+
with write_lock:
|
| 94 |
+
for result in batch_results:
|
| 95 |
+
out_f.write(json.dumps(result.to_dict()) + "\n")
|
| 96 |
+
done_pmids.add(result.pmid)
|
| 97 |
+
results.append(result)
|
| 98 |
+
out_f.flush()
|
| 99 |
+
_save_progress(progress_path, done_pmids)
|
| 100 |
+
registry.save()
|
| 101 |
+
progress.advance(task, len(futures[future]))
|
| 102 |
|
| 103 |
return results
|
| 104 |
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
def _call_claude(client: anthropic.Anthropic, batch: list[ALSPaper]) -> list:
|
| 173 |
+
"""Raw Claude call — returns response.content blocks.
|
| 174 |
+
|
| 175 |
+
Prompt caching: system and tools are static across all calls; adding
|
| 176 |
+
cache_control to the last tool + system block caches the entire prefix
|
| 177 |
+
(tools render before system in the API token order). Cache reads cost
|
| 178 |
+
~10% of normal input price, halving the effective per-call overhead.
|
| 179 |
+
"""
|
| 180 |
+
# Cache the static system+tools prefix across batch calls
|
| 181 |
+
cached_system = [{"type": "text", "text": _EXTRACTION_SYSTEM, "cache_control": {"type": "ephemeral"}}]
|
| 182 |
+
cached_tools = list(EXTRACTION_TOOLS)
|
| 183 |
+
if cached_tools:
|
| 184 |
+
last = dict(cached_tools[-1])
|
| 185 |
+
last["cache_control"] = {"type": "ephemeral"}
|
| 186 |
+
cached_tools[-1] = last
|
| 187 |
+
|
| 188 |
+
def _request() -> list:
|
| 189 |
response = client.messages.create(
|
| 190 |
model=EXTRACTION_MODEL,
|
| 191 |
+
max_tokens=8192,
|
| 192 |
+
system=cached_system,
|
| 193 |
+
tools=cached_tools,
|
| 194 |
tool_choice={"type": "any"},
|
| 195 |
messages=[{"role": "user", "content": _format_batch(batch)}],
|
| 196 |
)
|
| 197 |
return response.content
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
return _request()
|
| 201 |
except anthropic.RateLimitError:
|
| 202 |
_logger.warning("Rate limited — sleeping 30s")
|
| 203 |
time.sleep(30)
|
| 204 |
+
return _request()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
def _format_batch(batch: list[ALSPaper]) -> str:
|
|
|
|
| 211 |
]
|
| 212 |
for paper in batch:
|
| 213 |
text = paper.full_text if paper.full_text else paper.abstract
|
| 214 |
+
# Cap at 2000 chars — 20-paper batches at ~500 tokens each stay well under 8192 output limit
|
| 215 |
+
excerpt = text[:2000] if text else paper.abstract[:1000]
|
| 216 |
parts.append(
|
| 217 |
f"--- PMID:{paper.pmid} ---\n"
|
| 218 |
f"Title: {paper.title}\n\n"
|
|
|
|
| 228 |
) -> list[ExtractedEntity]:
|
| 229 |
entities = []
|
| 230 |
for item in raw:
|
| 231 |
+
if not isinstance(item, dict):
|
| 232 |
+
continue
|
| 233 |
name = item.get("name", "").strip()
|
| 234 |
entity_type = item.get("type", "").strip()
|
| 235 |
if not name or not entity_type:
|
|
|
|
| 254 |
) -> list[EntityRelationship]:
|
| 255 |
rels = []
|
| 256 |
for item in raw:
|
| 257 |
+
if not isinstance(item, dict):
|
| 258 |
+
continue
|
| 259 |
source_name = item.get("source", "").strip()
|
| 260 |
target_name = item.get("target", "").strip()
|
| 261 |
rel_type = item.get("type", "").strip()
|
|
@@ -28,16 +28,19 @@ Call extract_trial_targets once per trial. Return an empty targets list only whe
|
|
| 28 |
|
| 29 |
|
| 30 |
def fetch_als_trials(
|
| 31 |
-
status: str | list[str] = ("RECRUITING", "NOT_YET_RECRUITING", "ACTIVE_NOT_RECRUITING"),
|
| 32 |
client: "anthropic.Anthropic | None" = None,
|
| 33 |
) -> list[dict]:
|
| 34 |
-
"""Fetch ALS interventional
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
params: dict[str, str | int] = {
|
| 38 |
"query.cond": "Amyotrophic Lateral Sclerosis",
|
| 39 |
-
"
|
| 40 |
-
"aggFilters": "studyType:int",
|
| 41 |
"pageSize": 1000,
|
| 42 |
"format": "json",
|
| 43 |
}
|
|
@@ -91,11 +94,17 @@ def _flatten_trial(study: dict) -> dict:
|
|
| 91 |
for iv in arms_mod.get("interventions", [])
|
| 92 |
]
|
| 93 |
|
|
|
|
|
|
|
|
|
|
| 94 |
return {
|
| 95 |
"nct_id": nct_id,
|
| 96 |
"title": id_mod.get("briefTitle", ""),
|
| 97 |
-
|
|
|
|
| 98 |
"status": status_mod.get("overallStatus", ""),
|
|
|
|
|
|
|
| 99 |
"sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
|
| 100 |
"summary": desc_mod.get("briefSummary", ""),
|
| 101 |
"interventions": interventions,
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def fetch_als_trials(
|
|
|
|
| 31 |
client: "anthropic.Anthropic | None" = None,
|
| 32 |
) -> list[dict]:
|
| 33 |
+
"""Fetch all ALS interventional + expanded-access studies, regardless of status.
|
| 34 |
+
|
| 35 |
+
No status filter — completed, terminated, and withdrawn trials are as
|
| 36 |
+
clinically important as active ones (negative results inform research).
|
| 37 |
+
`studyType:int exp` includes both interventional trials AND Expanded Access
|
| 38 |
+
Programs (EAP / compassionate use, e.g. NCT05281484), which physicians need for
|
| 39 |
+
off-trial access options. Status filtering is left to query time.
|
| 40 |
+
"""
|
| 41 |
params: dict[str, str | int] = {
|
| 42 |
"query.cond": "Amyotrophic Lateral Sclerosis",
|
| 43 |
+
"aggFilters": "studyType:int exp",
|
|
|
|
| 44 |
"pageSize": 1000,
|
| 45 |
"format": "json",
|
| 46 |
}
|
|
|
|
| 94 |
for iv in arms_mod.get("interventions", [])
|
| 95 |
]
|
| 96 |
|
| 97 |
+
study_type = design_mod.get("studyType", "")
|
| 98 |
+
is_eap = study_type == "EXPANDED_ACCESS"
|
| 99 |
+
|
| 100 |
return {
|
| 101 |
"nct_id": nct_id,
|
| 102 |
"title": id_mod.get("briefTitle", ""),
|
| 103 |
+
# Expanded Access has no trial phase — surface it as the phase label instead
|
| 104 |
+
"phase": "Expanded Access" if is_eap else (", ".join(design_mod.get("phases", [])) or "N/A"),
|
| 105 |
"status": status_mod.get("overallStatus", ""),
|
| 106 |
+
"study_type": study_type,
|
| 107 |
+
"is_expanded_access": is_eap,
|
| 108 |
"sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
|
| 109 |
"summary": desc_mod.get("briefSummary", ""),
|
| 110 |
"interventions": interventions,
|
|
@@ -27,19 +27,29 @@ def _sleep() -> None:
|
|
| 27 |
time.sleep(0.1 if os.getenv("NCBI_API_KEY") else 0.4)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def get_pmcids(pmids: list[str]) -> dict[str, str]:
|
| 31 |
"""
|
| 32 |
Map PubMed IDs to PMC IDs for papers with Open Access full text.
|
| 33 |
-
|
| 34 |
-
|
|
|
|
| 35 |
Returns {pmid: pmcid}.
|
| 36 |
"""
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
if not pmids:
|
| 39 |
return {}
|
| 40 |
|
| 41 |
from rich.progress import Progress, SpinnerColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn, TextColumn
|
|
|
|
|
|
|
| 42 |
result: dict[str, str] = {}
|
|
|
|
| 43 |
|
| 44 |
with Progress(
|
| 45 |
SpinnerColumn(),
|
|
@@ -48,31 +58,36 @@ def get_pmcids(pmids: list[str]) -> dict[str, str]:
|
|
| 48 |
TaskProgressColumn(),
|
| 49 |
TimeRemainingColumn(),
|
| 50 |
) as progress:
|
| 51 |
-
task = progress.add_task("Looking up PMC IDs...", total=len(
|
| 52 |
-
|
| 53 |
-
for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
for attempt in range(3):
|
| 55 |
try:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
break
|
| 60 |
except Exception as exc:
|
| 61 |
if attempt == 2:
|
| 62 |
-
_logger.
|
| 63 |
-
|
| 64 |
break
|
| 65 |
time.sleep(2 ** attempt)
|
| 66 |
|
| 67 |
-
for
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
break
|
| 74 |
|
| 75 |
-
|
| 76 |
progress.advance(task)
|
| 77 |
|
| 78 |
_logger.info("PMC ID lookup", extra={"data": {"pmids": len(pmids), "found": len(result)}})
|
|
@@ -112,19 +127,22 @@ def _parse_jats_xml(xml_data: bytes) -> str | None:
|
|
| 112 |
return None
|
| 113 |
|
| 114 |
sections: list[str] = []
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
return "\n\n".join(sections) if sections else None
|
|
|
|
| 27 |
time.sleep(0.1 if os.getenv("NCBI_API_KEY") else 0.4)
|
| 28 |
|
| 29 |
|
| 30 |
+
_IDCONV_URL = "https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/"
|
| 31 |
+
_IDCONV_BATCH = 200
|
| 32 |
+
|
| 33 |
+
|
| 34 |
def get_pmcids(pmids: list[str]) -> dict[str, str]:
|
| 35 |
"""
|
| 36 |
Map PubMed IDs to PMC IDs for papers with Open Access full text.
|
| 37 |
+
Uses the NCBI ID Converter API (idconv) which accepts batches of 200 PMIDs
|
| 38 |
+
and returns a proper PMID→PMCID mapping — dramatically faster than one
|
| 39 |
+
elink call per PMID.
|
| 40 |
Returns {pmid: pmcid}.
|
| 41 |
"""
|
| 42 |
+
import urllib.request
|
| 43 |
+
import urllib.parse
|
| 44 |
+
|
| 45 |
if not pmids:
|
| 46 |
return {}
|
| 47 |
|
| 48 |
from rich.progress import Progress, SpinnerColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn, TextColumn
|
| 49 |
+
|
| 50 |
+
email = os.environ.get("ENTREZ_EMAIL", "")
|
| 51 |
result: dict[str, str] = {}
|
| 52 |
+
batches = [pmids[i : i + _IDCONV_BATCH] for i in range(0, len(pmids), _IDCONV_BATCH)]
|
| 53 |
|
| 54 |
with Progress(
|
| 55 |
SpinnerColumn(),
|
|
|
|
| 58 |
TaskProgressColumn(),
|
| 59 |
TimeRemainingColumn(),
|
| 60 |
) as progress:
|
| 61 |
+
task = progress.add_task("Looking up PMC IDs...", total=len(batches))
|
| 62 |
+
|
| 63 |
+
for batch in batches:
|
| 64 |
+
params = urllib.parse.urlencode({
|
| 65 |
+
"ids": ",".join(batch),
|
| 66 |
+
"format": "json",
|
| 67 |
+
"email": email,
|
| 68 |
+
})
|
| 69 |
+
url = f"{_IDCONV_URL}?{params}"
|
| 70 |
for attempt in range(3):
|
| 71 |
try:
|
| 72 |
+
with urllib.request.urlopen(url, timeout=30) as resp:
|
| 73 |
+
import json as _json
|
| 74 |
+
data = _json.loads(resp.read())
|
| 75 |
break
|
| 76 |
except Exception as exc:
|
| 77 |
if attempt == 2:
|
| 78 |
+
_logger.warning(f"idconv batch failed: {exc}")
|
| 79 |
+
data = {}
|
| 80 |
break
|
| 81 |
time.sleep(2 ** attempt)
|
| 82 |
|
| 83 |
+
for record in data.get("records", []):
|
| 84 |
+
pmid = record.get("pmid")
|
| 85 |
+
pmcid = record.get("pmcid")
|
| 86 |
+
# pmid comes back as int from the API; pmcid is like "PMC1234567"
|
| 87 |
+
if pmid and pmcid and pmcid.startswith("PMC"):
|
| 88 |
+
result[str(pmid)] = pmcid[3:]
|
|
|
|
| 89 |
|
| 90 |
+
time.sleep(0.4)
|
| 91 |
progress.advance(task)
|
| 92 |
|
| 93 |
_logger.info("PMC ID lookup", extra={"data": {"pmids": len(pmids), "found": len(result)}})
|
|
|
|
| 127 |
return None
|
| 128 |
|
| 129 |
sections: list[str] = []
|
| 130 |
+
|
| 131 |
+
# Try structured sections first (standard JATS)
|
| 132 |
+
top_secs = [sec for sec in body.findall(".//sec") if sec in body]
|
| 133 |
+
for sec in top_secs:
|
| 134 |
+
title_el = sec.find("title")
|
| 135 |
+
title = (title_el.text or "Section").strip() if title_el is not None else "Section"
|
| 136 |
+
paragraphs = ["".join(p.itertext()).strip() for p in sec.findall(".//p")]
|
| 137 |
+
paragraphs = [t for t in paragraphs if t]
|
| 138 |
+
if paragraphs:
|
| 139 |
+
sections.append(f"[{title}]\n" + "\n".join(paragraphs))
|
| 140 |
+
|
| 141 |
+
# Fall back to bare paragraphs when there are no top-level sec elements
|
| 142 |
+
if not sections:
|
| 143 |
+
paragraphs = ["".join(p.itertext()).strip() for p in body.findall(".//p")]
|
| 144 |
+
paragraphs = [t for t in paragraphs if t]
|
| 145 |
+
if paragraphs:
|
| 146 |
+
sections.append("[Body]\n" + "\n".join(paragraphs))
|
| 147 |
|
| 148 |
return "\n\n".join(sections) if sections else None
|
|
@@ -28,14 +28,39 @@ def _sleep() -> None:
|
|
| 28 |
time.sleep(0.1 if os.getenv("NCBI_API_KEY") else 0.4)
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
| 31 |
def search_pmids(query: str, max_results: int = 500) -> list[str]:
|
| 32 |
-
"""Search PubMed with a query string and return a list of PMIDs.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
_configure_entrez()
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
return pmids
|
| 40 |
|
| 41 |
|
|
|
|
| 28 |
time.sleep(0.1 if os.getenv("NCBI_API_KEY") else 0.4)
|
| 29 |
|
| 30 |
|
| 31 |
+
_ESEARCH_PAGE_SIZE = 9999 # NCBI hard cap per esearch call
|
| 32 |
+
|
| 33 |
+
|
| 34 |
def search_pmids(query: str, max_results: int = 500) -> list[str]:
|
| 35 |
+
"""Search PubMed with a query string and return a list of PMIDs.
|
| 36 |
+
|
| 37 |
+
Pages through esearch results in chunks of 9,999 (NCBI's per-call cap)
|
| 38 |
+
until max_results or the total result count is reached.
|
| 39 |
+
"""
|
| 40 |
_configure_entrez()
|
| 41 |
+
pmids: list[str] = []
|
| 42 |
+
retstart = 0
|
| 43 |
+
total: int | None = None
|
| 44 |
+
|
| 45 |
+
while True:
|
| 46 |
+
want = min(_ESEARCH_PAGE_SIZE, max_results - len(pmids))
|
| 47 |
+
handle = Entrez.esearch(db="pubmed", term=query, retmax=want, retstart=retstart)
|
| 48 |
+
record = Entrez.read(handle)
|
| 49 |
+
handle.close()
|
| 50 |
+
|
| 51 |
+
if total is None:
|
| 52 |
+
total = int(record["Count"])
|
| 53 |
+
|
| 54 |
+
page = list(record["IdList"])
|
| 55 |
+
pmids.extend(page)
|
| 56 |
+
|
| 57 |
+
if not page or len(pmids) >= max_results or len(pmids) >= total:
|
| 58 |
+
break
|
| 59 |
+
|
| 60 |
+
retstart += len(page)
|
| 61 |
+
_sleep()
|
| 62 |
+
|
| 63 |
+
_logger.info("PubMed esearch", extra={"data": {"count": len(pmids), "total": total, "query": query[:80]}})
|
| 64 |
return pmids
|
| 65 |
|
| 66 |
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Query-time drug-name canonicalization: variance + typo auto-fix.
|
| 3 |
+
|
| 4 |
+
Behavior:
|
| 5 |
+
1. Variance (hyphen/space/case) — auto-handled downstream by normalized matching, e.g.
|
| 6 |
+
'Prime-C'/'PrimeC'/'prime c' all match 'primec'. No note.
|
| 7 |
+
2. Alias (brand↔generic, code↔name) — resolved via extraction.normalizer tables. No note.
|
| 8 |
+
3. Typo — NOT auto-substituted. A conservative fuzzy match (rapidfuzz) yields a transparent
|
| 9 |
+
"did you mean X?" SUGGESTION only; the original term is still what gets searched.
|
| 10 |
+
|
| 11 |
+
Why typos are suggested, not silently fixed: biomedical names are adversarially dense. Empirically
|
| 12 |
+
'biib068'→'BIIB078' (a DIFFERENT drug) scores 85.7, higher than the genuine typo 'primce'→'primec'
|
| 13 |
+
at 83.3 — so no score threshold can auto-correct the real typo without also silently mapping a
|
| 14 |
+
query onto the wrong drug. For a physician tool that is unacceptable, so tier 3 never changes the
|
| 15 |
+
searched term; it only surfaces a suggestion the physician can choose to act on.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from rapidfuzz import fuzz, process
|
| 20 |
+
|
| 21 |
+
from extraction.normalizer import _COMPOUND_ALIASES, _GENE_ALIASES
|
| 22 |
+
from logging_config import get_logger
|
| 23 |
+
|
| 24 |
+
_logger = get_logger("normalization.drug_vocab")
|
| 25 |
+
|
| 26 |
+
# Fuzzy-SUGGESTION guards (calibrated: primce/primec ≈ 83; word-drug typos ≈ 87–94).
|
| 27 |
+
# Suggestions never change the searched term, so no ambiguity gap is needed — the physician
|
| 28 |
+
# sees the candidate and decides. Length floor still skips short gene codes (SOD1, NEK1, FUS).
|
| 29 |
+
_FUZZY_MIN_LEN = 6
|
| 30 |
+
_FUZZY_MIN_SCORE = 82.0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _norm(s: str) -> str:
|
| 34 |
+
"""Lowercase, alphanumeric-only form so 'Prime-C'/'PrimeC'/'prime c' unify to 'primec'."""
|
| 35 |
+
return "".join(c for c in s.lower() if c.isalnum())
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def build_drug_vocab(trials: list[dict], graph=None) -> dict[str, object]:
|
| 39 |
+
"""
|
| 40 |
+
Build the query-time drug vocabulary as {"exact": frozenset, "fuzzy": {norm: display}}.
|
| 41 |
+
|
| 42 |
+
- `exact` (tier 1/2 membership): every normalized known name — alias forms, trial
|
| 43 |
+
intervention names + target_entities, and KG Compound names. Broad on purpose so a real
|
| 44 |
+
drug/intervention always resolves exactly and is NEVER sent to fuzzy correction.
|
| 45 |
+
- `fuzzy` (tier 3 candidates): clean CANONICAL drug names only — alias-table values and KG
|
| 46 |
+
Compound display names. Excludes messy intervention strings ("riluzole 50 mg tablet")
|
| 47 |
+
whose near-duplicates would otherwise act as false ambiguity competitors and block valid
|
| 48 |
+
corrections. Built once at startup and passed into the search handler.
|
| 49 |
+
"""
|
| 50 |
+
exact: set[str] = set()
|
| 51 |
+
fuzzy: dict[str, str] = {}
|
| 52 |
+
|
| 53 |
+
def _add_exact(name: str) -> None:
|
| 54 |
+
n = _norm(name)
|
| 55 |
+
if len(n) >= 3:
|
| 56 |
+
exact.add(n)
|
| 57 |
+
|
| 58 |
+
def _add_fuzzy(name: str) -> None:
|
| 59 |
+
n = _norm(name)
|
| 60 |
+
if len(n) >= 3:
|
| 61 |
+
fuzzy.setdefault(n, name)
|
| 62 |
+
exact.add(n)
|
| 63 |
+
|
| 64 |
+
# Alias tables — canonical values are clean drug names (fuzzy), surface forms exact-only.
|
| 65 |
+
for alias, canonical in {**_COMPOUND_ALIASES, **_GENE_ALIASES}.items():
|
| 66 |
+
_add_exact(alias)
|
| 67 |
+
_add_fuzzy(canonical)
|
| 68 |
+
|
| 69 |
+
# KG Compound node display names — clean canonical drug names (fuzzy candidates).
|
| 70 |
+
if graph is not None:
|
| 71 |
+
for _, data in graph.nodes(data=True):
|
| 72 |
+
if data.get("type") == "Compound":
|
| 73 |
+
_add_fuzzy(data.get("display_name", ""))
|
| 74 |
+
|
| 75 |
+
# Trial interventions + enriched targets — exact-match coverage only (often verbose).
|
| 76 |
+
for t in trials or []:
|
| 77 |
+
for iv in t.get("interventions", []):
|
| 78 |
+
_add_exact(iv.get("name", ""))
|
| 79 |
+
for tgt in t.get("target_entities", []):
|
| 80 |
+
_add_exact(tgt)
|
| 81 |
+
|
| 82 |
+
_logger.info("drug vocabulary built", extra={"data": {"exact": len(exact), "fuzzy": len(fuzzy)}})
|
| 83 |
+
return {"exact": frozenset(exact), "fuzzy": fuzzy}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def suggest_drug_term(term: str, vocab: dict[str, object]) -> str | None:
|
| 87 |
+
"""
|
| 88 |
+
Return a "did you mean 'X'?" suggestion for an unrecognized query drug term, or None.
|
| 89 |
+
|
| 90 |
+
NEVER substitutes — the caller keeps searching the original term. Suggestion fires only for
|
| 91 |
+
unknown terms (absent from the exact vocabulary), of sufficient length, whose closest clean
|
| 92 |
+
canonical drug name scores above the floor. Safe by construction: a wrong suggestion cannot
|
| 93 |
+
silently redirect the search onto a different drug; the physician decides.
|
| 94 |
+
"""
|
| 95 |
+
if not term or not term.strip():
|
| 96 |
+
return None
|
| 97 |
+
exact: frozenset = vocab.get("exact", frozenset()) # type: ignore[assignment]
|
| 98 |
+
fuzzy: dict[str, str] = vocab.get("fuzzy", {}) # type: ignore[assignment]
|
| 99 |
+
|
| 100 |
+
n = _norm(term)
|
| 101 |
+
if len(n) < _FUZZY_MIN_LEN or n in exact or not fuzzy:
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
match = process.extractOne(n, list(fuzzy.keys()), scorer=fuzz.ratio)
|
| 105 |
+
if not match:
|
| 106 |
+
return None
|
| 107 |
+
best_key, best_score, _ = match
|
| 108 |
+
if best_score >= _FUZZY_MIN_SCORE and _norm(fuzzy[best_key]) != n:
|
| 109 |
+
resolved = fuzzy[best_key]
|
| 110 |
+
_logger.info("drug typo suggestion", extra={"data": {
|
| 111 |
+
"query_term": term, "suggested": resolved, "score": round(best_score, 1),
|
| 112 |
+
}})
|
| 113 |
+
return resolved
|
| 114 |
+
return None
|
|
@@ -32,6 +32,7 @@ When answering a physician's question, structure your response as follows:
|
|
| 32 |
|
| 33 |
## Key Mechanisms
|
| 34 |
2–3 bullet points summarizing the core biological mechanisms relevant to the query.
|
|
|
|
| 35 |
|
| 36 |
## Entities Involved
|
| 37 |
Brief descriptions of the key genes, proteins, compounds, or pathways involved,
|
|
@@ -53,8 +54,13 @@ Any relevant ALS clinical trials linked to the topic, with NCT ID and status.
|
|
| 53 |
Not a substitute for clinical judgment.*
|
| 54 |
|
| 55 |
Guidelines:
|
| 56 |
-
-
|
| 57 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
- Use clinical language appropriate for a physician audience
|
| 59 |
- If a query falls outside ALS research, note that and answer only from ALS context
|
| 60 |
"""
|
|
|
|
| 32 |
|
| 33 |
## Key Mechanisms
|
| 34 |
2–3 bullet points summarizing the core biological mechanisms relevant to the query.
|
| 35 |
+
End every bullet with the inline PMID(s) that support it, e.g. "(PMID: 33259633)".
|
| 36 |
|
| 37 |
## Entities Involved
|
| 38 |
Brief descriptions of the key genes, proteins, compounds, or pathways involved,
|
|
|
|
| 54 |
Not a substitute for clinical judgment.*
|
| 55 |
|
| 56 |
Guidelines:
|
| 57 |
+
- Begin directly with the structured response — no preamble, no "let me search", no narration of your reasoning steps
|
| 58 |
+
- GROUNDING RULE (non-negotiable): Every factual claim must be directly supported by text in the retrieved excerpt for the PMID you cite. Before citing a PMID, verify the claim actually appears in that paper's excerpt. NEVER cite a PMID because it is topically adjacent — a citation asserts that specific paper supports that specific claim.
|
| 59 |
+
- Do NOT use training knowledge to fill gaps. If a retrieved excerpt does not state it, you cannot assert it with a citation.
|
| 60 |
+
- Honor the `grounding_note` in the search result. If it says the database has no evidence for an entity, state that plainly and do not describe its mechanism or cite any PMID for it — even if you recall information from training. Report only the clinical trials returned, if any.
|
| 61 |
+
- EVIDENCE TIER: Each retrieved paper carries `evidence_tier` and `fulltext_only_mentions`. When you cite a paper for a compound listed in its `fulltext_only_mentions` (i.e. the paper mentions it only in its full text, e.g. a drug-pipeline table, not its abstract), you MUST label that citation, e.g. "(PMID: 40858858 — named in a drug-pipeline table, not a primary study of SPG302)". Never present an `evidence_tier` of "landscape_mention" as a primary mechanistic source.
|
| 62 |
+
- If retrieved evidence is insufficient, say exactly: "The papers retrieved from this database do not contain information about [topic]."
|
| 63 |
+
- DID-YOU-MEAN: If `did_you_mean` maps a query term to a suggested drug name, the term was not recognized. Tell the physician there was no exact match and ask whether they meant the suggested name (e.g. "No exact match for 'primce' — did you mean 'PrimeC'? Re-run with that name to see its trials and evidence."). Never assume the suggestion is correct or fabricate results for it.
|
| 64 |
- Use clinical language appropriate for a physician audience
|
| 65 |
- If a query falls outside ALS research, note that and answer only from ALS context
|
| 66 |
"""
|
|
@@ -15,6 +15,7 @@ dependencies = [
|
|
| 15 |
"rich>=13.0.0",
|
| 16 |
"sentence-transformers>=3.0.0",
|
| 17 |
"openai>=2.44.0",
|
|
|
|
| 18 |
]
|
| 19 |
|
| 20 |
[project.optional-dependencies]
|
|
|
|
| 15 |
"rich>=13.0.0",
|
| 16 |
"sentence-transformers>=3.0.0",
|
| 17 |
"openai>=2.44.0",
|
| 18 |
+
"rapidfuzz>=3.14.5",
|
| 19 |
]
|
| 20 |
|
| 21 |
[project.optional-dependencies]
|
|
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
|
|
| 7 |
import chromadb
|
| 8 |
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
|
|
|
|
| 9 |
|
| 10 |
from config import CHROMA_COLLECTION, CHROMA_DIR, PAPERS_PATH
|
| 11 |
from logging_config import get_logger
|
|
@@ -13,9 +15,12 @@ from models import ALSPaper
|
|
| 13 |
|
| 14 |
_logger = get_logger("rag.indexer")
|
| 15 |
|
|
|
|
|
|
|
|
|
|
| 16 |
# BioLORD-2023-C: anchored to UMLS/SNOMED CT/MeSH ontologies — natively understands
|
| 17 |
# biomedical synonyms (TARDBP = TDP-43, SOD1 = superoxide dismutase) and clinical phrasing.
|
| 18 |
-
_EMBED_FN = SentenceTransformerEmbeddingFunction(model_name="FremyCompany/BioLORD-2023-C")
|
| 19 |
|
| 20 |
|
| 21 |
def _chunk_paper(paper: ALSPaper) -> list[dict]:
|
|
@@ -58,8 +63,16 @@ def _chunk_paper(paper: ALSPaper) -> list[dict]:
|
|
| 58 |
if body:
|
| 59 |
sections.append((current_title, body))
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
chunks = []
|
| 62 |
-
for i, (section_title, section_text) in enumerate(
|
| 63 |
doc = f"{paper.title}\n[{section_title}]\n{section_text}"
|
| 64 |
chunks.append({
|
| 65 |
"id": f"{paper.pmid}_s{i}",
|
|
@@ -127,13 +140,22 @@ def build_collection(
|
|
| 127 |
_logger.info(f"Indexing {len(new_chunks)} new chunks from {len(papers)} papers")
|
| 128 |
|
| 129 |
batch_size = 100
|
| 130 |
-
for i in range(0, len(new_chunks), batch_size)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
_logger.info(f"Collection '{collection_name}': {collection.count()} total chunks")
|
| 139 |
return collection
|
|
|
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
+
import torch
|
| 8 |
import chromadb
|
| 9 |
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
|
| 10 |
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
|
| 11 |
|
| 12 |
from config import CHROMA_COLLECTION, CHROMA_DIR, PAPERS_PATH
|
| 13 |
from logging_config import get_logger
|
|
|
|
| 15 |
|
| 16 |
_logger = get_logger("rag.indexer")
|
| 17 |
|
| 18 |
+
# Use MPS on Apple Silicon, CUDA on NVIDIA, otherwise CPU.
|
| 19 |
+
_DEVICE = "mps" if torch.backends.mps.is_available() else ("cuda" if torch.cuda.is_available() else "cpu")
|
| 20 |
+
|
| 21 |
# BioLORD-2023-C: anchored to UMLS/SNOMED CT/MeSH ontologies — natively understands
|
| 22 |
# biomedical synonyms (TARDBP = TDP-43, SOD1 = superoxide dismutase) and clinical phrasing.
|
| 23 |
+
_EMBED_FN = SentenceTransformerEmbeddingFunction(model_name="FremyCompany/BioLORD-2023-C", device=_DEVICE)
|
| 24 |
|
| 25 |
|
| 26 |
def _chunk_paper(paper: ALSPaper) -> list[dict]:
|
|
|
|
| 63 |
if body:
|
| 64 |
sections.append((current_title, body))
|
| 65 |
|
| 66 |
+
# Prioritise high-value sections; cap at 6 total to keep index lean.
|
| 67 |
+
# With 500 papers the cross-encoder only sees 20 candidates anyway —
|
| 68 |
+
# 50+ chunks per paper adds noise without improving recall.
|
| 69 |
+
_PRIORITY = {"abstract", "introduction", "results", "discussion", "conclusion", "methods"}
|
| 70 |
+
priority = [s for s in sections if s[0].lower() in _PRIORITY]
|
| 71 |
+
others = [s for s in sections if s[0].lower() not in _PRIORITY]
|
| 72 |
+
selected = (priority + others)[:6]
|
| 73 |
+
|
| 74 |
chunks = []
|
| 75 |
+
for i, (section_title, section_text) in enumerate(selected):
|
| 76 |
doc = f"{paper.title}\n[{section_title}]\n{section_text}"
|
| 77 |
chunks.append({
|
| 78 |
"id": f"{paper.pmid}_s{i}",
|
|
|
|
| 140 |
_logger.info(f"Indexing {len(new_chunks)} new chunks from {len(papers)} papers")
|
| 141 |
|
| 142 |
batch_size = 100
|
| 143 |
+
batches = [new_chunks[i : i + batch_size] for i in range(0, len(new_chunks), batch_size)]
|
| 144 |
+
|
| 145 |
+
with Progress(
|
| 146 |
+
TextColumn("[cyan]Embedding chunks[/cyan]"),
|
| 147 |
+
BarColumn(),
|
| 148 |
+
MofNCompleteColumn(),
|
| 149 |
+
TimeElapsedColumn(),
|
| 150 |
+
) as progress:
|
| 151 |
+
task = progress.add_task("", total=len(new_chunks))
|
| 152 |
+
for batch in batches:
|
| 153 |
+
collection.add(
|
| 154 |
+
ids=[c["id"] for c in batch],
|
| 155 |
+
documents=[c["document"] for c in batch],
|
| 156 |
+
metadatas=[c["metadata"] for c in batch],
|
| 157 |
+
)
|
| 158 |
+
progress.advance(task, len(batch))
|
| 159 |
|
| 160 |
_logger.info(f"Collection '{collection_name}': {collection.count()} total chunks")
|
| 161 |
return collection
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import math
|
|
|
|
| 5 |
|
| 6 |
import chromadb
|
| 7 |
|
|
@@ -72,6 +73,154 @@ def search_by_entities(
|
|
| 72 |
return merged[:n_results]
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def rrf_merge(
|
| 76 |
ranked_lists: list[list[dict]],
|
| 77 |
k: int = RRF_K,
|
|
@@ -125,15 +274,31 @@ def cross_encoder_rerank(
|
|
| 125 |
return candidates[:top_n]
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def apply_citation_boost(results: list[dict]) -> list[dict]:
|
| 129 |
"""
|
| 130 |
-
Final score =
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
"""
|
| 134 |
for r in results:
|
| 135 |
base = r.get("ce_score", r.get("similarity", 0.0))
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
results.sort(key=lambda x: x["score"], reverse=True)
|
| 138 |
return results
|
| 139 |
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import math
|
| 5 |
+
import re
|
| 6 |
|
| 7 |
import chromadb
|
| 8 |
|
|
|
|
| 73 |
return merged[:n_results]
|
| 74 |
|
| 75 |
|
| 76 |
+
def _term_variants(term: str) -> list[str]:
|
| 77 |
+
"""
|
| 78 |
+
Generate letter/digit boundary variants of a compound identifier so that
|
| 79 |
+
"SPG302", "SPG 302", and "SPG-302" all resolve to the same papers.
|
| 80 |
+
"SPG302" → ["SPG302", "SPG 302", "SPG-302"]
|
| 81 |
+
"""
|
| 82 |
+
# Collapse any existing space/hyphen at letter–digit boundaries → canonical form
|
| 83 |
+
canonical = re.sub(r'(?<=[A-Za-z])[\s\-](?=\d)|(?<=\d)[\s\-](?=[A-Za-z])', '', term)
|
| 84 |
+
spaced = re.sub(r'([A-Za-z])(\d)', r'\1 \2', canonical)
|
| 85 |
+
hyphenated = re.sub(r'([A-Za-z])(\d)', r'\1-\2', canonical)
|
| 86 |
+
return list(dict.fromkeys([term, canonical, spaced, hyphenated]))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def term_matches_text(text: str, term: str) -> bool:
|
| 90 |
+
"""True if any spacing/hyphen variant of `term` appears in `text` (case-insensitive)."""
|
| 91 |
+
if not text or not term.strip():
|
| 92 |
+
return False
|
| 93 |
+
low = text.lower()
|
| 94 |
+
return any(v.lower() in low for v in _term_variants(term) if v.strip())
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def paper_texts_for_pmids(
|
| 98 |
+
collection: chromadb.Collection,
|
| 99 |
+
pmids: list[str],
|
| 100 |
+
) -> dict[str, dict[str, str]]:
|
| 101 |
+
"""
|
| 102 |
+
Fetch ALL chunks for each PMID in one call and return, per PMID:
|
| 103 |
+
{"abstract": <chunk_index 0 doc>, "full": <all chunk docs concatenated>}.
|
| 104 |
+
Used to distinguish "the paper is actually about this compound" (present in the
|
| 105 |
+
abstract) from an incidental full-text-only mention (present somewhere in the
|
| 106 |
+
body, e.g. a drug-pipeline table, but not the abstract). Fetching every chunk is
|
| 107 |
+
required because the retrieved representative chunk is often NOT the one holding
|
| 108 |
+
the compound name.
|
| 109 |
+
"""
|
| 110 |
+
pmids = [p for p in dict.fromkeys(pmids) if p]
|
| 111 |
+
if not pmids:
|
| 112 |
+
return {}
|
| 113 |
+
try:
|
| 114 |
+
res = collection.get(
|
| 115 |
+
where={"pmid": {"$in": pmids}},
|
| 116 |
+
include=["documents", "metadatas"],
|
| 117 |
+
)
|
| 118 |
+
except Exception:
|
| 119 |
+
return {}
|
| 120 |
+
out: dict[str, dict[str, str]] = {p: {"abstract": "", "full": ""} for p in pmids}
|
| 121 |
+
parts: dict[str, list[str]] = {p: [] for p in pmids}
|
| 122 |
+
for meta, doc in zip(res.get("metadatas", []), res.get("documents", [])):
|
| 123 |
+
pmid = meta.get("pmid", "")
|
| 124 |
+
if pmid not in out:
|
| 125 |
+
continue
|
| 126 |
+
doc = doc or ""
|
| 127 |
+
parts[pmid].append(doc)
|
| 128 |
+
if meta.get("chunk_index") == 0:
|
| 129 |
+
out[pmid]["abstract"] = doc
|
| 130 |
+
for pmid in out:
|
| 131 |
+
out[pmid]["full"] = "\n".join(parts[pmid])
|
| 132 |
+
return out
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def is_grounded_in_abstract(collection: chromadb.Collection, term: str) -> bool:
|
| 136 |
+
"""
|
| 137 |
+
True if any variant of `term` appears in an abstract chunk (chunk_index == 0).
|
| 138 |
+
Signals that at least one paper is genuinely *about* the term, as opposed to
|
| 139 |
+
only naming it in a full-text pipeline/landscape table.
|
| 140 |
+
"""
|
| 141 |
+
if not term.strip() or collection.count() == 0:
|
| 142 |
+
return False
|
| 143 |
+
for variant in _term_variants(term):
|
| 144 |
+
if not variant.strip():
|
| 145 |
+
continue
|
| 146 |
+
try:
|
| 147 |
+
raw = collection.get(
|
| 148 |
+
where={"chunk_index": {"$eq": 0}},
|
| 149 |
+
where_document={"$contains": variant},
|
| 150 |
+
limit=1,
|
| 151 |
+
include=["metadatas"],
|
| 152 |
+
)
|
| 153 |
+
except Exception:
|
| 154 |
+
continue
|
| 155 |
+
if raw.get("ids"):
|
| 156 |
+
return True
|
| 157 |
+
return False
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def is_grounded_in_corpus(collection: chromadb.Collection, term: str) -> bool:
|
| 161 |
+
"""
|
| 162 |
+
True if any spacing/hyphen variant of `term` appears literally in a paper
|
| 163 |
+
(ChromaDB $contains). This is exact-substring presence — the reliable signal
|
| 164 |
+
for "is this named entity actually written in the corpus", as opposed to
|
| 165 |
+
semantic search which always returns nearest neighbors regardless of relevance.
|
| 166 |
+
"""
|
| 167 |
+
if not term.strip() or collection.count() == 0:
|
| 168 |
+
return False
|
| 169 |
+
for variant in _term_variants(term):
|
| 170 |
+
if not variant.strip():
|
| 171 |
+
continue
|
| 172 |
+
try:
|
| 173 |
+
raw = collection.query(
|
| 174 |
+
query_texts=[variant],
|
| 175 |
+
where_document={"$contains": variant},
|
| 176 |
+
n_results=1,
|
| 177 |
+
include=["metadatas"],
|
| 178 |
+
)
|
| 179 |
+
except Exception:
|
| 180 |
+
continue
|
| 181 |
+
if raw.get("ids", [[]])[0]:
|
| 182 |
+
return True
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def search_by_keyword(
|
| 187 |
+
collection: chromadb.Collection,
|
| 188 |
+
terms: list[str],
|
| 189 |
+
n_results: int = 10,
|
| 190 |
+
) -> list[dict]:
|
| 191 |
+
"""
|
| 192 |
+
Exact-substring search using ChromaDB's where_document $contains filter.
|
| 193 |
+
Searches all spacing/hyphen variants of each term so "SPG302", "SPG 302",
|
| 194 |
+
and "SPG-302" all resolve to the same papers. Catches proper nouns (drug
|
| 195 |
+
codes, gene IDs) whose embeddings are meaningless to the model.
|
| 196 |
+
"""
|
| 197 |
+
if not terms or collection.count() == 0:
|
| 198 |
+
return []
|
| 199 |
+
|
| 200 |
+
seen: dict[str, dict] = {}
|
| 201 |
+
for term in terms:
|
| 202 |
+
if not term.strip():
|
| 203 |
+
continue
|
| 204 |
+
for variant in _term_variants(term):
|
| 205 |
+
if not variant.strip():
|
| 206 |
+
continue
|
| 207 |
+
try:
|
| 208 |
+
raw = collection.query(
|
| 209 |
+
query_texts=[variant],
|
| 210 |
+
where_document={"$contains": variant},
|
| 211 |
+
n_results=min(n_results, collection.count()),
|
| 212 |
+
include=["documents", "metadatas", "distances"],
|
| 213 |
+
)
|
| 214 |
+
except Exception:
|
| 215 |
+
continue
|
| 216 |
+
for r in _parse_raw(raw):
|
| 217 |
+
pmid = r["pmid"]
|
| 218 |
+
if pmid not in seen or r["similarity"] > seen[pmid]["similarity"]:
|
| 219 |
+
seen[pmid] = r
|
| 220 |
+
|
| 221 |
+
return _dedup_by_pmid(list(seen.values()))
|
| 222 |
+
|
| 223 |
+
|
| 224 |
def rrf_merge(
|
| 225 |
ranked_lists: list[list[dict]],
|
| 226 |
k: int = RRF_K,
|
|
|
|
| 274 |
return candidates[:top_n]
|
| 275 |
|
| 276 |
|
| 277 |
+
_RECENCY_BASE_YEAR = 1990
|
| 278 |
+
_RECENCY_CURRENT_YEAR = 2025
|
| 279 |
+
_RECENCY_MAX_BOOST = 0.5 # most recent papers get 1.5× vs oldest at 1.0×
|
| 280 |
+
|
| 281 |
+
|
| 282 |
def apply_citation_boost(results: list[dict]) -> list[dict]:
|
| 283 |
"""
|
| 284 |
+
Final score = ce_score × log(citation_count + 2) × recency_factor.
|
| 285 |
+
|
| 286 |
+
Citation factor: log-scaled so each order-of-magnitude in citations adds
|
| 287 |
+
roughly equal weight. log(2) ≈ 0.69 floor for uncited papers.
|
| 288 |
+
|
| 289 |
+
Recency factor: linear 1.0 → 1.5 from 1990 to 2025. A 2024 paper scores
|
| 290 |
+
50% higher than a 1990 paper at equal citation count and relevance, reflecting
|
| 291 |
+
that recent evidence is more likely to reflect current understanding.
|
| 292 |
"""
|
| 293 |
for r in results:
|
| 294 |
base = r.get("ce_score", r.get("similarity", 0.0))
|
| 295 |
+
citation_factor = math.log(r["citation_count"] + 2)
|
| 296 |
+
year = r.get("year") or _RECENCY_BASE_YEAR
|
| 297 |
+
recency_factor = 1.0 + _RECENCY_MAX_BOOST * (
|
| 298 |
+
max(0, year - _RECENCY_BASE_YEAR)
|
| 299 |
+
/ (_RECENCY_CURRENT_YEAR - _RECENCY_BASE_YEAR)
|
| 300 |
+
)
|
| 301 |
+
r["score"] = base * citation_factor * recency_factor
|
| 302 |
results.sort(key=lambda x: x["score"], reverse=True)
|
| 303 |
return results
|
| 304 |
|
|
@@ -1,11 +1,12 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
Ingest ALS clinical trials from ClinicalTrials.gov v2 API.
|
|
|
|
|
|
|
| 4 |
|
| 5 |
Usage:
|
| 6 |
uv run python scripts/ingest_trials.py
|
| 7 |
-
uv run python scripts/ingest_trials.py --
|
| 8 |
-
uv run python scripts/ingest_trials.py --status RECRUITING NOT_YET_RECRUITING COMPLETED
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
@@ -28,22 +29,8 @@ from ingestion.clinicaltrials import fetch_als_trials
|
|
| 28 |
|
| 29 |
console = Console()
|
| 30 |
|
| 31 |
-
_ALL_STATUSES = ["RECRUITING", "COMPLETED", "ACTIVE_NOT_RECRUITING", "NOT_YET_RECRUITING"]
|
| 32 |
-
|
| 33 |
-
|
| 34 |
def main() -> None:
|
| 35 |
parser = argparse.ArgumentParser(description="Ingest ALS clinical trials")
|
| 36 |
-
parser.add_argument(
|
| 37 |
-
"--status",
|
| 38 |
-
nargs="+",
|
| 39 |
-
default=["RECRUITING", "NOT_YET_RECRUITING", "ACTIVE_NOT_RECRUITING"],
|
| 40 |
-
choices=_ALL_STATUSES,
|
| 41 |
-
metavar="STATUS",
|
| 42 |
-
help=(
|
| 43 |
-
f"One or more trial statuses to fetch (default: RECRUITING NOT_YET_RECRUITING "
|
| 44 |
-
f"ACTIVE_NOT_RECRUITING). Choices: {_ALL_STATUSES}"
|
| 45 |
-
),
|
| 46 |
-
)
|
| 47 |
parser.add_argument("--upsert", action="store_true", help="Merge fetched trials into existing trials.jsonl by nct_id")
|
| 48 |
args = parser.parse_args()
|
| 49 |
|
|
@@ -51,8 +38,8 @@ def main() -> None:
|
|
| 51 |
|
| 52 |
client = anthropic.Anthropic()
|
| 53 |
|
| 54 |
-
console.print(
|
| 55 |
-
trials = fetch_als_trials(
|
| 56 |
console.print(f"[green]Fetched {len(trials)} trials[/green]")
|
| 57 |
|
| 58 |
if args.upsert and TRIALS_PATH.exists():
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
Ingest ALS clinical trials from ClinicalTrials.gov v2 API.
|
| 4 |
+
Fetches ALL statuses (recruiting, completed, terminated, withdrawn, etc.)
|
| 5 |
+
so physicians can see the full trial landscape including failed trials.
|
| 6 |
|
| 7 |
Usage:
|
| 8 |
uv run python scripts/ingest_trials.py
|
| 9 |
+
uv run python scripts/ingest_trials.py --upsert
|
|
|
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 29 |
|
| 30 |
console = Console()
|
| 31 |
|
|
|
|
|
|
|
|
|
|
| 32 |
def main() -> None:
|
| 33 |
parser = argparse.ArgumentParser(description="Ingest ALS clinical trials")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
parser.add_argument("--upsert", action="store_true", help="Merge fetched trials into existing trials.jsonl by nct_id")
|
| 35 |
args = parser.parse_args()
|
| 36 |
|
|
|
|
| 38 |
|
| 39 |
client = anthropic.Anthropic()
|
| 40 |
|
| 41 |
+
console.print("[cyan]Fetching all ALS interventional trials (no status filter)...[/cyan]")
|
| 42 |
+
trials = fetch_als_trials(client=client)
|
| 43 |
console.print(f"[green]Fetched {len(trials)} trials[/green]")
|
| 44 |
|
| 45 |
if args.upsert and TRIALS_PATH.exists():
|
|
@@ -453,6 +453,7 @@ dependencies = [
|
|
| 453 |
{ name = "networkx" },
|
| 454 |
{ name = "openai" },
|
| 455 |
{ name = "python-dotenv" },
|
|
|
|
| 456 |
{ name = "rich" },
|
| 457 |
{ name = "sentence-transformers" },
|
| 458 |
]
|
|
@@ -479,6 +480,7 @@ requires-dist = [
|
|
| 479 |
{ name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.35" },
|
| 480 |
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14" },
|
| 481 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
|
|
|
| 482 |
{ name = "rich", specifier = ">=13.0.0" },
|
| 483 |
{ name = "sentence-transformers", specifier = ">=3.0.0" },
|
| 484 |
]
|
|
@@ -3030,6 +3032,85 @@ wheels = [
|
|
| 3030 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 3031 |
]
|
| 3032 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3033 |
[[package]]
|
| 3034 |
name = "referencing"
|
| 3035 |
version = "0.37.0"
|
|
|
|
| 453 |
{ name = "networkx" },
|
| 454 |
{ name = "openai" },
|
| 455 |
{ name = "python-dotenv" },
|
| 456 |
+
{ name = "rapidfuzz" },
|
| 457 |
{ name = "rich" },
|
| 458 |
{ name = "sentence-transformers" },
|
| 459 |
]
|
|
|
|
| 480 |
{ name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.35" },
|
| 481 |
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14" },
|
| 482 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
| 483 |
+
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
| 484 |
{ name = "rich", specifier = ">=13.0.0" },
|
| 485 |
{ name = "sentence-transformers", specifier = ">=3.0.0" },
|
| 486 |
]
|
|
|
|
| 3032 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 3033 |
]
|
| 3034 |
|
| 3035 |
+
[[package]]
|
| 3036 |
+
name = "rapidfuzz"
|
| 3037 |
+
version = "3.14.5"
|
| 3038 |
+
source = { registry = "https://pypi.org/simple" }
|
| 3039 |
+
sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
|
| 3040 |
+
wheels = [
|
| 3041 |
+
{ url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" },
|
| 3042 |
+
{ url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" },
|
| 3043 |
+
{ url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" },
|
| 3044 |
+
{ url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" },
|
| 3045 |
+
{ url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" },
|
| 3046 |
+
{ url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" },
|
| 3047 |
+
{ url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" },
|
| 3048 |
+
{ url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" },
|
| 3049 |
+
{ url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" },
|
| 3050 |
+
{ url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" },
|
| 3051 |
+
{ url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" },
|
| 3052 |
+
{ url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
|
| 3053 |
+
{ url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
|
| 3054 |
+
{ url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
|
| 3055 |
+
{ url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
|
| 3056 |
+
{ url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
|
| 3057 |
+
{ url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
|
| 3058 |
+
{ url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
|
| 3059 |
+
{ url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
|
| 3060 |
+
{ url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
|
| 3061 |
+
{ url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
|
| 3062 |
+
{ url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
|
| 3063 |
+
{ url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" },
|
| 3064 |
+
{ url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" },
|
| 3065 |
+
{ url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" },
|
| 3066 |
+
{ url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" },
|
| 3067 |
+
{ url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" },
|
| 3068 |
+
{ url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" },
|
| 3069 |
+
{ url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" },
|
| 3070 |
+
{ url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" },
|
| 3071 |
+
{ url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" },
|
| 3072 |
+
{ url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" },
|
| 3073 |
+
{ url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" },
|
| 3074 |
+
{ url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" },
|
| 3075 |
+
{ url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" },
|
| 3076 |
+
{ url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" },
|
| 3077 |
+
{ url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" },
|
| 3078 |
+
{ url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" },
|
| 3079 |
+
{ url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" },
|
| 3080 |
+
{ url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" },
|
| 3081 |
+
{ url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" },
|
| 3082 |
+
{ url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" },
|
| 3083 |
+
{ url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" },
|
| 3084 |
+
{ url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" },
|
| 3085 |
+
{ url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" },
|
| 3086 |
+
{ url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" },
|
| 3087 |
+
{ url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" },
|
| 3088 |
+
{ url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" },
|
| 3089 |
+
{ url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" },
|
| 3090 |
+
{ url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" },
|
| 3091 |
+
{ url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" },
|
| 3092 |
+
{ url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" },
|
| 3093 |
+
{ url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" },
|
| 3094 |
+
{ url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" },
|
| 3095 |
+
{ url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" },
|
| 3096 |
+
{ url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" },
|
| 3097 |
+
{ url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" },
|
| 3098 |
+
{ url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" },
|
| 3099 |
+
{ url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" },
|
| 3100 |
+
{ url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" },
|
| 3101 |
+
{ url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" },
|
| 3102 |
+
{ url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" },
|
| 3103 |
+
{ url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" },
|
| 3104 |
+
{ url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" },
|
| 3105 |
+
{ url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" },
|
| 3106 |
+
{ url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
|
| 3107 |
+
{ url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" },
|
| 3108 |
+
{ url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" },
|
| 3109 |
+
{ url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" },
|
| 3110 |
+
{ url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" },
|
| 3111 |
+
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
|
| 3112 |
+
]
|
| 3113 |
+
|
| 3114 |
[[package]]
|
| 3115 |
name = "referencing"
|
| 3116 |
version = "0.37.0"
|