KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
e53bbe5
·
1 Parent(s): 6116e0a

feat: automate ALS seed entities from trials and paper corpus

Browse files

- Fix _add_trials() to create nodes for novel trial compounds with
no paper evidence (type=Unknown, is_trial_derived=True) — directly
fixes SPG302/Tazbentetol not appearing as a KG entity
- Migrate ALS_SEED_ENTITIES from config.py to data/seeds/manual_seeds.json
so domain experts can edit seeds without touching code
- Add scripts/derive_seeds.py: auto-derives seeds from DRUG/BIOLOGICAL
trial interventions and paper entities above SEED_PROMOTION_THRESHOLD
- Add scripts/refresh.py: incremental pipeline orchestrator with state
tracking (--since for papers, --upsert for trials, --dry-run support)
- Add --since / append mode to ingest_papers.py for incremental ingestion
- Add --upsert flag to ingest_trials.py for nct_id-based merge
- Promote guess_entity_type() to normalizer.py as a public function

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

config.py CHANGED
@@ -22,12 +22,27 @@ GRAPH_JSON_PATH = DATA_DIR / "graph" / "als_graph.json"
22
  CHROMA_DIR = DATA_DIR / "chroma"
23
  CHROMA_COLLECTION = "als_papers"
24
 
 
 
 
 
 
 
25
  # PubMed ingestion defaults
 
 
 
 
26
  PUBMED_DEFAULT_QUERY = (
27
  '"amyotrophic lateral sclerosis"[MeSH Major Topic] '
28
  'AND ("2018"[PDAT]:"2024"[PDAT]) '
29
  "AND hasabstract[text]"
30
  )
 
 
 
 
 
31
  PUBMED_DEFAULT_MAX = 500
32
  PUBMED_BATCH_SIZE = 200 # PMIDs per Entrez efetch call
33
 
@@ -53,34 +68,6 @@ CROSS_ENCODER_TOP_N = 15 # final papers sent to Claude for synthesis
53
  KG_EXPANSION_HOPS = 1 # hops for query entity expansion
54
  KG_MIN_EDGE_CONFIDENCE = 0.3 # edges below this are excluded from traversal
55
 
56
- # ALS seed entities — pre-populate the graph before paper-derived extraction
57
- ALS_SEED_ENTITIES = {
58
- "genes": [
59
- "SOD1", "TARDBP", "FUS", "C9orf72", "ATXN2",
60
- "TBK1", "OPTN", "UBQLN2", "VCP", "NEK1",
61
- "ANG", "SETX", "SIGMAR1", "CHCHD10", "MATR3",
62
- ],
63
- "proteins": [
64
- "TDP-43", "FUS protein", "SOD1 protein", "Alsin",
65
- "Optineurin", "p62", "Ubiquilin-2",
66
- ],
67
- "compounds": [
68
- "riluzole", "edaravone", "tofersen", "AMX0035",
69
- "mexiletine", "masitinib", "bosutinib",
70
- ],
71
- "mechanisms": [
72
- "glutamate excitotoxicity", "oxidative stress",
73
- "neuroinflammation", "protein aggregation",
74
- "RNA metabolism dysfunction", "mitochondrial dysfunction",
75
- "axonal transport defect", "autophagy impairment",
76
- ],
77
- "phenotypes": [
78
- "upper motor neuron degeneration", "lower motor neuron degeneration",
79
- "bulbar onset ALS", "spinal onset ALS",
80
- "frontotemporal dementia", "respiratory failure",
81
- ],
82
- }
83
-
84
  # ALS condition synonyms for ClinicalTrials.gov queries
