KevinIsInCoding commited on
Commit
cbc4a98
·
unverified ·
2 Parent(s): b3e42fa5bd89ce

Merge pull request #6 from KevinIsInCoding/feat/stage4-kg-extraction

Browse files
CLAUDE.md CHANGED
@@ -59,6 +59,9 @@ uv run python scripts/build_graph.py
59
 
60
  # 5. Build ChromaDB vector index
61
  uv run python scripts/build_index.py
 
 
 
62
  ```
63
 
64
  ## Key Invariants
 
59
 
60
  # 5. Build ChromaDB vector index
61
  uv run python scripts/build_index.py
62
+
63
+ # 6. run application
64
+ uv run gradio app.py
65
  ```
66
 
67
  ## Key Invariants
agents/research_agent.py CHANGED
@@ -162,7 +162,7 @@ def _handle_search(
162
  # Step 4: Trial matching — prefer KG-linked trials, fall back to text match
163
  related_trials: list[dict] = []
164
  if graph and query_entities:
165
- related_trials = kg_query.find_trials_for_entities(graph, expanded_entities, max_trials=5)
166
 
167
  if not related_trials and query_entities:
168
  entities_lower = [e.lower() for e in expanded_entities]
 
162
  # Step 4: Trial matching — prefer KG-linked trials, fall back to text match
163
  related_trials: list[dict] = []
164
  if graph and query_entities:
165
+ related_trials = kg_query.find_trials_for_entities(graph, expanded_entities, max_trials=10)
166
 
167
  if not related_trials and query_entities:
168
  entities_lower = [e.lower() for e in expanded_entities]
app.py CHANGED
@@ -129,7 +129,7 @@ with gr.Blocks(title="Candle-Fire — ALS Research Intelligence") as demo:
129
  height=520,
130
  show_label=False,
131
  sanitize_html=False,
132
- avatar_images=(None, "https://api.dicebear.com/7.x/icons/svg?seed=candle&icon=flame"),
133
  placeholder="Ask a question about ALS research to get started.",
134
  )
135
 
 
129
  height=520,
130
  show_label=False,
131
  sanitize_html=False,
132
+ avatar_images=(None, "assets/flame.svg"),
133
  placeholder="Ask a question about ALS research to get started.",
134
  )
135
 
assets/flame.svg ADDED
config.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
 
3
  # Models
4
  SYNTHESIS_MODEL = "claude-sonnet-4-6"
5
- EXTRACTION_MODEL = "claude-sonnet-4-6"
6
 
7
  # External API endpoints
8
  CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
 
2
 
3
  # Models
4
  SYNTHESIS_MODEL = "claude-sonnet-4-6"
5
+ EXTRACTION_MODEL = "claude-haiku-4-5-20251001"
6
 
7
  # External API endpoints
8
  CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
data/tools/extract_trial_targets.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ "nct_id": {
5
+ "type": "string",
6
+ "description": "The ClinicalTrials.gov identifier (e.g. NCT12345678)."
7
+ },
8
+ "targets": {
9
+ "type": "array",
10
+ "description": "Primary biological targets modulated or studied in this trial.",
11
+ "items": {
12
+ "type": "object",
13
+ "properties": {
14
+ "name": {
15
+ "type": "string",
16
+ "description": "Standard name of the target (e.g. TARDBP, SOD1, riluzole, neuroinflammation). Use canonical gene symbols where applicable."
17
+ },
18
+ "type": {
19
+ "type": "string",
20
+ "enum": ["Gene", "Protein", "Compound", "Mechanism"],
21
+ "description": "Category of the target."
22
+ },
23
+ "confidence": {
24
+ "type": "number",
25
+ "minimum": 0.0,
26
+ "maximum": 1.0,
27
+ "description": "Confidence that this is the intended biological target (1.0 = explicit in text)."
28
+ }
29
+ },
30
+ "required": ["name", "type", "confidence"]
31
+ }
32
+ }
33
+ },
34
+ "required": ["nct_id", "targets"]
35
+ }
extraction/extractor.py CHANGED
@@ -99,34 +99,11 @@ def _extract_batch(
99
  registry: CanonicalRegistry,
100
  ) -> list[PaperExtractionResult]:
