KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
0569d05
·
1 Parent(s): ac8f675

feat(trials): replace hardcoded entity regex with LLM extraction

Browse files

- Remove _KNOWN_TARGETS regex list — cannot handle novel compounds like AMX0114
- Add extract_target_entities_llm(): batches 10 trials per Claude Haiku call,
extracts biological target (gene/protein) not drug name, canonicalizes via
extraction/normalizer. AMX0114 now correctly maps to TARDBP.
- Add data/tools/extract_trial_targets.json tool schema
- Add TRIAL_EXTRACTION_TOOLS export to tools.py
- Widen default status filter from RECRUITING-only to also include
NOT_YET_RECRUITING and ACTIVE_NOT_RECRUITING
- ingest_trials.py: --status now accepts multiple values; creates Anthropic
client and passes it to fetch_als_trials()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

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
+ }
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]