85
  ALS_CONDITION_TERMS = [
86
  "Amyotrophic Lateral Sclerosis",
 
22
  CHROMA_DIR = DATA_DIR / "chroma"
23
  CHROMA_COLLECTION = "als_papers"
24
 
25
+ # Seed entity files
26
+ MANUAL_SEEDS_PATH = DATA_DIR / "seeds" / "manual_seeds.json"
27
+ DERIVED_SEEDS_PATH = DATA_DIR / "seeds" / "derived_seeds.json"
28
+ SEED_PROMOTION_THRESHOLD = 5 # min papers for entity to become a derived seed
29
+ REFRESH_STATE_PATH = DATA_DIR / ".refresh_state.json"
30
+
31
  # PubMed ingestion defaults
32
+ 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
 
 
68
  KG_EXPANSION_HOPS = 1 # hops for query entity expansion
69
  KG_MIN_EDGE_CONFIDENCE = 0.3 # edges below this are excluded from traversal
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  # ALS condition synonyms for ClinicalTrials.gov queries
72
  ALS_CONDITION_TERMS = [
73
  "Amyotrophic Lateral Sclerosis",
data/seeds/manual_seeds.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "genes": [
3
+ "SOD1", "TARDBP", "FUS", "C9orf72", "ATXN2",
4
+ "TBK1", "OPTN", "UBQLN2", "VCP", "NEK1",
5
+ "ANG", "SETX", "SIGMAR1", "CHCHD10", "MATR3"
6
+ ],
7
+ "proteins": [
8
+ "TDP-43", "FUS protein", "SOD1 protein", "Alsin",
9
+ "Optineurin", "p62", "Ubiquilin-2"
10
+ ],
11
+ "compounds": [
12
+ "riluzole", "edaravone", "tofersen", "AMX0035",
13
+ "mexiletine", "masitinib", "bosutinib"
14
+ ],
15
+ "mechanisms": [
16
+ "glutamate excitotoxicity", "oxidative stress",
17
+ "neuroinflammation", "protein aggregation",
18
+ "RNA metabolism dysfunction", "mitochondrial dysfunction",
19
+ "axonal transport defect", "autophagy impairment"
20
+ ],
21
+ "phenotypes": [
22
+ "upper motor neuron degeneration", "lower motor neuron degeneration",
23
+ "bulbar onset ALS", "spinal onset ALS",
24
+ "frontotemporal dementia", "respiratory failure"
25
+ ]
26
+ }
extraction/extractor.py CHANGED
@@ -19,7 +19,7 @@ from config import (
19
  EXTRACTION_PROGRESS_PATH,
20
  PAPERS_PATH,
21
  )
22
- from extraction.normalizer import CanonicalRegistry, normalize_entity
23
  from logging_config import get_logger
24
  from models import ALSPaper, ExtractedEntity, EntityRelationship, PaperExtractionResult
25
  from tools import EXTRACTION_TOOLS
@@ -254,14 +254,7 @@ def _parse_relationships(
254
  return rels
255
 
256
 
257
- def _guess_type(name: str) -> str:
258
- """Best-effort entity type guess from name for relationship source/target."""
259
- from extraction.normalizer import _GENE_ALIASES, _COMPOUND_ALIASES
260
- if name.strip().upper() in _GENE_ALIASES or name.strip() in _GENE_ALIASES:
261
- return "Gene"
262
- if name.strip() in _COMPOUND_ALIASES:
263
- return "Compound"
264
- return "Protein"
265
 
266
 
267
  def _load_papers(path: Path) -> list[ALSPaper]:
 
19
  EXTRACTION_PROGRESS_PATH,
20
  PAPERS_PATH,
21
  )
22
+ from extraction.normalizer import CanonicalRegistry, guess_entity_type, normalize_entity
23
  from logging_config import get_logger
24
  from models import ALSPaper, ExtractedEntity, EntityRelationship, PaperExtractionResult
25
  from tools import EXTRACTION_TOOLS
 
254
  return rels
255
 
256
 
257
+ _guess_type = guess_entity_type
 
 
 
 
 
 
 
258
 
259
 
260
  def _load_papers(path: Path) -> list[ALSPaper]:
extraction/normalizer.py CHANGED
@@ -104,6 +104,16 @@ _MECHANISM_ALIASES: dict[str, str] = {
104
  }
105
 
106
 
 
 
 
 
 
 
 
 
 
 
107
  def normalize_entity(name: str, entity_type: str) -> str:
108
  """Return a canonical_id string in the form '<prefix>:<canonical_name>'."""
109
  canonical = _resolve_name(name.strip(), entity_type)
 
104
  }