101
  """Send a batch of papers to Claude and collect one extract_entities call per paper."""
102
- user_content = _format_batch(batch)
103
-
104
- try:
105
- response = client.messages.create(
106
- model=EXTRACTION_MODEL,
107
- max_tokens=4096,
108
- system=_EXTRACTION_SYSTEM,
109
- tools=EXTRACTION_TOOLS,
110
- tool_choice={"type": "any"},
111
- messages=[{"role": "user", "content": user_content}],
112
- )
113
- except anthropic.RateLimitError:
114
- _logger.warning("Rate limited — sleeping 30s")
115
- time.sleep(30)
116
- response = client.messages.create(
117
- model=EXTRACTION_MODEL,
118
- max_tokens=4096,
119
- system=_EXTRACTION_SYSTEM,
120
- tools=EXTRACTION_TOOLS,
121
- tool_choice={"type": "any"},
122
- messages=[{"role": "user", "content": user_content}],
123
- )
124
-
125
- # Build a PMID→paper lookup so we can match extracted results back
126
  paper_by_pmid = {p.pmid: p for p in batch}
 
127
 
128
  results: list[PaperExtractionResult] = []
129
- for block in response.content:
130
  if block.type != "tool_use" or block.name != "extract_entities":
131
  continue
132
 
@@ -151,16 +128,63 @@ def _extract_batch(
151
  # Mark paper entity_names (used downstream by RAG indexer on re-index)
152
  paper.entity_names = [e.canonical_id for e in entities]
153
 
154
- # For any paper with no Claude response, add an empty result so it's not re-processed
155
  found_pmids = {r.pmid for r in results}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  for p in batch:
157
  if p.pmid not in found_pmids:
158
- _logger.warning(f"No extraction result for PMID {p.pmid} — recording empty")
159
  results.append(PaperExtractionResult(pmid=p.pmid, entities=[], relationships=[]))
160
 
161
  return results
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  def _format_batch(batch: list[ALSPaper]) -> str:
165
  parts = [
166
  f"Extract entities from each of the following {len(batch)} ALS papers. "
 
99
  registry: CanonicalRegistry,
100
  ) -> list[PaperExtractionResult]:
101
  """Send a batch of papers to Claude and collect one extract_entities call per paper."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  paper_by_pmid = {p.pmid: p for p in batch}
103
+ content_blocks = _call_claude(client, batch)
104
 
105
  results: list[PaperExtractionResult] = []
106
+ for block in content_blocks:
107
  if block.type != "tool_use" or block.name != "extract_entities":
108
  continue
109
 
 
128
  # Mark paper entity_names (used downstream by RAG indexer on re-index)
129
  paper.entity_names = [e.canonical_id for e in entities]
130
 
131
+ # Retry any papers Claude missed send them individually
132
  found_pmids = {r.pmid for r in results}
133
+ missed = [p for p in batch if p.pmid not in found_pmids]
134
+ if missed:
135
+ _logger.info(f"Retrying {len(missed)} missed papers individually")
136
+ for paper in missed:
137
+ retry_results = _call_claude(client, [paper])
138
+ for block in retry_results:
139
+ if block.type != "tool_use" or block.name != "extract_entities":
140
+ continue
141
+ inp = block.input
142
+ pmid = str(inp.get("pmid", ""))
143
+ if not pmid or pmid not in paper_by_pmid:
144
+ continue
145
+ entities = _parse_entities(inp.get("entities", []), pmid, registry)
146
+ relationships = _parse_relationships(inp.get("relationships", []), pmid, registry)
147
+ results.append(PaperExtractionResult(pmid=pmid, entities=entities, relationships=relationships))
148
+ paper_by_pmid[pmid].entity_names = [e.canonical_id for e in entities]
149
+ found_pmids.add(pmid)
150
+ _logger.info(f"Retry succeeded for PMID {pmid}")
151
+ time.sleep(0.5)
152
+
153
+ # Any still-missing after retry → record empty so they're not re-attempted
154
  for p in batch:
155
  if p.pmid not in found_pmids:
156
+ _logger.warning(f"No extraction result for PMID {p.pmid} after retry — recording empty")
157
  results.append(PaperExtractionResult(pmid=p.pmid, entities=[], relationships=[]))
158
 
159
  return results
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:
189
  parts = [
190
  f"Extract entities from each of the following {len(batch)} ALS papers. "
graph/query.py CHANGED
@@ -72,12 +72,19 @@ def expand_query_entities(
72
  return display_names
73
 
74
 
 
 
 
75
  def find_trials_for_entities(
76
  G: nx.DiGraph,
77
  entity_names: list[str],
78
- max_trials: int = 5,
79
  ) -> list[dict]:
80
- """Return clinical trials linked to the given entity names."""
 
 
 
 
81
  if not G or not entity_names:
82
  return []
83
 
@@ -86,31 +93,37 @@ def find_trials_for_entities(
86
  matched = _find_node(G, name)
87
  target_nodes.update(matched)
88
 
89
- trials: list[dict] = []
90
- seen: set[str] = set()
 
91
 
92
  for node_id in target_nodes:
93
- # Trials point TO their targets, so look at predecessors
94
  for pred in G.predecessors(node_id):
95
  if not pred.startswith("trial:"):
96
  continue
97
  nct_id = G.nodes[pred].get("nct_id", "")
98
- if nct_id in seen:
99
  continue
100
- seen.add(nct_id)
101
- trials.append({
102
- "nct_id": nct_id,
103
- "title": G.nodes[pred].get("display_name", ""),
104
- "phase": G.nodes[pred].get("phase", ""),
105
- "status": G.nodes[pred].get("status", ""),
106
- "url": G.nodes[pred].get("url", ""),
107
- })
108
- if len(trials) >= max_trials:
109
- break
110
- if len(trials) >= max_trials:
111
- break
112
-
113
- return trials
 
 
 
 
 
 
114
 
115
 
116
  def get_entity_evidence(G: nx.DiGraph, canonical_id: str) -> dict:
 
72
  return display_names
73
 
74
 
75
+ _STATUS_RANK = {"RECRUITING": 0, "ACTIVE_NOT_RECRUITING": 1, "NOT_YET_RECRUITING": 2, "COMPLETED": 3}
76
+
77
+
78
  def find_trials_for_entities(
79
  G: nx.DiGraph,
80
  entity_names: list[str],
81
+ max_trials: int = 10,
82
  ) -> list[dict]:
83
+ """Return clinical trials linked to the given entity names.
84
+
85
+ Collects all matches, scores by number of linked entities, sorts by
86
+ status (RECRUITING first) then score, and returns the top max_trials.
87
+ """
88
  if not G or not entity_names:
89
  return []
90
 
 
93
  matched = _find_node(G, name)
94
  target_nodes.update(matched)
95
 
96
+ # score[nct_id] = number of query entities this trial links to
97
+ scores: dict[str, int] = {}
98
+ meta: dict[str, dict] = {}
99
 
100
  for node_id in target_nodes:
 
101
  for pred in G.predecessors(node_id):
102
  if not pred.startswith("trial:"):
103
  continue
104
  nct_id = G.nodes[pred].get("nct_id", "")
105
+ if not nct_id:
106
  continue
107
+ scores[nct_id] = scores.get(nct_id, 0) + 1
108
+ if nct_id not in meta:
109
+ status = G.nodes[pred].get("status", "")
110
+ meta[nct_id] = {
111
+ "nct_id": nct_id,
112
+ "title": G.nodes[pred].get("display_name", ""),
113
+ "phase": G.nodes[pred].get("phase", ""),
114
+ "status": status,
115
+ "url": G.nodes[pred].get("url", ""),
116
+ "_status_rank": _STATUS_RANK.get(status, 9),
117
+ }
118
+
119
+ ranked = sorted(
120
+ meta.values(),
121
+ key=lambda t: (t["_status_rank"], -scores[t["nct_id"]]),
122
+ )
123
+ for t in ranked:
124
+ del t["_status_rank"]
125
+
126
+ return ranked[:max_trials]
127
 
128
 
129
  def get_entity_evidence(G: nx.DiGraph, canonical_id: str) -> dict:
ingestion/clinicaltrials.py CHANGED
@@ -1,47 +1,42 @@
1
  """ClinicalTrials.gov v2 client for ALS trials (adapted from beacon/trials_api.py)."""
2
  from __future__ import annotations
3
 
4
- import re
5
  import time
 
6
 
7
  import httpx
8
 
9
- from config import CTGOV_BASE
10
  from logging_config import get_logger
11
 
 
 
 
12
  _logger = get_logger("ingestion.clinicaltrials")
13
 
14
- # Known ALS-relevant targets for heuristic entity linking
15
- _KNOWN_TARGETS: list[tuple[re.Pattern, str]] = [
16
- (re.compile(r"\bSOD1\b", re.IGNORECASE), "SOD1"),
17
- (re.compile(r"\bTARDBP\b", re.IGNORECASE), "TARDBP"),
18
- (re.compile(r"\bTDP-?43\b", re.IGNORECASE), "TARDBP"),
19
- (re.compile(r"\bFUS\b", re.IGNORECASE), "FUS"),
20
- (re.compile(r"\bC9orf72\b", re.IGNORECASE), "C9orf72"),
21
- (re.compile(r"\bATXN2\b", re.IGNORECASE), "ATXN2"),
22
- (re.compile(r"\bTBK1\b", re.IGNORECASE), "TBK1"),
23
- (re.compile(r"\bNEK1\b", re.IGNORECASE), "NEK1"),
24
- (re.compile(r"\bVCP\b", re.IGNORECASE), "VCP"),
25
- (re.compile(r"\briluzole\b", re.IGNORECASE), "riluzole"),
26
- (re.compile(r"\bedaravone\b", re.IGNORECASE), "edaravone"),
27
- (re.compile(r"\btofersen\b", re.IGNORECASE), "tofersen"),
28
- (re.compile(r"\bAMX0035\b", re.IGNORECASE), "AMX0035"),
29
- (re.compile(r"\bmasitinib\b", re.IGNORECASE), "masitinib"),
30
- (re.compile(r"\bbosutinib\b", re.IGNORECASE), "bosutinib"),
31
- (re.compile(r"\bmexiletine\b", re.IGNORECASE), "mexiletine"),
32
- (re.compile(r"\bantisense oligonucleotide\b", re.IGNORECASE), "antisense oligonucleotide"),
33
- (re.compile(r"\bASO\b"), "antisense oligonucleotide"),
34
- (re.compile(r"\bsiRNA\b", re.IGNORECASE), "siRNA"),
35
- (re.compile(r"\bstem cell\b", re.IGNORECASE), "stem cell"),
36
- (re.compile(r"\bgene therapy\b", re.IGNORECASE), "gene therapy"),
37
- ]
38
-
39
-
40
- def fetch_als_trials(status: str = "RECRUITING") -> list[dict]:
41
  """Fetch ALS interventional trials. Returns flat dicts ready for JSONL serialization."""
 
 
42
  params: dict[str, str | int] = {
43
  "query.cond": "Amyotrophic Lateral Sclerosis",
44
- "filter.overallStatus": status,
45
  "aggFilters": "studyType:int",
46
  "pageSize": 1000,
47
  "format": "json",
@@ -58,9 +53,8 @@ def fetch_als_trials(status: str = "RECRUITING") -> list[dict]:
58
  except httpx.HTTPError as exc:
59
  if attempt == 2:
60
  raise
61
- wait = 2 ** attempt
62
  _logger.warning(f"ClinicalTrials.gov error (attempt {attempt + 1}): {exc}")
63
- time.sleep(wait)
64
 
65
  page_studies = body.get("studies", [])
66
  all_studies.extend(page_studies)
@@ -74,6 +68,10 @@ def fetch_als_trials(status: str = "RECRUITING") -> list[dict]:
74
  params["pageToken"] = next_token
75
 
76
  trials = [_flatten_trial(s) for s in all_studies]
 
 
 
 
77
  _logger.info("ALS trial fetch complete", extra={"data": {"total": len(trials)}})
78
  return trials
79
 
@@ -88,16 +86,14 @@ def _flatten_trial(study: dict) -> dict:
88
  status_mod = proto.get("statusModule", {})
89
 
90
  nct_id = id_mod.get("nctId", "")
91
- title = id_mod.get("briefTitle", "")
92
  interventions = [
93
  {"type": iv.get("type", ""), "name": iv.get("name", "")}
94
  for iv in arms_mod.get("interventions", [])
95
  ]
96
- intervention_names = " ".join(iv["name"] for iv in interventions)
97
 
98
  return {
99
  "nct_id": nct_id,
100
- "title": title,
101
  "phase": ", ".join(design_mod.get("phases", [])) or "N/A",
102
  "status": status_mod.get("overallStatus", ""),
103
  "sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
@@ -105,14 +101,93 @@ def _flatten_trial(study: dict) -> dict:
105
  "interventions": interventions,
106
  "start_date": status_mod.get("startDateStruct", {}).get("date", ""),
107
  "url": f"https://clinicaltrials.gov/study/{nct_id}" if nct_id else "",
108
- "target_entities": extract_target_entities(f"{title} {intervention_names}"),
109
  }
110
 
111
 
112
- def extract_target_entities(text: str) -> list[str]:
113
- """Scan text for known ALS target names. Returns sorted canonical entity names."""
114
- found: set[str] = set()
115
- for pattern, canonical in _KNOWN_TARGETS:
116
- if pattern.search(text):
117
- found.add(canonical)
118
- return sorted(found)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """ClinicalTrials.gov v2 client for ALS trials (adapted from beacon/trials_api.py)."""
2
  from __future__ import annotations
3
 
 
4
  import time
5
+ from typing import TYPE_CHECKING
6
 
7
  import httpx
8
 
9
+ from config import CTGOV_BASE, EXTRACTION_MODEL
10
  from logging_config import get_logger
11
 
12
+ if TYPE_CHECKING:
13
+ import anthropic
14
+
15
  _logger = get_logger("ingestion.clinicaltrials")
16
 
17
+ _TRIAL_BATCH_SIZE = 10
18
+
19
+ _TRIAL_EXTRACTION_SYSTEM = """You are a biomedical NLP expert specializing in ALS (amyotrophic lateral sclerosis) clinical trials.
20
+
21
+ For each trial provided, identify the primary biological target(s) being tested or modulated:
22
+ - Genes silenced or corrected (e.g., SOD1, TARDBP, FUS, C9orf72, NEK1, VCP, TBK1)
23
+ - Proteins targeted (use canonical gene symbol, e.g. TARDBP for TDP-43 protein)
24
+ - Compounds/drugs — report the molecular or pathway target, not the drug name (e.g., a trial of AMX0114 targets TARDBP)
25
+ - Mechanisms (e.g., neuroinflammation, oxidative stress, glutamate excitotoxicity)
26
+
27
+ Call extract_trial_targets once per trial. Return an empty targets list only when no specific molecular or mechanistic target is identifiable."""
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",
 
53
  except httpx.HTTPError as exc:
54
  if attempt == 2:
55
  raise
 
56
  _logger.warning(f"ClinicalTrials.gov error (attempt {attempt + 1}): {exc}")
57
+ time.sleep(2 ** attempt)
58
 
59
  page_studies = body.get("studies", [])
60
  all_studies.extend(page_studies)
 
68
  params["pageToken"] = next_token
69
 
70
  trials = [_flatten_trial(s) for s in all_studies]
71
+
72
+ if client is not None:
73
+ _enrich_targets_llm(trials, client)
74
+
75
  _logger.info("ALS trial fetch complete", extra={"data": {"total": len(trials)}})
76
  return trials
77
 
 
86
  status_mod = proto.get("statusModule", {})
87
 
88
  nct_id = id_mod.get("nctId", "")
 
89
  interventions = [
90
  {"type": iv.get("type", ""), "name": iv.get("name", "")}
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", ""),
 
101
  "interventions": interventions,
102
  "start_date": status_mod.get("startDateStruct", {}).get("date", ""),
103
  "url": f"https://clinicaltrials.gov/study/{nct_id}" if nct_id else "",
104
+ "target_entities": [],
105
  }
106
 
107
 
108
+ def _enrich_targets_llm(trials: list[dict], client: "anthropic.Anthropic") -> None:
109
+ """Call Claude in batches to extract biological targets; mutates each trial in-place."""
110
+ from extraction.normalizer import normalize_entity
111
+ from tools import TRIAL_EXTRACTION_TOOLS
112
+
113
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeRemainingColumn
114
+
115
+ with Progress(
116
+ SpinnerColumn(),
117
+ TextColumn("[progress.description]{task.description}"),
118
+ BarColumn(),
119
+ TaskProgressColumn(),
120
+ TimeRemainingColumn(),
121
+ ) as progress:
122
+ task = progress.add_task("Extracting trial targets (LLM)...", total=len(trials))
123
+
124
+ for i in range(0, len(trials), _TRIAL_BATCH_SIZE):
125
+ batch = trials[i : i + _TRIAL_BATCH_SIZE]
126
+ results = _call_claude_batch(client, batch, TRIAL_EXTRACTION_TOOLS)
127
+
128
+ for nct_id, raw_targets in results.items():
129
+ trial = next((t for t in batch if t["nct_id"] == nct_id), None)
130
+ if trial is None:
131
+ continue
132
+ canonical: list[str] = []
133
+ for t in raw_targets:
134
+ if t.get("confidence", 0) < 0.5:
135
+ continue
136
+ canon_id = normalize_entity(t["name"], t["type"])
137
+ # strip prefix (e.g. "protein:TARDBP" → "TARDBP")
138
+ canon_name = canon_id.split(":", 1)[-1]
139
+ if canon_name and canon_name not in canonical:
140
+ canonical.append(canon_name)
141
+ trial["target_entities"] = canonical
142
+
143
+ progress.advance(task, len(batch))
144
+
145
+ if i + _TRIAL_BATCH_SIZE < len(trials):
146
+ time.sleep(1.0)
147
+
148
+
149
+ def _call_claude_batch(
150
+ client: "anthropic.Anthropic",
151
+ batch: list[dict],
152
+ tools: list,
153
+ ) -> dict[str, list[dict]]:
154
+ """Send one batch of trials to Claude; return {nct_id: [target dicts]}."""
155
+ lines = [
156
+ f"Extract targets from each of the following {len(batch)} ALS clinical trials. "
157
+ "Call extract_trial_targets once per trial.\n"
158
+ ]
159
+ for trial in batch:
160
+ iv_names = ", ".join(iv["name"] for iv in trial.get("interventions", [])) or "N/A"
161
+ summary = (trial.get("summary") or "")[:400]
162
+ lines.append(
163
+ f"--- NCT: {trial['nct_id']} ---\n"
164
+ f"Title: {trial['title']}\n"
165
+ f"Interventions: {iv_names}\n"
166
+ f"Summary: {summary}\n"
167
+ )
168
+
169
+ for attempt in range(3):
170
+ try:
171
+ response = client.messages.create(
172
+ model=EXTRACTION_MODEL,
173
+ max_tokens=4096,
174
+ system=_TRIAL_EXTRACTION_SYSTEM,
175
+ tools=tools,
176
+ tool_choice={"type": "any"},
177
+ messages=[{"role": "user", "content": "\n".join(lines)}],
178
+ )
179
+ break
180
+ except Exception as exc:
181
+ if attempt == 2:
182
+ _logger.warning(f"Claude trial extraction failed: {exc}")
183
+ return {}
184
+ time.sleep(30 if "rate" in str(exc).lower() else 2 ** attempt)
185
+
186
+ results: dict[str, list[dict]] = {}
187
+ for block in response.content:
188
+ if block.type == "tool_use" and block.name == "extract_trial_targets":
189
+ nct_id = block.input.get("nct_id", "")
190
+ if nct_id:
191
+ results[nct_id] = block.input.get("targets", [])
192
+
193
+ return results
scripts/ingest_trials.py CHANGED
@@ -5,7 +5,7 @@ Ingest ALS clinical trials from ClinicalTrials.gov v2 API.
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 COMPLETED
9
  """
10
  from __future__ import annotations
11
 
@@ -20,6 +20,7 @@ from dotenv import load_dotenv
20
 
21
  load_dotenv()
22
 
 
23
  from rich.console import Console
24
 
25
  from config import TRIALS_PATH
@@ -27,21 +28,30 @@ from ingestion.clinicaltrials import fetch_als_trials
27
 
28
  console = Console()
29
 
 
 
30
 
31
  def main() -> None:
32
  parser = argparse.ArgumentParser(description="Ingest ALS clinical trials")
33
  parser.add_argument(
34
  "--status",
35
- default="RECRUITING",
36
- choices=["RECRUITING", "COMPLETED", "ACTIVE_NOT_RECRUITING", "NOT_YET_RECRUITING"],
37
- help="Trial status filter (default: RECRUITING)",
 
 
 
 
 
38
  )
39
  args = parser.parse_args()
40
 
41
  TRIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
42
 
 
 
43
  console.print(f"[cyan]Fetching ALS interventional trials (status={args.status})...[/cyan]")
44
- trials = fetch_als_trials(status=args.status)
45
  console.print(f"[green]Fetched {len(trials)} trials[/green]")
46
 
47
  with open(TRIALS_PATH, "w", encoding="utf-8") as f:
 
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
 
 
20
 
21
  load_dotenv()
22
 
23
+ import anthropic
24
  from rich.console import Console
25
 
26
  from config import TRIALS_PATH
 
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
  args = parser.parse_args()
48
 
49
  TRIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
50
 
51
+ client = anthropic.Anthropic()
52
+
53
  console.print(f"[cyan]Fetching ALS interventional trials (status={args.status})...[/cyan]")
54
+ trials = fetch_als_trials(status=args.status, client=client)
55
  console.print(f"[green]Fetched {len(trials)} trials[/green]")
56
 
57
  with open(TRIALS_PATH, "w", encoding="utf-8") as f:
tools.py CHANGED
@@ -34,5 +34,15 @@ SEARCH_LANDSCAPE_TOOL: anthropic.types.ToolParam = {
34
  "input_schema": _load("search_landscape"),
35
  }
36
 
 
 
 
 
 
 
 
 
 
37
  EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_ENTITIES_TOOL]
 
38
  RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_LANDSCAPE_TOOL]
 
34
  "input_schema": _load("search_landscape"),
35
  }
36
 
37
+ EXTRACT_TRIAL_TARGETS_TOOL: anthropic.types.ToolParam = {
38
+ "name": "extract_trial_targets",
39
+ "description": (
40
+ "Extract the primary biological target(s) of an ALS clinical trial — the gene, protein, "
41
+ "compound, or mechanism being tested or modulated. Call once per trial."
42
+ ),
43
+ "input_schema": _load("extract_trial_targets"),
44
+ }
45
+
46
  EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_ENTITIES_TOOL]
47
+ TRIAL_EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_TRIAL_TARGETS_TOOL]
48
  RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_LANDSCAPE_TOOL]