KevinIsInCoding Claude Opus 4.8 commited on
Commit
6e92226
·
1 Parent(s): e53bbe5

feat: retrieval grounding, trial quality, and drug-name typo suggestions

Browse files

Session 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 CHANGED
@@ -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
agents/research_agent.py CHANGED
@@ -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 — prefer KG-linked trials, fall back to text match
182
- related_trials: list[dict] = []
183
- if graph and query_entities:
184
- related_trials = kg_query.find_trials_for_entities(graph, expanded_entities, max_trials=10)
185
-
186
- if not related_trials:
187
- # Exact NCT ID match first — handles "NCT06351592" style queries
188
- nct_ids_in_query = {
189
- w.upper() for w in query_text.split() if w.upper().startswith("NCT")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  }
191
- trial_by_nct = {t.get("nct_id", "").upper(): t for t in trials}
192
- for nct_id in nct_ids_in_query:
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
- if not related_trials and query_entities:
204
- entities_lower = [e.lower() for e in expanded_entities]
205
- for trial in trials:
206
- iv_names = " ".join(iv.get("name", "") for iv in trial.get("interventions", []))
207
- trial_text = f"{trial.get('title', '')} {iv_names}".lower()
208
- if any(e in trial_text for e in entities_lower):
209
- related_trials.append({
210
- "nct_id": trial.get("nct_id", ""),
211
- "title": trial.get("title", ""),
212
- "phase": trial.get("phase", ""),
213
- "status": trial.get("status", ""),
214
- "url": trial.get("url", ""),
215
- })
216
- if len(related_trials) >= 5:
217
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"][:600],
229
  "score": round(r["score"], 3),
 
 
230
  }
231
- for r in top_papers
232
  ],
233
  "query_entities": query_entities,
234
  "expanded_entities": expanded_entities,
 
 
235
  "trials": related_trials,
236
- "evidence_count": len(top_papers),
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
  }
config.py CHANGED
@@ -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 = 500
47
  PUBMED_BATCH_SIZE = 200 # PMIDs per Entrez efetch call
48
 
49
  # Entity extraction
50
- EXTRACTION_BATCH_SIZE = 10 # papers per Claude call
 
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)
extraction/extractor.py CHANGED
@@ -1,12 +1,15 @@
1
  """
2
- Claude Sonnet entity extractor.
3
- Batches 10 papers per API call; resumable via .progress.json.
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
- for i in range(0, len(pending), EXTRACTION_BATCH_SIZE):
77
- batch = pending[i : i + EXTRACTION_BATCH_SIZE]
78
- batch_results = _extract_batch(client, batch, registry)
79
-
80
- for result in batch_results:
81
- out_f.write(json.dumps(result.to_dict()) + "\n")
82
- done_pmids.add(result.pmid)
83
- results.append(result)
84
-
85
- _save_progress(progress_path, done_pmids)
86
- registry.save()
87
- progress.advance(task, len(batch))
88
-
89
- # Respect rate limits between batches
90
- if i + EXTRACTION_BATCH_SIZE < len(pending):
91
- time.sleep(1.0)
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
- try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  response = client.messages.create(
166
  model=EXTRACTION_MODEL,
167
- max_tokens=4096,
168
- system=_EXTRACTION_SYSTEM,
169
- tools=EXTRACTION_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
- response = client.messages.create(
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 3000 chars to stay within token budget for a 10-paper batch
196
- excerpt = text[:3000] if text else paper.abstract[:1000]
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()
ingestion/clinicaltrials.py CHANGED
@@ -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 trials. Returns flat dicts ready for JSONL serialization."""
35
- status_filter = ",".join(status) if isinstance(status, (list, tuple)) else status
36
-
 
 
 
 
 
37
  params: dict[str, str | int] = {
38
  "query.cond": "Amyotrophic Lateral Sclerosis",
39
- "filter.overallStatus": status_filter,
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
- "phase": ", ".join(design_mod.get("phases", [])) or "N/A",
 
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,
ingestion/pmc.py CHANGED
@@ -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
- Sends one PMID at a time batch elink merges all results into one
34
- LinkSet with no per-ID mapping, making it unusable for this purpose.
 
35
  Returns {pmid: pmcid}.
36
  """
37
- _configure_entrez()
 
 
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(pmids))
52
-
53
- for pmid in pmids:
 
 
 
 
 
 
54
  for attempt in range(3):
55
  try:
56
- handle = Entrez.elink(dbfrom="pubmed", db="pmc", id=pmid)
57
- link_sets = Entrez.read(handle)
58
- handle.close()
59
  break
60
  except Exception as exc:
61
  if attempt == 2:
62
- _logger.debug(f"elink failed for PMID {pmid}: {exc}")
63
- link_sets = []
64
  break
65
  time.sleep(2 ** attempt)
66
 
67
- for ls in link_sets:
68
- for db_link in ls.get("LinkSetDb", []):
69
- if db_link.get("DbTo") == "pmc":
70
- links = db_link.get("Link", [])
71
- if links:
72
- result[pmid] = str(links[0]["Id"])
73
- break
74
 
75
- _sleep()
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
- for sec in body.findall(".//sec"):
116
- # Skip nested sections only top-level sec elements under body
117
- if sec in body:
118
- title_el = sec.find("title")
119
- title = (title_el.text or "Section").strip() if title_el is not None else "Section"
120
-
121
- paragraphs: list[str] = []
122
- for p in sec.findall("p"):
123
- text = "".join(p.itertext()).strip()
124
- if text:
125
- paragraphs.append(text)
126
-
127
- if paragraphs:
128
- sections.append(f"[{title}]\n" + "\n".join(paragraphs))
 
 
 
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
ingestion/pubmed.py CHANGED
@@ -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
- handle = Entrez.esearch(db="pubmed", term=query, retmax=max_results)
35
- record = Entrez.read(handle)
36
- handle.close()
37
- pmids = list(record["IdList"])
38
- _logger.info("PubMed esearch", extra={"data": {"count": len(pmids), "query": query[:80]}})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
normalization/__init__.py ADDED
File without changes
normalization/drug_vocab.py ADDED
@@ -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
prompts.py CHANGED
@@ -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
- - Be precise and cite PMIDs for every factual claim where available
57
- - Acknowledge uncertainty where evidence is limited or conflicting
 
 
 
 
 
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
  """
pyproject.toml CHANGED
@@ -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]
rag/indexer.py CHANGED
@@ -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(sections):
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
- batch = new_chunks[i : i + batch_size]
132
- collection.add(
133
- ids=[c["id"] for c in batch],
134
- documents=[c["document"] for c in batch],
135
- metadatas=[c["metadata"] for c in batch],
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
rag/retriever.py CHANGED
@@ -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 = cross_encoder_score × log(citation_count + 2).
131
- Applied after cross-encoder so citation quality amplifies — not corrupts — relevance.
132
- log(2) 0.69 is the floor for uncited papers.
 
 
 
 
 
133
  """
134
  for r in results:
135
  base = r.get("ce_score", r.get("similarity", 0.0))
136
- r["score"] = base * math.log(r["citation_count"] + 2)
 
 
 
 
 
 
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
 
scripts/ingest_trials.py CHANGED
@@ -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 --status RECRUITING
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(f"[cyan]Fetching ALS interventional trials (status={args.status})...[/cyan]")
55
- trials = fetch_als_trials(status=args.status, client=client)
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():
uv.lock CHANGED
@@ -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"