105
 
106
 
107
+ def guess_entity_type(name: str) -> str:
108
+ """Best-effort entity type inference from name. Used when type metadata is unavailable."""
109
+ n = name.strip()
110
+ if n.upper() in _GENE_ALIASES or n in _GENE_ALIASES:
111
+ return "Gene"
112
+ if n in _COMPOUND_ALIASES:
113
+ return "Compound"
114
+ return "Protein"
115
+
116
+
117
  def normalize_entity(name: str, entity_type: str) -> str:
118
  """Return a canonical_id string in the form '<prefix>:<canonical_name>'."""
119
  canonical = _resolve_name(name.strip(), entity_type)
graph/builder.py CHANGED
@@ -11,9 +11,10 @@ from pathlib import Path
11
  import networkx as nx
12
 
13
  from config import (
14
- ALS_SEED_ENTITIES,
15
  ENTITIES_PATH,
16
  KG_MIN_EDGE_CONFIDENCE,
 
17
  TRIALS_PATH,
18
  )
19
  from extraction.normalizer import normalize_entity
@@ -59,9 +60,18 @@ def _add_seed_entities(G: nx.DiGraph) -> None:
59
  "mechanisms": "Mechanism",
60
  "phenotypes": "Phenotype",
61
  }
 
 
 
 
 
 
 
62
  for category, entity_type in type_map.items():
63
- for name in ALS_SEED_ENTITIES.get(category, []):
 
64
  canonical_id = normalize_entity(name, entity_type)
 
65
  _upsert_node(G, canonical_id, {
66
  "type": entity_type,
67
  "display_name": name,
@@ -69,6 +79,19 @@ def _add_seed_entities(G: nx.DiGraph) -> None:
69
  "evidence_pmids": [],
70
  "is_seed": True,
71
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  def _add_extracted_entities(G: nx.DiGraph, entities_path: Path) -> int:
@@ -186,7 +209,27 @@ def _add_trials(G: nx.DiGraph, trials_path: Path) -> int:
186
  matched = True
187
  break
188
  if not matched:
189
- _logger.debug(f"Trial {nct_id}: no graph node for target {target_name!r}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  n_trials += 1
192
  return n_trials
 
11
  import networkx as nx
12
 
13
  from config import (
14
+ DERIVED_SEEDS_PATH,
15
  ENTITIES_PATH,
16
  KG_MIN_EDGE_CONFIDENCE,
17
+ MANUAL_SEEDS_PATH,
18
  TRIALS_PATH,
19
  )
20
  from extraction.normalizer import normalize_entity
 
60
  "mechanisms": "Mechanism",
61
  "phenotypes": "Phenotype",
62
  }
63
+
64
+ manual = json.loads(MANUAL_SEEDS_PATH.read_text())
65
+
66
+ derived: dict[str, list[str]] = {}
67
+ if DERIVED_SEEDS_PATH.exists():
68
+ derived = json.loads(DERIVED_SEEDS_PATH.read_text())
69
+
70
  for category, entity_type in type_map.items():
71
+ seen: set[str] = set()
72
+ for name in manual.get(category, []):
73
  canonical_id = normalize_entity(name, entity_type)
74
+ seen.add(canonical_id)
75
  _upsert_node(G, canonical_id, {
76
  "type": entity_type,
77
  "display_name": name,
 
79
  "evidence_pmids": [],
80
  "is_seed": True,
81
  })
82
+ for name in derived.get(category, []):
83
+ canonical_id = normalize_entity(name, entity_type)
84
+ if canonical_id in seen:
85
+ continue
86
+ seen.add(canonical_id)
87
+ _upsert_node(G, canonical_id, {
88
+ "type": entity_type,
89
+ "display_name": name,
90
+ "paper_count": 0,
91
+ "evidence_pmids": [],
92
+ "is_seed": False,
93
+ "is_derived_seed": True,
94
+ })
95
 
96
 
97
  def _add_extracted_entities(G: nx.DiGraph, entities_path: Path) -> int:
 
209
  matched = True
210
  break
211
  if not matched:
212
+ # Novel entity from trial with no paper evidence canonicalize as
213
+ # compound (trial targets are drugs) but type stays Unknown since
214
+ # we can't confirm mechanism/class without literature support.
215
+ fallback_id = normalize_entity(target_name, "Compound")
216
+ _upsert_node(G, fallback_id, {
217
+ "type": "Unknown",
218
+ "display_name": target_name,
219
+ "paper_count": 0,
220
+ "evidence_pmids": [],
221
+ "confidence": 0.0,
222
+ "is_seed": False,
223
+ "is_trial_derived": True,
224
+ })
225
+ G.add_edge(node_id, fallback_id, **{
226
+ "relation_type": "TESTED_IN",
227
+ "relation_types": ["TESTED_IN"],
228
+ "evidence_pmids": [],
229
+ "confidence": 1.0,
230
+ "evidence_text": "",
231
+ })
232
+ _logger.debug(f"Trial {nct_id}: created trial-derived node {fallback_id!r} (type=Unknown)")
233
 
234
  n_trials += 1
235
  return n_trials
scripts/derive_seeds.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Derive dynamic seed entities from the ingested trial and paper corpus.
4
+
5
+ Sources:
6
+ 1. trials.jsonl — DRUG/BIOLOGICAL interventions + LLM-extracted targets
7
+ 2. entities.jsonl — entities appearing in >= SEED_PROMOTION_THRESHOLD papers
8
+
9
+ Writes data/seeds/derived_seeds.json (always overwritten, never hand-edited).
10
+ Run after extract_entities.py and before build_graph.py.
11
+
12
+ Usage:
13
+ uv run python scripts/derive_seeds.py
14
+ uv run python scripts/derive_seeds.py --threshold 3
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+
24
+ sys.path.insert(0, str(Path(__file__).parent.parent))
25
+
26
+ from dotenv import load_dotenv
27
+
28
+ load_dotenv()
29
+
30
+ from rich.console import Console
31
+
32
+ from config import (
33
+ DERIVED_SEEDS_PATH,
34
+ ENTITIES_PATH,
35
+ SEED_PROMOTION_THRESHOLD,
36
+ TRIALS_PATH,
37
+ )
38
+ from extraction.normalizer import normalize_entity, _GENE_ALIASES, _COMPOUND_ALIASES
39
+
40
+ console = Console()
41
+
42
+ _INTERVENTION_BLOCKLIST = {
43
+ "placebo", "standard_care", "standard_of_care", "exercise",
44
+ "physical_therapy", "physiotherapy", "occupational_therapy",
45
+ "riluzole", # already in manual seeds
46
+ "edaravone",
47
+ "best_supportive_care", "nutritional_support", "sham",
48
+ "observation", "usual_care",
49
+ }
50
+
51
+ _EMPTY_SEEDS: dict[str, list[str]] = {
52
+ "genes": [], "proteins": [], "compounds": [], "mechanisms": [], "phenotypes": [],
53
+ }
54
+
55
+ _TYPE_TO_CATEGORY = {
56
+ "Gene": "genes",
57
+ "Protein": "proteins",
58
+ "Compound": "compounds",
59
+ "Mechanism": "mechanisms",
60
+ "Phenotype": "phenotypes",
61
+ "Pathway": "mechanisms",
62
+ }
63
+
64
+
65
+ def _derive_from_trials(trials_path: Path) -> dict[str, set[str]]:
66
+ """Return category → set of display names derived from active trials."""
67
+ result: dict[str, set[str]] = defaultdict(set)
68
+
69
+ if not trials_path.exists():
70
+ console.print(f"[yellow]trials.jsonl not found at {trials_path} — skipping trial seeds[/yellow]")
71
+ return result
72
+
73
+ n_trials = 0
74
+ with open(trials_path, encoding="utf-8") as f:
75
+ for line in f:
76
+ line = line.strip()
77
+ if not line:
78
+ continue
79
+ trial = json.loads(line)
80
+ n_trials += 1
81
+
82
+ # Drug/biological interventions from ClinicalTrials.gov structured data
83
+ for iv in trial.get("interventions", []):
84
+ if iv.get("type", "").upper() not in ("DRUG", "BIOLOGICAL"):
85
+ continue
86
+ name = iv.get("name", "").strip()
87
+ if not name:
88
+ continue
89
+ canonical = normalize_entity(name, "Compound")
90
+ slug = canonical.split(":", 1)[-1]
91
+ if slug not in _INTERVENTION_BLOCKLIST:
92
+ result["compounds"].add(name)
93
+
94
+ # LLM-extracted target entities
95
+ for target in trial.get("target_entities", []):
96
+ target = target.strip()
97
+ if not target:
98
+ continue
99
+ # Infer type via alias tables
100
+ u = target.upper()
101
+ if u in _GENE_ALIASES or target in _GENE_ALIASES:
102
+ result["genes"].add(target)
103
+ elif target in _COMPOUND_ALIASES:
104
+ result["compounds"].add(target)
105
+ else:
106
+ # Unknown — put in compounds (most unmatched trial targets are drugs)
107
+ slug = normalize_entity(target, "Compound").split(":", 1)[-1]
108
+ if slug not in _INTERVENTION_BLOCKLIST:
109
+ result["compounds"].add(target)
110
+
111
+ console.print(f" Scanned {n_trials} trials")
112
+ return result
113
+
114
+
115
+ def _derive_from_entities(entities_path: Path, threshold: int) -> dict[str, set[str]]:
116
+ """Return category → set of display names for entities in >= threshold papers."""
117
+ if not entities_path.exists():
118
+ console.print(f"[yellow]entities.jsonl not found at {entities_path} — skipping paper seeds[/yellow]")
119
+ return defaultdict(set)
120
+
121
+ # canonical_id → {type, display_name, count}
122
+ counts: dict[str, dict] = {}
123
+
124
+ with open(entities_path, encoding="utf-8") as f:
125
+ for line in f:
126
+ line = line.strip()
127
+ if not line:
128
+ continue
129
+ raw = json.loads(line)
130
+ for ent in raw.get("entities", []):
131
+ if ent.get("confidence", 0.0) < 0.6:
132
+ continue
133
+ cid = ent.get("canonical_id", "")
134
+ if not cid:
135
+ continue
136
+ if cid not in counts:
137
+ counts[cid] = {
138
+ "type": ent.get("type", "Unknown"),
139
+ "display_name": ent.get("name", cid.split(":", 1)[-1]),
140
+ "count": 0,
141
+ }
142
+ counts[cid]["count"] += 1
143
+
144
+ result: dict[str, set[str]] = defaultdict(set)
145
+ promoted = 0
146
+ for cid, data in counts.items():
147
+ if data["count"] >= threshold:
148
+ category = _TYPE_TO_CATEGORY.get(data["type"])
149
+ if category:
150
+ result[category].add(data["display_name"])
151
+ promoted += 1
152
+
153
+ console.print(f" {len(counts)} unique entities; {promoted} promoted above threshold={threshold}")
154
+ return result
155
+
156
+
157
+ def main() -> None:
158
+ parser = argparse.ArgumentParser(description="Derive seed entities from corpus")
159
+ parser.add_argument("--trials", type=Path, default=TRIALS_PATH)
160
+ parser.add_argument("--entities", type=Path, default=ENTITIES_PATH)
161
+ parser.add_argument("--threshold", type=int, default=SEED_PROMOTION_THRESHOLD)
162
+ parser.add_argument("--output", type=Path, default=DERIVED_SEEDS_PATH)
163
+ args = parser.parse_args()
164
+
165
+ console.print("[cyan]Deriving seeds from trials...[/cyan]")
166
+ trial_seeds = _derive_from_trials(args.trials)
167
+
168
+ console.print("[cyan]Deriving seeds from paper entities...[/cyan]")
169
+ paper_seeds = _derive_from_entities(args.entities, args.threshold)
170
+
171
+ merged: dict[str, list[str]] = {}
172
+ for category in _EMPTY_SEEDS:
173
+ combined = trial_seeds.get(category, set()) | paper_seeds.get(category, set())
174
+ merged[category] = sorted(combined)
175
+
176
+ args.output.parent.mkdir(parents=True, exist_ok=True)
177
+ args.output.write_text(json.dumps(merged, indent=2, sort_keys=True))
178
+
179
+ total = sum(len(v) for v in merged.values())
180
+ console.print(f"\n[bold green]Done![/bold green] Written to {args.output}")
181
+ console.print(f" Total derived seeds: {total}")
182
+ for cat, names in merged.items():
183
+ if names:
184
+ console.print(f" {cat}: {len(names)} ({', '.join(names[:5])}{'...' if len(names) > 5 else ''})")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
scripts/ingest_papers.py CHANGED
@@ -28,20 +28,27 @@ from rich.progress import track
28
  import ingestion.pmc as pmc
29
  import ingestion.pubmed as pubmed
30
  import ingestion.semantic_scholar as ss
31
- from config import PAPERS_PATH, PUBMED_DEFAULT_MAX, PUBMED_DEFAULT_QUERY
32
 
33
  console = Console()
34
 
35
 
36
  def main() -> None:
37
  parser = argparse.ArgumentParser(description="Ingest ALS papers")
38
- parser.add_argument("--query", default=PUBMED_DEFAULT_QUERY, help="PubMed query string")
 
39
  parser.add_argument("--max", type=int, default=PUBMED_DEFAULT_MAX, dest="max_results", help="Max papers to fetch")
40
  parser.add_argument("--pmid-file", help="Path to file with one PMID per line")
41
  parser.add_argument("--skip-fulltext", action="store_true", help="Skip PMC full text fetch")
42
  parser.add_argument("--skip-citations", action="store_true", help="Skip Semantic Scholar citation counts")
43
  args = parser.parse_args()
44
 
 
 
 
 
 
 
45
  PAPERS_PATH.parent.mkdir(parents=True, exist_ok=True)
46
 
47
  # Step 1: Obtain PMIDs
@@ -58,6 +65,21 @@ def main() -> None:
58
  console.print("[red]No PMIDs found — check your query or PMID file.[/red]")
59
  sys.exit(1)
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  # Step 2: Fetch paper records from PubMed
62
  console.print("[cyan]Fetching Medline records...[/cyan]")
63
  papers = pubmed.fetch_by_pmids(pmids)
@@ -91,8 +113,9 @@ def main() -> None:
91
  paper.citation_count = citation_map.get(paper.pmid, 0)
92
  console.print(f"[green]Got citation counts for {len(citation_map)}/{len(papers)} papers[/green]")
93
 
94
- # Step 5: Write to JSONL
95
- with open(PAPERS_PATH, "w", encoding="utf-8") as f:
 
96
  for paper in papers:
97
  f.write(json.dumps(paper.to_dict()) + "\n")
98
 
 
28
  import ingestion.pmc as pmc
29
  import ingestion.pubmed as pubmed
30
  import ingestion.semantic_scholar as ss
31
+ from config import PAPERS_PATH, PUBMED_DEFAULT_MAX, PUBMED_DEFAULT_QUERY, PUBMED_REFRESH_QUERY_TEMPLATE
32
 
33
  console = Console()
34
 
35
 
36
  def main() -> None:
37
  parser = argparse.ArgumentParser(description="Ingest ALS papers")
38
+ parser.add_argument("--query", default=None, help="PubMed query string (overrides --since)")
39
+ parser.add_argument("--since", metavar="YYYY-MM-DD", help="Fetch papers published on or after this date (incremental append mode)")
40
  parser.add_argument("--max", type=int, default=PUBMED_DEFAULT_MAX, dest="max_results", help="Max papers to fetch")
41
  parser.add_argument("--pmid-file", help="Path to file with one PMID per line")
42
  parser.add_argument("--skip-fulltext", action="store_true", help="Skip PMC full text fetch")
43
  parser.add_argument("--skip-citations", action="store_true", help="Skip Semantic Scholar citation counts")
44
  args = parser.parse_args()
45
 
46
+ if args.query is None:
47
+ if args.since:
48
+ args.query = PUBMED_REFRESH_QUERY_TEMPLATE.format(since_date=args.since)
49
+ else:
50
+ args.query = PUBMED_DEFAULT_QUERY
51
+
52
  PAPERS_PATH.parent.mkdir(parents=True, exist_ok=True)
53
 
54
  # Step 1: Obtain PMIDs
 
65
  console.print("[red]No PMIDs found — check your query or PMID file.[/red]")
66
  sys.exit(1)
67
 
68
+ # In append mode, filter out PMIDs already in the existing corpus
69
+ existing_pmids: set[str] = set()
70
+ if args.since and PAPERS_PATH.exists():
71
+ with open(PAPERS_PATH, encoding="utf-8") as f:
72
+ for line in f:
73
+ line = line.strip()
74
+ if line:
75
+ existing_pmids.add(json.loads(line).get("pmid", ""))
76
+ original_count = len(pmids)
77
+ pmids = [p for p in pmids if p not in existing_pmids]
78
+ console.print(f"[cyan]Incremental mode: {original_count} fetched, {len(existing_pmids)} already known, {len(pmids)} new[/cyan]")
79
+ if not pmids:
80
+ console.print("[green]No new papers found — corpus is up to date.[/green]")
81
+ return
82
+
83
  # Step 2: Fetch paper records from PubMed
84
  console.print("[cyan]Fetching Medline records...[/cyan]")
85
  papers = pubmed.fetch_by_pmids(pmids)
 
113
  paper.citation_count = citation_map.get(paper.pmid, 0)
114
  console.print(f"[green]Got citation counts for {len(citation_map)}/{len(papers)} papers[/green]")
115
 
116
+ # Step 5: Write to JSONL (append in incremental mode, overwrite otherwise)
117
+ write_mode = "a" if args.since else "w"
118
+ with open(PAPERS_PATH, write_mode, encoding="utf-8") as f:
119
  for paper in papers:
120
  f.write(json.dumps(paper.to_dict()) + "\n")
121
 
scripts/ingest_trials.py CHANGED
@@ -44,6 +44,7 @@ def main() -> None:
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)
@@ -54,6 +55,20 @@ def main() -> None:
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:
58
  for trial in trials:
59
  f.write(json.dumps(trial) + "\n")
 
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
 
50
  TRIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
 
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():
59
+ existing: dict[str, dict] = {}
60
+ with open(TRIALS_PATH, encoding="utf-8") as f:
61
+ for line in f:
62
+ line = line.strip()
63
+ if line:
64
+ t = json.loads(line)
65
+ existing[t["nct_id"]] = t
66
+ before = len(existing)
67
+ for trial in trials:
68
+ existing[trial["nct_id"]] = trial
69
+ trials = list(existing.values())
70
+ console.print(f"[cyan]Upsert: {before} existing + {len(trials) - before} new/updated → {len(trials)} total[/cyan]")
71
+
72
  with open(TRIALS_PATH, "w", encoding="utf-8") as f:
73
  for trial in trials:
74
  f.write(json.dumps(trial) + "\n")
scripts/refresh.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Incremental refresh orchestrator. Runs the full pipeline in order, using
4
+ --since for papers and --upsert for trials to avoid full re-ingestion.
5
+
6
+ State is tracked in data/.refresh_state.json. On first run (no state file),
7
+ defaults to fetching papers since 2024-12-31 (end of the initial corpus window).
8
+
9
+ Usage:
10
+ uv run python scripts/refresh.py # incremental from last state
11
+ uv run python scripts/refresh.py --since 2025-01-01
12
+ uv run python scripts/refresh.py --dry-run
13
+ uv run python scripts/refresh.py --skip-papers # trials + graph only
14
+ uv run python scripts/refresh.py --full-rebuild # ignore state, full re-ingest
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import subprocess
21
+ import sys
22
+ from datetime import date
23
+ from pathlib import Path
24
+
25
+ sys.path.insert(0, str(Path(__file__).parent.parent))
26
+
27
+ from dotenv import load_dotenv
28
+
29
+ load_dotenv()
30
+
31
+ from rich.console import Console
32
+
33
+ from config import REFRESH_STATE_PATH
34
+
35
+ console = Console()
36
+
37
+ _SCRIPTS_DIR = Path(__file__).parent
38
+ _DEFAULT_SINCE = "2024-12-31"
39
+
40
+
41
+ def _load_state() -> dict:
42
+ if REFRESH_STATE_PATH.exists():
43
+ return json.loads(REFRESH_STATE_PATH.read_text())
44
+ return {}
45
+
46
+
47
+ def _save_state(state: dict) -> None:
48
+ REFRESH_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
49
+ REFRESH_STATE_PATH.write_text(json.dumps(state, indent=2))
50
+
51
+
52
+ def _run(script: str, *args: str, dry_run: bool = False) -> None:
53
+ cmd = [sys.executable, str(_SCRIPTS_DIR / script), *args]
54
+ console.print(f"[cyan]{'(dry-run) ' if dry_run else ''}Running:[/cyan] {' '.join(cmd)}")
55
+ if dry_run:
56
+ return
57
+ result = subprocess.run(cmd, check=False)
58
+ if result.returncode != 0:
59
+ console.print(f"[red]Script {script} exited with code {result.returncode}[/red]")
60
+ sys.exit(result.returncode)
61
+
62
+
63
+ def main() -> None:
64
+ parser = argparse.ArgumentParser(description="Incremental pipeline refresh")
65
+ parser.add_argument("--since", metavar="YYYY-MM-DD", help="Override since-date for paper ingestion")
66
+ parser.add_argument("--dry-run", action="store_true", help="Print what would run without executing")
67
+ parser.add_argument("--skip-papers", action="store_true", help="Skip paper ingestion (trials + graph only)")
68
+ parser.add_argument("--full-rebuild", action="store_true", help="Full re-ingest, ignoring state")
69
+ args = parser.parse_args()
70
+
71
+ state = _load_state()
72
+ today = str(date.today())
73
+
74
+ if args.full_rebuild:
75
+ console.print("[yellow]Full rebuild requested — ignoring refresh state[/yellow]")
76
+ since_date = None
77
+ elif args.since:
78
+ since_date = args.since
79
+ else:
80
+ since_date = state.get("last_papers_ingest", _DEFAULT_SINCE)
81
+
82
+ console.print(f"[bold]Candle-fire incremental refresh[/bold] — {today}")
83
+ if not args.full_rebuild and not args.skip_papers:
84
+ console.print(f" Fetching papers since: {since_date}")
85
+
86
+ # 1. Papers
87
+ if not args.skip_papers:
88
+ if args.full_rebuild or since_date is None:
89
+ _run("ingest_papers.py", "--skip-fulltext", "--skip-citations", dry_run=args.dry_run)
90
+ else:
91
+ _run("ingest_papers.py", "--since", since_date, "--skip-fulltext", "--skip-citations", dry_run=args.dry_run)
92
+
93
+ # 2. Trials
94
+ _run("ingest_trials.py", "--upsert", dry_run=args.dry_run)
95
+
96
+ # 3. Entity extraction (auto-resumes via .progress.json)
97
+ _run("extract_entities.py", dry_run=args.dry_run)
98
+
99
+ # 4. Derive seeds
100
+ _run("derive_seeds.py", dry_run=args.dry_run)
101
+
102
+ # 5. Build graph
103
+ _run("build_graph.py", dry_run=args.dry_run)
104
+
105
+ # 6. Build index (idempotent — skips existing chunks)
106
+ _run("build_index.py", dry_run=args.dry_run)
107
+
108
+ if not args.dry_run:
109
+ new_state = {**state, "last_papers_ingest": today, "last_trials_ingest": today}
110
+ _save_state(new_state)
111
+ console.print(f"\n[bold green]Refresh complete.[/bold green] State written to {REFRESH_STATE_PATH}")
112
+ else:
113
+ console.print("\n[yellow]Dry run complete — no changes made.[/yellow]")
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()